diff --git a/ai-tools/cursor.mdx b/ai-tools/cursor.mdx
index 8a402974f..98354a6f4 100644
--- a/ai-tools/cursor.mdx
+++ b/ai-tools/cursor.mdx
@@ -1,6 +1,6 @@
---
title: "Cursor Integration"
-description: "Use Cursor as an agent provider in ADE — launch sessions, route models, and track work through the Agent Control Protocol (ACP)."
+description: "Use Cursor as an agent provider in ADE — launch sessions, route models, and track work through the Cursor SDK."
icon: "arrow-pointer"
---
@@ -9,9 +9,9 @@ icon: "arrow-pointer"
ADE integrates with [Cursor](https://cursor.com) in two ways:
1. **Launch Cursor from ADE** — open your project or a lane's worktree in Cursor from the Run tab
-2. **Cursor as an agent provider** — use Cursor's AI agent as a chat provider inside ADE via the Agent Control Protocol (ACP)
+2. **Cursor as an agent provider** — use Cursor's AI agent as a chat provider inside ADE via the Cursor SDK
-The ACP integration means Cursor's agent capabilities are available directly in ADE's chat interface, alongside Claude, Codex, and other providers. You can switch between providers per-session.
+The SDK integration means Cursor's agent capabilities are available directly in ADE's chat interface, alongside Claude, Codex, and other providers. You can switch between providers per-session.
---
@@ -19,11 +19,11 @@ The ACP integration means Cursor's agent capabilities are available directly in
| Requirement | Details |
|------------|---------|
-| **Cursor installed** | Cursor desktop app must be installed on your machine |
-| **Cursor agent CLI** | The Cursor `agent` executable must be available (ADE checks standard install paths and PATH) |
-| **Authentication** | Cursor must be authenticated — either sign in through the Cursor app or set `CURSOR_API_KEY` / `CURSOR_AUTH_TOKEN` environment variables |
+| **Cursor installed** | Cursor desktop app must be installed to launch Cursor from ADE |
+| **Cursor SDK auth** | Cursor chat requires `CURSOR_API_KEY` or a Cursor API key saved in ADE Settings |
+| **Authentication** | ADE uses API-key authentication for Cursor chat. Cursor desktop sign-in is only needed for launching the editor. |
-ADE auto-detects the Cursor CLI executable and checks authentication status during onboarding and in **Settings → AI**. When environment-based credentials (`CURSOR_API_KEY` or `CURSOR_AUTH_TOKEN`) are present, ADE recognizes them as valid authentication signals and the AI status indicator in Settings may reflect those variables rather than an interactive sign-in session.
+ADE checks Cursor API-key authentication during onboarding and in **Settings → AI**. When `CURSOR_API_KEY` is present, ADE recognizes it as a valid authentication signal. You can also save the key in ADE's encrypted provider settings.
---
@@ -35,16 +35,16 @@ The session is linked back to the active lane for tracking. File changes made in
---
-## Cursor as an agent provider (ACP)
+## Cursor as an agent provider
-When Cursor's agent executable is detected, ADE can use it as a chat provider through the **Agent Control Protocol (ACP)**.
+When Cursor API-key auth is configured, ADE can use Cursor as a chat provider through the **Cursor SDK**.
### How it works
-1. ADE resolves the Cursor agent executable path (checks standard install locations and PATH)
-2. An ACP session pool manages connections to the Cursor agent process
-3. Chat messages are routed through the ACP protocol, and the Cursor agent has access to the `ade` CLI for ADE workflows
-4. Model discovery queries Cursor for available models and capabilities
+1. ADE starts an isolated SDK worker for the active lane
+2. The worker creates or resumes a Cursor SDK agent with the selected model
+3. Chat messages are routed through SDK runs, and Cursor has access to the `ade` CLI for ADE workflows
+4. Model discovery queries Cursor SDK metadata for available models and capabilities
### Selecting Cursor in chat
@@ -61,10 +61,10 @@ ADE queries Cursor for its available models at startup and caches the result. Th
| Behavior | Details |
|----------|---------|
-| **Session lifecycle** | Managed by the ACP pool; sessions are reused when possible |
-| **Event mapping** | ACP events are mapped to ADE's chat event model for consistent UI |
+| **Session lifecycle** | Managed by the Cursor SDK pool; sessions are reused when possible |
+| **Event mapping** | SDK events are mapped to ADE's chat event model for consistent UI |
| **ADE CLI access** | The `ade` CLI is on `PATH` so Cursor agents can call ADE lane, git, PR, and action tools |
-| **Interrupts** | Supported via ACP session cancel |
+| **Interrupts** | Supported via SDK run cancel |
---
@@ -74,9 +74,9 @@ ADE shows Cursor's connection status in **Settings → AI**:
| Status | Meaning |
|--------|---------|
-| **Connected** (green) | Cursor agent executable found, authenticated, and ready |
-| **CLI found, not authenticated** (amber) | Cursor is installed but not signed in — sign in through the Cursor app |
-| **Not found** (gray) | Cursor CLI not detected — install Cursor or add it to PATH |
+| **Connected** (green) | Cursor API key found and ready |
+| **Not authenticated** (amber) | Cursor API key is missing or invalid |
+| **Not found** (gray) | Cursor desktop is not detected for editor launch |
---
@@ -84,15 +84,15 @@ ADE shows Cursor's connection status in **Settings → AI**:
- ADE checks standard installation paths for the Cursor agent executable. If Cursor is installed in a non-standard location, ensure the `agent` command (Cursor agent CLI) is available in your PATH. You can verify by running `which agent` (macOS/Linux) or `where agent` (Windows) in a terminal. If neither works, try running `agent --version` directly — if the command is not found, add the directory containing the `agent` binary to your PATH environment variable.
+ ADE checks standard installation paths for the Cursor desktop app when launching the editor. If Cursor is installed in a non-standard location, open the project directly from Cursor or add the app to the standard Applications location.
- Open the Cursor desktop app and sign in. ADE detects Cursor's auth status through the CLI — it does not manage Cursor credentials directly.
+ Add a Cursor API key in **Settings → AI Providers** or set `CURSOR_API_KEY` in the environment used to launch ADE.
Model discovery runs at ADE startup. If you installed Cursor after starting ADE, restart ADE or go to **Settings → AI** and click **Refresh** to re-run model discovery.
- If ACP sessions fail, check that Cursor is running and authenticated. The ACP pool retries connections with backoff. Check ADE's developer logs (**Settings → Developer → Log file**) for detailed ACP error messages.
+ If SDK sessions fail, check that the Cursor API key is configured and that the selected model is available. Check ADE's developer logs (**Settings → Developer → Log file**) for detailed SDK worker errors.
diff --git a/ai-tools/windsurf.mdx b/ai-tools/windsurf.mdx
index 4cf3594ac..1642a3205 100644
--- a/ai-tools/windsurf.mdx
+++ b/ai-tools/windsurf.mdx
@@ -121,11 +121,11 @@ ADE supports multiple external AI tools. Here is how Windsurf compares:
|-----------|----------|--------|-------------|
| **Launch from ADE** | Yes | Yes | Yes |
| **Session tracking** | Yes | Yes | Yes |
-| **Agent provider in ADE chat** | No | Yes (via ACP) | Yes (via CLI) |
+| **Agent provider in ADE chat** | No | Yes (via SDK) | Yes (via CLI) |
| **ADE CLI access** | No | Yes | Yes |
| **Model routing through ADE** | No | Yes | Yes |
-Windsurf is a good choice when you want to use Codeium's AI features directly in the editor while keeping the session tracked in ADE. For deeper integration where ADE manages the AI agent lifecycle, use Cursor (via ACP) or Claude Code (via CLI).
+Windsurf is a good choice when you want to use Codeium's AI features directly in the editor while keeping the session tracked in ADE. For deeper integration where ADE manages the AI agent lifecycle, use Cursor (via SDK) or Claude Code (via CLI).
---
diff --git a/apps/desktop/native/ios-sim-helpers/README.md b/apps/desktop/native/ios-sim-helpers/README.md
index 7e7178a28..c54145800 100644
--- a/apps/desktop/native/ios-sim-helpers/README.md
+++ b/apps/desktop/native/ios-sim-helpers/README.md
@@ -12,13 +12,16 @@ streaming and touch input on macOS.
Indigo; unsupported keyboard/text operations are reported as typed failures so
ADE can fall back to idb for that method.
- `build.sh` compiles both helpers lazily into `build/xcode--/`.
+ Set `ADE_IOS_SIM_HELPER_BUILD_ROOT` to place that cache somewhere else;
+ packaged ADE builds use this to keep generated binaries outside the signed
+ `.app` bundle.
These helpers intentionally use Apple private frameworks. They are local
developer tooling, not app runtime code. Keep the supported Xcode major-version
set explicit in `iosSimulatorService.ts`, and expand it only after testing the
-helpers against that Xcode. `iosurface-indigo` is currently gated off in
-packaged ADE builds until the helper signing/notarization story is cleared; set
-`ADE_IOS_SURFACE_ALLOW_PACKAGED=1` only for explicit packaging experiments.
+helpers against that Xcode. Packaged ADE builds ship these sources as resources
+and compile the selected-Xcode helper binaries into the user's ADE cache at
+runtime.
To rebuild manually:
diff --git a/apps/desktop/native/ios-sim-helpers/build.sh b/apps/desktop/native/ios-sim-helpers/build.sh
index a243b12f2..f6969bc7e 100644
--- a/apps/desktop/native/ios-sim-helpers/build.sh
+++ b/apps/desktop/native/ios-sim-helpers/build.sh
@@ -2,7 +2,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-BUILD_ROOT="$SCRIPT_DIR/build"
+BUILD_ROOT="${ADE_IOS_SIM_HELPER_BUILD_ROOT:-"$SCRIPT_DIR/build"}"
PRINT_JSON=0
SMOKE=0
@@ -82,6 +82,15 @@ if [[ ! -x "$CAPTURE" || ! -x "$INPUT" ]]; then
-o "$INPUT"
fi
+if command -v codesign >/dev/null 2>&1; then
+ if ! codesign --force --sign - "$CAPTURE" >/dev/null 2>&1; then
+ echo "warning: failed to ad-hoc sign $CAPTURE" >&2
+ fi
+ if ! codesign --force --sign - "$INPUT" >/dev/null 2>&1; then
+ echo "warning: failed to ad-hoc sign $INPUT" >&2
+ fi
+fi
+
if [[ "$SMOKE" == "1" ]]; then
"$CAPTURE" --smoke-test >/dev/null
"$INPUT" --smoke-test >/dev/null
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 8439ebe60..89315dc03 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -34,6 +34,7 @@
"test:coverage": "vitest run --coverage",
"test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose",
"test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose",
+ "test:chat-model-runtime-audit": "node ./scripts/audit-chat-model-runtime.mjs --mode=dry-run --max-per-provider=2",
"ade:dev": "npm --prefix ../ade-cli run dev -- --project-root ../..",
"ade:build": "npm --prefix ../ade-cli run build",
"ade:typecheck": "npm --prefix ../ade-cli run typecheck",
@@ -227,7 +228,19 @@
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"notarize": true,
"mergeASARs": false,
- "x64ArchFiles": "Contents/Resources/app.asar.unpacked/{node_modules,vendor}/**/*"
+ "x64ArchFiles": "Contents/Resources/app.asar.unpacked/{node_modules,vendor}/**/*",
+ "extraResources": [
+ {
+ "from": "native/ios-sim-helpers",
+ "to": "native/ios-sim-helpers",
+ "filter": [
+ "README.md",
+ "build.sh",
+ "sim-capture.swift",
+ "sim-input.m"
+ ]
+ }
+ ]
}
}
}
diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs
index 520ca5475..81adb715a 100644
--- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs
+++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs
@@ -123,10 +123,16 @@ module.exports = async function afterPack(context) {
if (platform === "darwin") {
const bundledCliBinPath = path.join(resourcesRoot, "ade-cli", "bin", "ade");
const bundledCliInstallerPath = path.join(resourcesRoot, "ade-cli", "install-path.sh");
+ const iosSimHelperRoot = path.join(resourcesRoot, "native", "ios-sim-helpers");
+ const iosSimHelperBuildScript = path.join(iosSimHelperRoot, "build.sh");
requireFile(bundledCliBinPath, "bundled ADE CLI wrapper");
requireFile(bundledCliInstallerPath, "bundled ADE CLI PATH installer");
+ requireFile(iosSimHelperBuildScript, "bundled iOS simulator helper build script");
+ requireFile(path.join(iosSimHelperRoot, "sim-capture.swift"), "bundled iOS simulator capture helper source");
+ requireFile(path.join(iosSimHelperRoot, "sim-input.m"), "bundled iOS simulator input helper source");
fs.chmodSync(bundledCliBinPath, 0o755);
fs.chmodSync(bundledCliInstallerPath, 0o755);
+ fs.chmodSync(iosSimHelperBuildScript, 0o755);
} else if (platform === "win32") {
requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade.cmd"), "bundled ADE CLI Windows wrapper");
requireFile(path.join(resourcesRoot, "ade-cli", "install-path.cmd"), "bundled ADE CLI Windows PATH installer");
diff --git a/apps/desktop/scripts/audit-chat-model-runtime.mjs b/apps/desktop/scripts/audit-chat-model-runtime.mjs
new file mode 100644
index 000000000..1b1eebb8a
--- /dev/null
+++ b/apps/desktop/scripts/audit-chat-model-runtime.mjs
@@ -0,0 +1,1109 @@
+#!/usr/bin/env node
+
+import cp from "node:child_process";
+import fs from "node:fs/promises";
+import net from "node:net";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import vm from "node:vm";
+import { setTimeout as sleep } from "node:timers/promises";
+import WebSocket from "ws";
+
+const VALID_PROVIDERS = ["claude", "codex", "opencode", "cursor", "droid"];
+const DEFAULT_CODEX_REASONING_EFFORT = "medium";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const desktopRoot = path.resolve(__dirname, "..");
+const repoRoot = path.resolve(desktopRoot, "../..");
+
+function usage() {
+ return `
+Usage:
+ node ./scripts/audit-chat-model-runtime.mjs [options]
+
+Modes:
+ --mode=list Print advertised models and reasoning options.
+ --mode=dry-run Build launch payloads and verify expected model/reasoning fields. Default.
+ --mode=smoke Use the ADE preload over CDP to create, verify, and clean up chat sessions.
+
+Catalog sources:
+ --source=registry Read src/shared/modelRegistry.ts. Default for list/dry-run.
+ --source=chat-models Read window.ade.agentChat.models via an Electron renderer.
+ --source=auto registry for list/dry-run, chat-models for smoke.
+
+Filters and limits:
+ --providers=claude,codex,opencode,cursor,droid
+ --provider=claude May be repeated.
+ --max-per-provider=2 Limit models per provider.
+ --max-reasoning-per-model=1 Limit reasoning efforts per model.
+ --max-cases=20 Limit total launch cases.
+
+Smoke options:
+ --cdp-port=9222 Existing ADE Electron CDP port.
+ --start-dev Start npm run dev and stop it before exit.
+ --project-root= Project to open before creating sessions. Default: repository root.
+ --lane-id= Lane to use. Default: primary/current project lane.
+ --send Also send a tiny prompt. This can spend provider quota.
+ --send-text= Prompt used with --send.
+ --timeout-ms=30000 Per-operation timeout inside the renderer.
+
+Other:
+ --activate-runtime Pass activateRuntime to chat.models endpoint.
+ --json Print machine-readable JSON.
+ --verbose Print per-case details.
+ --help Show this help.
+`.trim();
+}
+
+function parseArgs(argv) {
+ const options = {
+ mode: "dry-run",
+ source: "auto",
+ providerValues: [],
+ maxPerProvider: Number.POSITIVE_INFINITY,
+ maxReasoningPerModel: Number.POSITIVE_INFINITY,
+ maxCases: Number.POSITIVE_INFINITY,
+ cdpPort: parseFirstValidPort([
+ process.env.ADE_MODEL_AUDIT_CDP_PORT,
+ process.env.ADE_ELECTRON_REMOTE_DEBUGGING_PORT,
+ process.env.ADE_APP_CONTROL_CDP_PORT,
+ ]),
+ startDev: false,
+ projectRoot: repoRoot,
+ laneId: null,
+ send: false,
+ sendText: "ADE model runtime smoke. Reply with OK only.",
+ timeoutMs: 30_000,
+ activateRuntime: false,
+ json: false,
+ verbose: false,
+ help: false,
+ };
+
+ const readValue = (arg, index) => {
+ const eq = arg.indexOf("=");
+ if (eq >= 0) return { value: arg.slice(eq + 1), consumed: 0 };
+ if (index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
+ throw new Error(`Missing value for ${arg}`);
+ }
+ return { value: argv[index + 1], consumed: 1 };
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ continue;
+ }
+ if (arg === "--list") {
+ options.mode = "list";
+ continue;
+ }
+ if (arg === "--dry-run") {
+ options.mode = "dry-run";
+ continue;
+ }
+ if (arg === "--smoke" || arg === "--launch") {
+ options.mode = "smoke";
+ continue;
+ }
+ if (arg === "--start-dev") {
+ options.startDev = true;
+ continue;
+ }
+ if (arg === "--send") {
+ options.send = true;
+ continue;
+ }
+ if (arg === "--activate-runtime") {
+ options.activateRuntime = true;
+ continue;
+ }
+ if (arg === "--json") {
+ options.json = true;
+ continue;
+ }
+ if (arg === "--verbose") {
+ options.verbose = true;
+ continue;
+ }
+
+ const key = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
+ if (key === "--mode") {
+ const read = readValue(arg, index);
+ options.mode = read.value;
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--source") {
+ const read = readValue(arg, index);
+ options.source = read.value;
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--providers" || key === "--provider") {
+ const read = readValue(arg, index);
+ options.providerValues.push(read.value);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--max-per-provider") {
+ const read = readValue(arg, index);
+ options.maxPerProvider = parsePositiveInteger(read.value, key);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--max-reasoning-per-model") {
+ const read = readValue(arg, index);
+ options.maxReasoningPerModel = parsePositiveInteger(read.value, key);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--max-cases") {
+ const read = readValue(arg, index);
+ options.maxCases = parsePositiveInteger(read.value, key);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--cdp-port") {
+ const read = readValue(arg, index);
+ options.cdpPort = parsePort(read.value, key);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--project-root") {
+ const read = readValue(arg, index);
+ options.projectRoot = path.resolve(read.value);
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--lane-id") {
+ const read = readValue(arg, index);
+ options.laneId = read.value.trim() || null;
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--send-text") {
+ const read = readValue(arg, index);
+ options.sendText = read.value;
+ index += read.consumed;
+ continue;
+ }
+ if (key === "--timeout-ms") {
+ const read = readValue(arg, index);
+ options.timeoutMs = parsePositiveInteger(read.value, key);
+ index += read.consumed;
+ continue;
+ }
+
+ throw new Error(`Unknown option: ${arg}`);
+ }
+
+ if (!["list", "dry-run", "smoke"].includes(options.mode)) {
+ throw new Error(`Unsupported --mode '${options.mode}'.`);
+ }
+ if (!["auto", "registry", "chat-models"].includes(options.source)) {
+ throw new Error(`Unsupported --source '${options.source}'.`);
+ }
+ options.providers = parseProviders(options.providerValues);
+ options.resolvedSource = options.source === "auto"
+ ? options.mode === "smoke" ? "chat-models" : "registry"
+ : options.source;
+ if ((options.mode === "smoke" || options.resolvedSource === "chat-models") && !Number.isFinite(options.cdpPort)) {
+ throw new Error("A valid CDP port is required for smoke mode or chat-models source.");
+ }
+ if (options.send && options.mode !== "smoke") {
+ throw new Error("--send is only valid with --mode=smoke.");
+ }
+ return options;
+}
+
+function parsePositiveInteger(value, label) {
+ const parsed = Number.parseInt(String(value).trim(), 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ throw new Error(`${label} must be a positive integer.`);
+ }
+ return parsed;
+}
+
+function parsePort(value, label) {
+ const parsed = parsePositiveInteger(value, label);
+ if (parsed > 65535) throw new Error(`${label} must be <= 65535.`);
+ return parsed;
+}
+
+function parseFirstValidPort(values, fallback = 9222) {
+ for (const value of values) {
+ if (value == null || String(value).trim() === "") continue;
+ try {
+ return parsePort(value, "CDP port");
+ } catch {
+ // Try the next configured source.
+ }
+ }
+ return fallback;
+}
+
+function parseProviders(values) {
+ const raw = values.length ? values.join(",") : VALID_PROVIDERS.join(",");
+ const providers = raw
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ for (const provider of providers) {
+ if (!VALID_PROVIDERS.includes(provider)) {
+ throw new Error(`Unsupported provider '${provider}'. Expected one of: ${VALID_PROVIDERS.join(", ")}.`);
+ }
+ }
+ return [...new Set(providers)];
+}
+
+function info(options, message) {
+ if (!options.json) process.stderr.write(`${message}\n`);
+}
+
+async function loadModelRegistry() {
+ const tsImport = await import("typescript");
+ const ts = tsImport.default ?? tsImport;
+ const filePath = path.join(desktopRoot, "src", "shared", "modelRegistry.ts");
+ const source = await fs.readFile(filePath, "utf8");
+ const transpiled = ts.transpileModule(source, {
+ fileName: filePath,
+ reportDiagnostics: true,
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2020,
+ esModuleInterop: true,
+ },
+ });
+ const diagnostics = transpiled.diagnostics ?? [];
+ const blocking = diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
+ if (blocking.length) {
+ const text = ts.formatDiagnosticsWithColorAndContext(blocking, {
+ getCanonicalFileName: (fileName) => fileName,
+ getCurrentDirectory: () => desktopRoot,
+ getNewLine: () => "\n",
+ });
+ throw new Error(`Unable to load model registry:\n${text}`);
+ }
+
+ const module = { exports: {} };
+ const sandbox = {
+ module,
+ exports: module.exports,
+ console,
+ };
+ vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath });
+ return module.exports;
+}
+
+function buildDescriptorIndexes(registry) {
+ const byId = new Map();
+ const byProviderModelId = new Map();
+ for (const descriptor of registry.MODEL_REGISTRY ?? []) {
+ const provider = registry.resolveProviderGroupForModel(descriptor);
+ byId.set(descriptor.id, descriptor);
+ byId.set(`${provider}:${descriptor.id}`, descriptor);
+ if (descriptor.shortId) byId.set(`${provider}:${descriptor.shortId}`, descriptor);
+ for (const alias of descriptor.aliases ?? []) {
+ byId.set(`${provider}:${alias}`, descriptor);
+ }
+ byProviderModelId.set(`${provider}:${descriptor.providerModelId}`, descriptor);
+ byProviderModelId.set(`${provider}:${descriptor.id}`, descriptor);
+ }
+ return { byId, byProviderModelId };
+}
+
+function modelFromDescriptor(registry, descriptor, advertisedProvider, source) {
+ const provider = registry.resolveProviderGroupForModel(descriptor);
+ return {
+ source,
+ advertisedProvider,
+ provider,
+ id: descriptor.id,
+ modelId: descriptor.id,
+ runtimeModel: registry.getRuntimeModelRefForDescriptor(descriptor, provider),
+ sessionModel: descriptor.isCliWrapped ? descriptor.providerModelId : descriptor.id,
+ displayName: descriptor.displayName,
+ family: descriptor.family,
+ supportsReasoning: descriptor.capabilities?.reasoning === true,
+ supportsTools: descriptor.capabilities?.tools === true,
+ reasoningEfforts: descriptor.capabilities?.reasoning === true
+ ? normalizeEfforts(descriptor.reasoningTiers)
+ : [],
+ color: descriptor.color ?? null,
+ descriptorFound: true,
+ };
+}
+
+function matchDescriptor(registry, indexes, provider, row) {
+ const belongsToProvider = (descriptor) =>
+ registry.resolveProviderGroupForModel(descriptor) === provider;
+
+ const candidates = [
+ row?.modelId,
+ row?.id,
+ ].filter((value) => typeof value === "string" && value.trim().length > 0);
+ for (const candidate of candidates) {
+ const normalized = candidate.trim();
+ const scoped = indexes.byId.get(`${provider}:${normalized}`);
+ if (scoped && belongsToProvider(scoped)) return scoped;
+
+ const providerScoped = registry.resolveModelDescriptorForProvider?.(normalized, provider);
+ if (providerScoped && belongsToProvider(providerScoped)) return providerScoped;
+
+ const byId = indexes.byId.get(normalized) ?? registry.getModelById?.(normalized);
+ if (byId && belongsToProvider(byId)) return byId;
+ }
+ if (typeof row?.id === "string") {
+ const descriptor = indexes.byProviderModelId.get(`${provider}:${row.id.trim()}`);
+ if (descriptor) return descriptor;
+ }
+ return null;
+}
+
+function modelFromEndpointRow(registry, indexes, row, provider) {
+ const descriptor = matchDescriptor(registry, indexes, provider, row);
+ if (descriptor) {
+ const model = modelFromDescriptor(registry, descriptor, provider, "chat-models");
+ const endpointEfforts = normalizeEfforts((row.reasoningEfforts ?? []).map((entry) => entry?.effort));
+ return {
+ ...model,
+ id: typeof row.id === "string" && row.id.trim() ? row.id.trim() : model.id,
+ displayName: typeof row.displayName === "string" && row.displayName.trim()
+ ? row.displayName.trim()
+ : model.displayName,
+ reasoningEfforts: endpointEfforts.length ? endpointEfforts : model.reasoningEfforts,
+ supportsReasoning: row.supportsReasoning === false ? false : model.supportsReasoning,
+ supportsTools: row.supportsTools === false ? false : model.supportsTools,
+ };
+ }
+
+ const id = typeof row?.id === "string" ? row.id.trim() : "";
+ const modelId = typeof row?.modelId === "string" && row.modelId.trim().length
+ ? row.modelId.trim()
+ : id.includes("/") ? id : null;
+ const runtimeModel = provider === "claude" || provider === "codex"
+ ? id
+ : modelId ?? id;
+ return {
+ source: "chat-models",
+ advertisedProvider: provider,
+ provider,
+ id,
+ modelId,
+ runtimeModel,
+ sessionModel: runtimeModel,
+ displayName: typeof row?.displayName === "string" && row.displayName.trim() ? row.displayName.trim() : id,
+ family: typeof row?.family === "string" ? row.family : null,
+ supportsReasoning: row?.supportsReasoning === true || Array.isArray(row?.reasoningEfforts),
+ supportsTools: row?.supportsTools === true,
+ reasoningEfforts: normalizeEfforts((row?.reasoningEfforts ?? []).map((entry) => entry?.effort)),
+ color: typeof row?.color === "string" ? row.color : null,
+ descriptorFound: false,
+ };
+}
+
+function normalizeEfforts(values) {
+ const efforts = [];
+ for (const value of values ?? []) {
+ if (typeof value !== "string") continue;
+ const effort = value.trim().toLowerCase();
+ if (!effort || efforts.includes(effort)) continue;
+ efforts.push(effort);
+ }
+ return efforts;
+}
+
+function collectRegistryModels(registry, providers) {
+ return providers.flatMap((provider) =>
+ registry
+ .listModelDescriptorsForProvider(provider)
+ .filter((descriptor) => !descriptor.deprecated)
+ .map((descriptor) => modelFromDescriptor(registry, descriptor, provider, "registry"))
+ );
+}
+
+async function collectEndpointModels(client, registry, providers, activateRuntime) {
+ const indexes = buildDescriptorIndexes(registry);
+ const all = [];
+ for (const provider of providers) {
+ const rows = await evaluateAde(client, async (args) => {
+ return await window.ade.agentChat.models({
+ provider: args.provider,
+ activateRuntime: args.activateRuntime,
+ });
+ }, { provider, activateRuntime });
+ for (const row of rows ?? []) {
+ all.push(modelFromEndpointRow(registry, indexes, row, provider));
+ }
+ }
+ return all;
+}
+
+function limitModelsByProvider(models, maxPerProvider) {
+ if (!Number.isFinite(maxPerProvider)) return models;
+ const counts = new Map();
+ return models.filter((model) => {
+ const count = counts.get(model.advertisedProvider) ?? 0;
+ if (count >= maxPerProvider) return false;
+ counts.set(model.advertisedProvider, count + 1);
+ return true;
+ });
+}
+
+function buildCases(models, options) {
+ const cases = [];
+ for (const model of models) {
+ if (!model.runtimeModel) continue;
+ const efforts = model.reasoningEfforts.length
+ ? model.reasoningEfforts
+ : [null];
+ const limitedEfforts = Number.isFinite(options.maxReasoningPerModel)
+ ? efforts.slice(0, options.maxReasoningPerModel)
+ : efforts;
+ for (const effort of limitedEfforts) {
+ if (cases.length >= options.maxCases) return cases;
+ const payload = {
+ provider: model.provider,
+ model: model.runtimeModel,
+ sessionProfile: "light",
+ permissionMode: "plan",
+ ...(model.modelId ? { modelId: model.modelId } : {}),
+ ...(effort ? { reasoningEffort: effort } : {}),
+ };
+ const expectedReasoningEffort = effort
+ ?? (model.provider === "codex" && model.supportsReasoning ? DEFAULT_CODEX_REASONING_EFFORT : null);
+ const expectedSessionModel = model.sessionModel ?? model.runtimeModel;
+ cases.push({
+ provider: model.provider,
+ advertisedProvider: model.advertisedProvider,
+ displayName: model.displayName,
+ source: model.source,
+ modelId: model.modelId,
+ descriptorFound: model.descriptorFound,
+ runtimeModel: model.runtimeModel,
+ requestedReasoningEffort: effort,
+ expected: {
+ provider: model.provider,
+ model: expectedSessionModel,
+ modelId: model.modelId,
+ reasoningEffort: expectedReasoningEffort,
+ },
+ payload,
+ });
+ }
+ }
+ return cases;
+}
+
+function verifyDryRunCases(cases) {
+ const results = [];
+ for (const testCase of cases) {
+ const issues = [];
+ if (testCase.payload.provider !== testCase.expected.provider) {
+ issues.push(`provider payload ${testCase.payload.provider} != expected ${testCase.expected.provider}`);
+ }
+ if (testCase.payload.model !== testCase.runtimeModel) {
+ issues.push(`model payload ${testCase.payload.model} != runtime ${testCase.runtimeModel}`);
+ }
+ if ((testCase.payload.modelId ?? null) !== (testCase.expected.modelId ?? null)) {
+ issues.push(`modelId payload ${testCase.payload.modelId ?? "null"} != expected ${testCase.expected.modelId ?? "null"}`);
+ }
+ if ((testCase.payload.reasoningEffort ?? null) !== (testCase.requestedReasoningEffort ?? null)) {
+ issues.push("reasoning payload does not match requested reasoning");
+ }
+ results.push({
+ ok: issues.length === 0,
+ case: testCase,
+ issues,
+ });
+ }
+ return results;
+}
+
+async function evaluateAde(client, fn, arg) {
+ const expression = `(${fn.toString()})(${JSON.stringify(arg)})`;
+ const response = await client.send("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ userGesture: true,
+ });
+ if (response.exceptionDetails) {
+ const text = response.exceptionDetails.exception?.description
+ || response.exceptionDetails.text
+ || "Runtime.evaluate failed.";
+ throw new Error(text);
+ }
+ return response.result?.value;
+}
+
+class CdpClient {
+ constructor(url) {
+ this.url = url;
+ this.ws = null;
+ this.nextId = 1;
+ this.pending = new Map();
+ }
+
+ async connect(options = {}) {
+ this.ws = new WebSocket(this.url);
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("Timed out opening CDP websocket.")), 10_000);
+ this.ws.once("open", () => {
+ clearTimeout(timeout);
+ resolve();
+ });
+ this.ws.once("error", (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ });
+ this.ws.on("message", (data) => this.onMessage(data));
+ this.ws.on("close", () => {
+ for (const pending of this.pending.values()) {
+ pending.reject(new Error("CDP websocket closed."));
+ }
+ this.pending.clear();
+ });
+ if (options.enableRuntime !== false) {
+ await this.send("Runtime.enable");
+ }
+ }
+
+ onMessage(data) {
+ const message = JSON.parse(String(data));
+ if (!message.id) return;
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ if (message.error) {
+ pending.reject(new Error(message.error.message ?? "CDP command failed."));
+ return;
+ }
+ pending.resolve(message.result ?? {});
+ }
+
+ send(method, params = {}) {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ throw new Error("CDP websocket is not open.");
+ }
+ const id = this.nextId;
+ this.nextId += 1;
+ const payload = JSON.stringify({ id, method, params });
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.ws.send(payload, (error) => {
+ if (!error) return;
+ this.pending.delete(id);
+ reject(error);
+ });
+ });
+ }
+
+ close() {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.close();
+ }
+ }
+}
+
+async function fetchJson(url, timeoutMs = 2_000) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
+ return await response.json();
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function findAdeTarget(cdpPort, timeoutMs) {
+ const startedAt = Date.now();
+ let lastError = null;
+ while (Date.now() - startedAt < timeoutMs) {
+ try {
+ const targets = await fetchJson(`http://127.0.0.1:${cdpPort}/json/list`, 2_000);
+ const pages = Array.isArray(targets) ? targets : [];
+ const target = pages.find((entry) => {
+ const title = String(entry.title ?? "").toLowerCase();
+ const url = String(entry.url ?? "");
+ return entry.type === "page"
+ && entry.webSocketDebuggerUrl
+ && !title.includes("devtools")
+ && !title.includes("developer tools")
+ && (url.includes("localhost:") || url.includes("127.0.0.1:") || url.startsWith("file://"));
+ }) ?? pages.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl);
+ if (target?.webSocketDebuggerUrl) return target;
+
+ const version = await fetchJson(`http://127.0.0.1:${cdpPort}/json/version`, 2_000);
+ if (typeof version?.webSocketDebuggerUrl === "string") {
+ const browserClient = new CdpClient(version.webSocketDebuggerUrl);
+ try {
+ await browserClient.connect({ enableRuntime: false });
+ const targetList = await browserClient.send("Target.getTargets");
+ const infos = Array.isArray(targetList.targetInfos) ? targetList.targetInfos : [];
+ const browserTarget = infos.find((entry) => {
+ const title = String(entry.title ?? "").toLowerCase();
+ const url = String(entry.url ?? "");
+ return entry.type === "page"
+ && !title.includes("devtools")
+ && !title.includes("developer tools")
+ && (url.includes("localhost:") || url.includes("127.0.0.1:") || url.startsWith("file://"));
+ }) ?? infos.find((entry) => entry.type === "page");
+ if (browserTarget?.targetId) {
+ const webSocketDebuggerUrl = version.webSocketDebuggerUrl.replace(
+ /\/devtools\/browser\/[^/]+$/,
+ `/devtools/page/${browserTarget.targetId}`,
+ );
+ return { ...browserTarget, webSocketDebuggerUrl };
+ }
+ } finally {
+ browserClient.close();
+ }
+ }
+ } catch (error) {
+ lastError = error;
+ }
+ await sleep(250);
+ }
+ const suffix = lastError ? ` Last error: ${lastError.message}` : "";
+ throw new Error(`No ADE renderer CDP target found on port ${cdpPort}.${suffix}`);
+}
+
+async function waitForAdePreload(client, timeoutMs) {
+ const result = await evaluateAde(client, async (args) => {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < args.timeoutMs) {
+ if (window.ade?.agentChat?.models && window.ade?.agentChat?.create) {
+ return {
+ ok: true,
+ href: window.location.href,
+ title: document.title,
+ };
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ return {
+ ok: false,
+ href: window.location.href,
+ title: document.title,
+ };
+ }, { timeoutMs });
+ if (!result?.ok) {
+ throw new Error(`ADE preload did not become ready in renderer ${result?.title ?? ""} ${result?.href ?? ""}`.trim());
+ }
+ return result;
+}
+
+async function ensureProjectAndLane(client, options) {
+ const project = await evaluateAde(client, async (args) => {
+ const current = await window.ade.app.getProject();
+ if (current?.rootPath !== args.projectRoot) {
+ await window.ade.project.switchToPath(args.projectRoot);
+ }
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < args.timeoutMs) {
+ const next = await window.ade.app.getProject();
+ if (next?.rootPath === args.projectRoot) return next;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ return await window.ade.app.getProject();
+ }, {
+ projectRoot: options.projectRoot,
+ timeoutMs: options.timeoutMs,
+ });
+ if (project?.rootPath !== options.projectRoot) {
+ throw new Error(`Unable to open project root '${options.projectRoot}'. Current project: ${project?.rootPath ?? "none"}`);
+ }
+
+ const lane = await evaluateAde(client, async (args) => {
+ const startedAt = Date.now();
+ let lanes = [];
+ let lastError = null;
+ while (Date.now() - startedAt < args.timeoutMs) {
+ try {
+ lanes = await window.ade.lanes.list({ includeArchived: false, includeStatus: false });
+ lastError = null;
+ break;
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : String(error);
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ }
+ if (lastError) throw new Error(`Unable to list lanes after project open: ${lastError}`);
+ if (args.laneId) return lanes.find((entry) => entry.id === args.laneId) ?? null;
+ return lanes.find((entry) => entry.worktreePath === args.projectRoot)
+ ?? lanes.find((entry) => entry.laneType === "primary")
+ ?? lanes[0]
+ ?? null;
+ }, {
+ projectRoot: options.projectRoot,
+ laneId: options.laneId,
+ timeoutMs: options.timeoutMs,
+ });
+ if (!lane?.id) {
+ throw new Error(`No lane found for project '${options.projectRoot}'.`);
+ }
+ return { project, lane };
+}
+
+async function runSmokeCase(client, testCase, laneId, options) {
+ const payload = { ...testCase.payload, laneId };
+ const result = await evaluateAde(client, async (args) => {
+ const cleanup = [];
+ const out = {
+ ok: false,
+ created: null,
+ summaryAfterCreate: null,
+ summaryAfterSend: null,
+ cleanup,
+ error: null,
+ };
+ const serializeError = (error) => ({
+ message: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack ?? null : null,
+ });
+ const withTimeout = (promise, label) => {
+ let timeout = null;
+ const timer = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new Error(`${label} timed out after ${args.timeoutMs}ms`)), args.timeoutMs);
+ });
+ return Promise.race([promise, timer]).finally(() => {
+ if (timeout) clearTimeout(timeout);
+ });
+ };
+ try {
+ out.created = await withTimeout(window.ade.agentChat.create(args.payload), "agentChat.create");
+ out.summaryAfterCreate = await withTimeout(
+ window.ade.agentChat.getSummary({ sessionId: out.created.id }),
+ "agentChat.getSummary",
+ );
+ if (args.send) {
+ await withTimeout(
+ window.ade.agentChat.send({
+ sessionId: out.created.id,
+ text: args.sendText,
+ ...(args.payload.reasoningEffort ? { reasoningEffort: args.payload.reasoningEffort } : {}),
+ }),
+ "agentChat.send",
+ );
+ out.summaryAfterSend = await withTimeout(
+ window.ade.agentChat.getSummary({ sessionId: out.created.id }),
+ "agentChat.getSummary after send",
+ );
+ }
+ out.ok = true;
+ } catch (error) {
+ out.error = serializeError(error);
+ } finally {
+ if (out.created?.id) {
+ try {
+ await withTimeout(window.ade.agentChat.dispose({ sessionId: out.created.id }), "agentChat.dispose");
+ cleanup.push({ action: "dispose", ok: true });
+ } catch (error) {
+ cleanup.push({ action: "dispose", ok: false, error: serializeError(error) });
+ }
+ try {
+ await withTimeout(window.ade.agentChat.delete({ sessionId: out.created.id }), "agentChat.delete");
+ cleanup.push({ action: "delete", ok: true });
+ } catch (error) {
+ cleanup.push({ action: "delete", ok: false, error: serializeError(error) });
+ }
+ }
+ }
+ return out;
+ }, {
+ payload,
+ send: options.send,
+ sendText: options.sendText,
+ timeoutMs: options.timeoutMs,
+ });
+
+ const issues = [];
+ if (!result?.ok) {
+ issues.push(result?.error?.message ?? "Smoke case failed before verification.");
+ }
+ verifySessionFields("created", result?.created, testCase.expected, issues);
+ verifySessionFields("summary", result?.summaryAfterCreate, testCase.expected, issues);
+ if (options.send && result?.summaryAfterSend) {
+ verifySessionFields("summaryAfterSend", result.summaryAfterSend, testCase.expected, issues);
+ }
+
+ return {
+ ok: result?.ok === true && issues.length === 0,
+ case: { ...testCase, payload },
+ issues,
+ created: summarizeSessionForOutput(result?.created),
+ summaryAfterCreate: summarizeSessionForOutput(result?.summaryAfterCreate),
+ summaryAfterSend: summarizeSessionForOutput(result?.summaryAfterSend),
+ cleanup: result?.cleanup ?? [],
+ error: result?.error ?? null,
+ };
+}
+
+function verifySessionFields(label, session, expected, issues) {
+ if (!session) {
+ issues.push(`${label} missing`);
+ return;
+ }
+ const actualProvider = session.provider ?? null;
+ const actualModel = session.model ?? null;
+ const actualModelId = session.modelId ?? null;
+ const actualReasoning = session.reasoningEffort ?? null;
+ if (actualProvider !== expected.provider) {
+ issues.push(`${label}.provider ${actualProvider ?? "null"} != ${expected.provider}`);
+ }
+ if (actualModel !== expected.model) {
+ issues.push(`${label}.model ${actualModel ?? "null"} != ${expected.model}`);
+ }
+ if (actualModelId !== (expected.modelId ?? null)) {
+ issues.push(`${label}.modelId ${actualModelId ?? "null"} != ${expected.modelId ?? "null"}`);
+ }
+ if (actualReasoning !== (expected.reasoningEffort ?? null)) {
+ issues.push(`${label}.reasoningEffort ${actualReasoning ?? "null"} != ${expected.reasoningEffort ?? "null"}`);
+ }
+}
+
+function summarizeSessionForOutput(session) {
+ if (!session) return null;
+ return {
+ sessionId: session.id ?? session.sessionId ?? null,
+ provider: session.provider ?? null,
+ model: session.model ?? null,
+ modelId: session.modelId ?? null,
+ reasoningEffort: session.reasoningEffort ?? null,
+ status: session.status ?? null,
+ };
+}
+
+async function runSmokeCases(client, cases, laneId, options) {
+ const results = [];
+ for (const testCase of cases) {
+ info(options, `smoke ${testCase.provider} ${testCase.modelId ?? testCase.runtimeModel} ${testCase.requestedReasoningEffort ?? "no-reasoning"}`);
+ const result = await runSmokeCase(client, testCase, laneId, options);
+ results.push(result);
+ if (!result.ok) break;
+ }
+ return results;
+}
+
+async function isPortListening(port) {
+ return await new Promise((resolve) => {
+ const socket = net.createConnection({ host: "127.0.0.1", port });
+ socket.setTimeout(250);
+ socket.once("connect", () => {
+ socket.destroy();
+ resolve(true);
+ });
+ socket.once("timeout", () => {
+ socket.destroy();
+ resolve(false);
+ });
+ socket.once("error", () => {
+ socket.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function chooseCdpPort(preferred) {
+ if (!await isPortListening(preferred)) return preferred;
+ for (let port = 9322; port < 9422; port += 1) {
+ if (!await isPortListening(port)) return port;
+ }
+ throw new Error("No free CDP port found in 9322-9421.");
+}
+
+function spawnDevApp(options) {
+ const env = {
+ ...process.env,
+ ADE_ELECTRON_REMOTE_DEBUGGING_PORT: String(options.cdpPort),
+ ADE_PROJECT_ROOT: options.projectRoot,
+ NO_DEVTOOLS: "1",
+ };
+ const child = cp.spawn("npm", ["run", "dev"], {
+ cwd: desktopRoot,
+ env,
+ detached: process.platform !== "win32",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ const logs = [];
+ const append = (chunk) => {
+ const text = chunk.toString("utf8");
+ logs.push(...text.split(/\r?\n/).filter(Boolean));
+ while (logs.length > 80) logs.shift();
+ if (options.verbose) process.stderr.write(text);
+ };
+ child.stdout.on("data", append);
+ child.stderr.on("data", append);
+ child.on("exit", (code, signal) => {
+ if (options.verbose) {
+ process.stderr.write(`[audit] dev app exited code=${code ?? "null"} signal=${signal ?? "null"}\n`);
+ }
+ });
+ return { child, logs };
+}
+
+async function stopDevApp(handle) {
+ if (!handle?.child || handle.child.exitCode != null || handle.child.signalCode != null) return;
+ if (process.platform === "win32") {
+ cp.spawnSync("taskkill.exe", ["/T", "/F", "/PID", String(handle.child.pid)], {
+ stdio: "ignore",
+ windowsHide: true,
+ });
+ return;
+ }
+ try {
+ process.kill(-handle.child.pid, "SIGTERM");
+ } catch {
+ try {
+ handle.child.kill("SIGTERM");
+ } catch {
+ return;
+ }
+ }
+ const exited = await new Promise((resolve) => {
+ const timeout = setTimeout(() => resolve(false), 5_000);
+ handle.child.once("exit", () => {
+ clearTimeout(timeout);
+ resolve(true);
+ });
+ });
+ if (!exited) {
+ try {
+ process.kill(-handle.child.pid, "SIGKILL");
+ } catch {
+ try {
+ handle.child.kill("SIGKILL");
+ } catch {
+ // ignore
+ }
+ }
+ }
+}
+
+function summarizeModels(models) {
+ const byProvider = new Map();
+ for (const model of models) {
+ const entry = byProvider.get(model.advertisedProvider) ?? { models: 0, reasoningCases: 0 };
+ entry.models += 1;
+ entry.reasoningCases += model.reasoningEfforts.length || 1;
+ byProvider.set(model.advertisedProvider, entry);
+ }
+ return Object.fromEntries(byProvider.entries());
+}
+
+function printHumanSummary({ options, models, cases, results, ok }) {
+ const failed = results.filter((result) => !result.ok);
+ process.stdout.write(`chat model runtime audit: ${ok ? "ok" : "failed"}\n`);
+ process.stdout.write(`mode=${options.mode} source=${options.resolvedSource} providers=${options.providers.join(",")}\n`);
+ process.stdout.write(`models=${models.length} cases=${cases.length}\n`);
+ for (const [provider, summary] of Object.entries(summarizeModels(models))) {
+ process.stdout.write(` ${provider}: ${summary.models} models, ${summary.reasoningCases} model/reasoning cases\n`);
+ }
+ if (options.mode === "list" || options.verbose) {
+ for (const model of models) {
+ process.stdout.write(
+ ` ${model.advertisedProvider} ${model.modelId ?? model.id} runtime=${model.runtimeModel} reasoning=${model.reasoningEfforts.join("|") || "none"}\n`,
+ );
+ }
+ }
+ if (options.verbose && cases.length) {
+ for (const testCase of cases) {
+ process.stdout.write(
+ ` case ${testCase.provider} ${testCase.modelId ?? testCase.runtimeModel} reasoning=${testCase.requestedReasoningEffort ?? "none"}\n`,
+ );
+ }
+ }
+ for (const result of failed) {
+ process.stdout.write(`FAILED ${result.case.provider} ${result.case.modelId ?? result.case.runtimeModel} ${result.case.requestedReasoningEffort ?? "none"}\n`);
+ for (const issue of result.issues) {
+ process.stdout.write(` - ${issue}\n`);
+ }
+ if (result.error?.message) process.stdout.write(` error: ${result.error.message}\n`);
+ }
+}
+
+function printJson(payload) {
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ process.stdout.write(`${usage()}\n`);
+ return;
+ }
+
+ const registry = await loadModelRegistry();
+ let devHandle = null;
+ let client = null;
+ try {
+ const needsCdp = options.resolvedSource === "chat-models" || options.mode === "smoke";
+ if (needsCdp) {
+ if (options.startDev) {
+ options.cdpPort = await chooseCdpPort(options.cdpPort);
+ info(options, `starting ADE dev app on CDP port ${options.cdpPort}`);
+ devHandle = spawnDevApp(options);
+ }
+ const target = await findAdeTarget(options.cdpPort, options.startDev ? 90_000 : 10_000);
+ client = new CdpClient(target.webSocketDebuggerUrl);
+ await client.connect();
+ const preload = await waitForAdePreload(client, options.timeoutMs);
+ info(options, `connected to ${preload.title || "ADE"} ${preload.href}`);
+ }
+
+ const rawModels = options.resolvedSource === "chat-models"
+ ? await collectEndpointModels(client, registry, options.providers, options.activateRuntime)
+ : collectRegistryModels(registry, options.providers);
+ const models = limitModelsByProvider(rawModels, options.maxPerProvider);
+ const cases = buildCases(models, options);
+ let results = [];
+
+ if (options.mode === "list") {
+ results = [];
+ } else if (options.mode === "dry-run") {
+ results = verifyDryRunCases(cases);
+ } else {
+ const { lane } = await ensureProjectAndLane(client, options);
+ info(options, `using lane ${lane.id} (${lane.name ?? lane.worktreePath})`);
+ results = await runSmokeCases(client, cases, lane.id, options);
+ }
+
+ const ok = options.mode === "list"
+ ? true
+ : models.length > 0 && cases.length > 0 && results.length > 0 && results.every((result) => result.ok);
+ const payload = {
+ ok,
+ mode: options.mode,
+ source: options.resolvedSource,
+ providers: options.providers,
+ projectRoot: options.projectRoot,
+ modelSummary: summarizeModels(models),
+ modelCount: models.length,
+ caseCount: cases.length,
+ models: options.mode === "list" || options.verbose ? models : undefined,
+ cases: options.verbose ? cases : undefined,
+ results: options.mode === "list" ? undefined : results,
+ };
+ if (options.json) {
+ printJson(payload);
+ } else {
+ printHumanSummary({ options, models, cases, results, ok });
+ }
+ if (!ok) process.exitCode = 1;
+ } finally {
+ if (client) client.close();
+ await stopDevApp(devHandle);
+ }
+}
+
+main().catch((error) => {
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
+ process.exitCode = 1;
+});
diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs
index fd26cfc35..85ee3c7ef 100644
--- a/apps/desktop/scripts/dev.cjs
+++ b/apps/desktop/scripts/dev.cjs
@@ -208,6 +208,9 @@ async function main() {
if (!Number.isFinite(remoteDebugPort) || remoteDebugPort <= 0 || remoteDebugPort > 65535) {
throw new Error(`Invalid Electron remote debugging port: ${remoteDebugPortRaw}`);
}
+ if (!(await isPortFree(remoteDebugPort))) {
+ throw new Error(`Electron remote debugging port ${remoteDebugPort} is already in use.`);
+ }
if (process.env.ADE_APP_CONTROL_CDP_PORT) {
process.stdout.write(`[ade] honoring ADE App Control CDP port ${remoteDebugPort}\n`);
}
@@ -256,7 +259,12 @@ async function main() {
const electronEnv = { VITE_DEV_SERVER_URL: devServerUrl };
const launchElectron = () => {
- const child = spawnProcess("electron", npxCommand, ["electron", ".", `--remote-debugging-port=${remoteDebugPort}`], electronEnv);
+ const electronArgs = ["electron", `--remote-debugging-port=${remoteDebugPort}`];
+ if (process.platform === "darwin") {
+ electronArgs.push("-ApplePersistenceIgnoreState", "YES");
+ }
+ electronArgs.push(".");
+ const child = spawnProcess("electron", npxCommand, electronArgs, electronEnv);
electron = child;
children.add(child);
child.on("exit", (code, signal) => {
diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs
index 5e77eadd7..b26588fb2 100644
--- a/apps/desktop/scripts/validate-mac-artifacts.mjs
+++ b/apps/desktop/scripts/validate-mac-artifacts.mjs
@@ -302,6 +302,8 @@ async function validatePackagedRuntime(appPath, description) {
const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs");
const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade");
const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh");
+ const iosSimHelperRoot = path.join(resourcesPath, "native", "ios-sim-helpers");
+ const iosSimHelperBuildScript = path.join(iosSimHelperRoot, "build.sh");
const nodeModulesPath = path.join(unpackedPath, "node_modules");
const nodePtyModulePath = path.join(nodeModulesPath, "node-pty");
const smokeScriptPath = path.join(unpackedPath, "dist", "main", "packagedRuntimeSmoke.cjs");
@@ -313,8 +315,13 @@ async function validatePackagedRuntime(appPath, description) {
await assertPathExists(adeCliPath, "bundled ADE CLI entry");
await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper");
await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer");
+ await assertPathExists(iosSimHelperBuildScript, "bundled iOS simulator helper build script");
+ await assertPathExists(path.join(iosSimHelperRoot, "sim-capture.swift"), "bundled iOS simulator capture helper source");
+ await assertPathExists(path.join(iosSimHelperRoot, "sim-input.m"), "bundled iOS simulator input helper source");
+ await assertPathMissing(path.join(iosSimHelperRoot, "build"), "bundled iOS simulator helper build cache");
await assertExecutable(adeCliBinPath, "bundled ADE CLI wrapper");
await assertExecutable(adeCliInstallerPath, "bundled ADE CLI PATH installer");
+ await assertExecutable(iosSimHelperBuildScript, "bundled iOS simulator helper build script");
await assertPathExists(nodePtyModulePath, "unpacked node-pty module");
await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script");
await validatePackageHygiene(appPath, description);
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 56500dca4..53d2688a3 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -4692,7 +4692,11 @@ app.whenReady().then(async () => {
try {
const { recoverManagedOpenCodeOrphans } = require("./services/opencode/openCodeServerManager");
- await recoverManagedOpenCodeOrphans({ force: true, logger: getActiveContext().logger });
+ void recoverManagedOpenCodeOrphans({ force: true, logger: getActiveContext().logger }).catch((error: unknown) => {
+ getActiveContext().logger.warn("opencode.orphan_recovery_failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
} catch (error) {
getActiveContext().logger.warn("opencode.orphan_recovery_failed", {
error: error instanceof Error ? error.message : String(error),
diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
index 5f0772688..fa95bfb9a 100644
--- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
+++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
@@ -9,6 +9,8 @@ const mockState = vi.hoisted(() => ({
inspectLocalProvider: vi.fn(),
clearCursorCliModelsCache: vi.fn(),
discoverCursorCliModelDescriptors: vi.fn(),
+ discoverCursorSdkModelDescriptors: vi.fn(),
+ probeCursorSdkModelDiscovery: vi.fn(),
getApiKeyStoreStatus: vi.fn(),
initModelsDevService: vi.fn(),
probeClaudeRuntimeHealth: vi.fn(),
@@ -38,6 +40,8 @@ vi.mock("./localModelDiscovery", () => ({
vi.mock("../chat/cursorModelsDiscovery", () => ({
clearCursorCliModelsCache: (...args: unknown[]) => mockState.clearCursorCliModelsCache(...args),
discoverCursorCliModelDescriptors: (...args: unknown[]) => mockState.discoverCursorCliModelDescriptors(...args),
+ discoverCursorSdkModelDescriptors: (...args: unknown[]) => mockState.discoverCursorSdkModelDescriptors(...args),
+ probeCursorSdkModelDiscovery: (...args: unknown[]) => mockState.probeCursorSdkModelDiscovery(...args),
}));
vi.mock("./apiKeyStore", () => ({
@@ -67,7 +71,7 @@ vi.mock("../opencode/openCodeBinaryManager", () => ({
resolveOpenCodeBinary: (...args: unknown[]) => mockState.resolveOpenCodeBinary(...args),
}));
-import { getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry";
+import { createDynamicCursorCliModelDescriptor, getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry";
import { createAiIntegrationService } from "./aiIntegrationService";
type ServiceFactoryOptions = {
@@ -243,6 +247,18 @@ beforeEach(() => {
}));
mockState.clearCursorCliModelsCache.mockImplementation(() => undefined);
mockState.discoverCursorCliModelDescriptors.mockResolvedValue([]);
+ mockState.discoverCursorSdkModelDescriptors.mockResolvedValue([
+ createDynamicCursorCliModelDescriptor("auto", "Auto"),
+ createDynamicCursorCliModelDescriptor("composer-2", "Composer 2"),
+ ]);
+ mockState.probeCursorSdkModelDiscovery.mockResolvedValue({
+ rows: [
+ { id: "auto", displayName: "Auto" },
+ { id: "composer-2", displayName: "Composer 2" },
+ ],
+ failureKind: null,
+ errorMessage: null,
+ });
mockState.getApiKeyStoreStatus.mockReturnValue({
secureStorageAvailable: true,
legacyPlaintextDetected: false,
@@ -435,4 +451,116 @@ describe("aiIntegrationService", () => {
]);
expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini");
});
+
+ it("invalidates provider readiness caches after API key verification", async () => {
+ const { service } = makeService({
+ providerMode: "guest",
+ availability: { claude: false, codex: false, cursor: false, droid: false },
+ });
+
+ const initialStatus = await service.getStatus();
+ expect(initialStatus.detectedAuth?.some((entry) => entry.type === "api-key" && entry.provider === "cursor")).toBe(false);
+
+ mockState.detectAllAuth.mockResolvedValue([
+ { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" },
+ ]);
+ mockState.buildProviderConnections.mockResolvedValue(makeProviderConnections({
+ claude: false,
+ codex: false,
+ cursor: true,
+ droid: false,
+ }));
+ mockState.verifyProviderApiKey.mockResolvedValue({
+ provider: "cursor",
+ ok: true,
+ message: "Connection verified successfully.",
+ endpoint: "Cursor.me",
+ statusCode: null,
+ verifiedAt: "2026-03-17T19:00:00.000Z",
+ });
+
+ const result = await service.verifyApiKeyConnection("cursor");
+ expect(result).toMatchObject({
+ provider: "cursor",
+ ok: true,
+ source: "store",
+ });
+ expect(JSON.stringify(result)).not.toContain("crsr_test");
+
+ const refreshedStatus = await service.getStatus();
+ expect(mockState.detectAllAuth.mock.calls.length).toBeGreaterThanOrEqual(3);
+ expect(refreshedStatus.detectedAuth).toContainEqual({
+ type: "api-key",
+ provider: "cursor",
+ source: "store",
+ });
+ expect(refreshedStatus.availableProviders.cursor).toBe(true);
+ expect(refreshedStatus.availableModelIds).toContain("cursor/auto");
+ expect(mockState.clearCursorCliModelsCache).toHaveBeenCalled();
+ expect(mockState.clearOpenCodeInventoryCache).toHaveBeenCalled();
+ });
+
+ it("does not verify Cursor when agent model access rejects the key", async () => {
+ const { service } = makeService({
+ providerMode: "guest",
+ availability: { claude: false, codex: false, cursor: false, droid: false },
+ });
+
+ mockState.detectAllAuth.mockResolvedValue([
+ { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" },
+ ]);
+ mockState.verifyProviderApiKey.mockResolvedValue({
+ provider: "cursor",
+ ok: true,
+ message: "Connection verified successfully.",
+ endpoint: "Cursor.me",
+ statusCode: null,
+ verifiedAt: "2026-03-17T19:00:00.000Z",
+ });
+ mockState.probeCursorSdkModelDiscovery.mockResolvedValue({
+ rows: [],
+ failureKind: "auth",
+ errorMessage: "Cursor model API returned HTTP 401.",
+ });
+
+ const result = await service.verifyApiKeyConnection("cursor");
+ expect(result).toMatchObject({
+ provider: "cursor",
+ ok: false,
+ source: "store",
+ });
+ expect(JSON.stringify(result)).not.toContain("crsr_test");
+ });
+
+ it("does not fail Cursor verification on non-auth model probe failures", async () => {
+ const { service } = makeService({
+ providerMode: "guest",
+ availability: { claude: false, codex: false, cursor: false, droid: false },
+ });
+
+ mockState.detectAllAuth.mockResolvedValue([
+ { type: "api-key", provider: "cursor", key: "crsr_test", source: "store" },
+ ]);
+ mockState.verifyProviderApiKey.mockResolvedValue({
+ provider: "cursor",
+ ok: true,
+ message: "Connection verified successfully.",
+ endpoint: "Cursor.me",
+ statusCode: null,
+ verifiedAt: "2026-03-17T19:00:00.000Z",
+ });
+ mockState.probeCursorSdkModelDiscovery.mockResolvedValue({
+ rows: [],
+ failureKind: "unavailable",
+ errorMessage: "Cursor model API returned HTTP 503.",
+ });
+
+ const result = await service.verifyApiKeyConnection("cursor");
+ expect(result).toMatchObject({
+ provider: "cursor",
+ ok: true,
+ source: "store",
+ });
+ expect(JSON.stringify(result)).not.toContain("crsr_test");
+ });
});
diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts
index 9c827e629..09844a925 100644
--- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts
+++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts
@@ -57,6 +57,7 @@ import { inspectLocalProvider } from "./localModelDiscovery";
import {
discoverCursorSdkModelDescriptors,
clearCursorCliModelsCache,
+ probeCursorSdkModelDiscovery,
} from "../chat/cursorModelsDiscovery";
import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery";
import { resolveDroidExecutable } from "./droidExecutable";
@@ -135,6 +136,9 @@ export type AiIntegrationStatus = {
opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>;
apiKeyStore?: {
secureStorageAvailable: boolean;
+ macosKeychainAvailable?: boolean;
+ macosKeychainService?: string | null;
+ macosKeychainError?: string | null;
legacyPlaintextDetected: boolean;
decryptionFailed: boolean;
encryptedStorePath?: string | null;
@@ -897,12 +901,13 @@ export function createAiIntegrationService(args: {
let available = getAvailableModels(auth);
const discoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback";
+ const cursorDiscoveryMode = options?.discoverCliModels === true ? "probe" : "cached-only";
const cursorApiKey = getCursorApiKeyFromAuth(auth);
if (cursorApiKey) {
let cursorModels: Awaited> = [];
try {
- cursorModels = await discoverCursorSdkModelDescriptors(cursorApiKey);
+ cursorModels = await discoverCursorSdkModelDescriptors(cursorApiKey, { mode: cursorDiscoveryMode });
} catch {
cursorModels = [];
}
@@ -939,31 +944,58 @@ export function createAiIntegrationService(args: {
const verifyApiKeyConnection = async (provider: string): Promise => {
const normalizedProvider = String(provider ?? "").trim().toLowerCase();
- const auth = await detectAuth();
-
- const apiEntry =
- normalizedProvider === "openrouter"
- ? auth.find((entry): entry is Extract => entry.type === "openrouter")
- : auth.find(
- (entry): entry is Extract =>
- entry.type === "api-key" && entry.provider === normalizedProvider
- );
+ let invalidateInFinally = true;
+ try {
+ const auth = await detectAuth();
+
+ const apiEntry =
+ normalizedProvider === "openrouter"
+ ? auth.find((entry): entry is Extract => entry.type === "openrouter")
+ : auth.find(
+ (entry): entry is Extract =>
+ entry.type === "api-key" && entry.provider === normalizedProvider
+ );
+
+ if (!apiEntry) {
+ return {
+ provider: normalizedProvider,
+ ok: false,
+ message: "No API key configured for this provider.",
+ verifiedAt: new Date().toISOString(),
+ };
+ }
- if (!apiEntry) {
+ const providerName = apiEntry.type === "openrouter" ? "openrouter" : apiEntry.provider;
+ const verification = await verifyProviderApiKey(providerName, apiEntry.key);
+ if (providerName === "cursor" && verification.ok) {
+ invalidateProviderReadinessCaches();
+ invalidateInFinally = false;
+ try {
+ const discovery = await probeCursorSdkModelDiscovery(apiEntry.key, { timeoutMs: 3_000 });
+ if (discovery.failureKind === "auth") {
+ return {
+ ...verification,
+ ok: false,
+ message:
+ "Cursor account verification succeeded, but Cursor rejected this API key for agent/model access. Re-enter a key from the Cursor dashboard integrations page.",
+ source: apiEntry.source,
+ };
+ }
+ } catch (error) {
+ logger.warn("ai.cursor.discovery_probe_failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
return {
- provider: normalizedProvider,
- ok: false,
- message: "No API key configured for this provider.",
- verifiedAt: new Date().toISOString(),
+ ...verification,
+ source: apiEntry.source,
};
+ } finally {
+ if (invalidateInFinally) {
+ invalidateProviderReadinessCaches();
+ }
}
-
- const providerName = apiEntry.type === "openrouter" ? "openrouter" : apiEntry.provider;
- const verification = await verifyProviderApiKey(providerName, apiEntry.key);
- return {
- ...verification,
- source: apiEntry.source,
- };
};
const requireCursorCloudApiKey = async (): Promise => {
@@ -1484,6 +1516,21 @@ export function createAiIntegrationService(args: {
const STATUS_CACHE_TTL_MS = 30_000; // 30 seconds
let statusCache: { result: AiIntegrationStatus; cachedAt: number; runtimeHealthVersion: number } | null = null;
const statusRequestsInFlight = new Map>();
+ let providerReadinessCacheGeneration = 0;
+
+ const invalidateProviderReadinessCaches = (): void => {
+ providerReadinessCacheGeneration += 1;
+ statusCache = null;
+ statusRequestsInFlight.clear();
+ modelListCache.clear();
+ resetProviderRuntimeHealth();
+ resetClaudeRuntimeProbeCache();
+ resetLocalProviderDetectionCache();
+ clearCursorCliModelsCache();
+ clearDroidCliModelsCache();
+ clearOpenCodeInventoryCache();
+ replaceDynamicOpenCodeModelDescriptors([]);
+ };
const executeReadOnlyOneShotTask = async (args: {
feature: AiFeatureKey;
@@ -1527,18 +1574,15 @@ export function createAiIntegrationService(args: {
return statusCache.result;
}
if (options?.force) {
- resetProviderRuntimeHealth();
- resetClaudeRuntimeProbeCache();
- resetLocalProviderDetectionCache();
- clearCursorCliModelsCache();
- clearDroidCliModelsCache();
- modelListCache.clear();
+ invalidateProviderReadinessCaches();
runtimeHealthVersion = getProviderRuntimeHealthVersion();
}
+ const requestGeneration = providerReadinessCacheGeneration;
const requestKey = [
options?.force === true ? "force" : "default",
options?.refreshOpenCodeInventory === true ? "refresh-opencode" : "reuse-opencode",
String(runtimeHealthVersion),
+ String(requestGeneration),
].join(":");
const existingRequest = statusRequestsInFlight.get(requestKey);
if (existingRequest) {
@@ -1701,7 +1745,9 @@ export function createAiIntegrationService(args: {
opencodeProviders: opencodeInventory.providers,
apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()),
};
- statusCache = { result, cachedAt: Date.now(), runtimeHealthVersion };
+ if (requestGeneration === providerReadinessCacheGeneration) {
+ statusCache = { result, cachedAt: Date.now(), runtimeHealthVersion };
+ }
logger.info("ai.status.summary", {
...phaseContext,
durationMs: Date.now() - totalStartedAt,
@@ -1765,6 +1811,7 @@ export function createAiIntegrationService(args: {
getAvailabilityAsync,
resolveModelForTask,
+ invalidateProviderReadinessCaches,
// Backward-compatible convenience methods used by migrated services.
async generateNarrative(args: {
diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts
new file mode 100644
index 000000000..843384733
--- /dev/null
+++ b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts
@@ -0,0 +1,279 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const spawnSyncMock = vi.hoisted(() => vi.fn());
+const safeStorageState = vi.hoisted(() => ({
+ available: false,
+ decrypted: "{}",
+ encrypted: Buffer.from("encrypted"),
+}));
+
+vi.mock("node:child_process", () => ({
+ spawnSync: (...args: unknown[]) => spawnSyncMock(...args),
+}));
+
+vi.mock("electron", () => ({
+ default: {
+ safeStorage: {
+ isEncryptionAvailable: () => safeStorageState.available,
+ decryptString: () => safeStorageState.decrypted,
+ encryptString: () => safeStorageState.encrypted,
+ },
+ },
+ safeStorage: {
+ isEncryptionAvailable: () => safeStorageState.available,
+ decryptString: () => safeStorageState.decrypted,
+ encryptString: () => safeStorageState.encrypted,
+ },
+}));
+
+const originalPlatform = process.platform;
+const originalEnv = { ...process.env };
+
+function setPlatform(value: NodeJS.Platform): void {
+ Object.defineProperty(process, "platform", {
+ value,
+ configurable: true,
+ });
+}
+
+function securityArg(args: string[], flag: string): string {
+ const index = args.indexOf(flag);
+ return index >= 0 ? args[index + 1] ?? "" : "";
+}
+
+function securityAccountsFor(command: string): string[] {
+ return spawnSyncMock.mock.calls
+ .map((call) => (call[1] as string[]).map(String))
+ .filter((args) => args[0] === command)
+ .map((args) => securityArg(args, "-a"));
+}
+
+function installSecurityMock(
+ keychain: Map,
+ options: { failProviderIndexWrites?: boolean } = {},
+): void {
+ spawnSyncMock.mockImplementation((_command: string, rawArgs: string[]) => {
+ const args = rawArgs.map(String);
+ const command = args[0];
+ const account = securityArg(args, "-a");
+ if (command === "find-generic-password") {
+ if (!keychain.has(account)) {
+ return {
+ status: 44,
+ stdout: "",
+ stderr: "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.",
+ };
+ }
+ return {
+ status: 0,
+ stdout: `${keychain.get(account) ?? ""}\n`,
+ stderr: "",
+ };
+ }
+ if (command === "add-generic-password") {
+ if (options.failProviderIndexWrites && account === "__ade_provider_index__") {
+ return { status: 1, stdout: "", stderr: "provider index write failed" };
+ }
+ keychain.set(account, securityArg(args, "-w"));
+ return { status: 0, stdout: "", stderr: "" };
+ }
+ if (command === "delete-generic-password") {
+ if (!keychain.has(account)) {
+ return {
+ status: 44,
+ stdout: "",
+ stderr: "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.",
+ };
+ }
+ keychain.delete(account);
+ return { status: 0, stdout: "", stderr: "" };
+ }
+ return { status: 1, stdout: "", stderr: `unexpected security command ${command}` };
+ });
+}
+
+async function loadStoreModule() {
+ vi.resetModules();
+ const mod = await import("./apiKeyStore");
+ mod.__setSafeStorageForTests({
+ isEncryptionAvailable: () => safeStorageState.available,
+ decryptString: () => safeStorageState.decrypted,
+ encryptString: () => safeStorageState.encrypted,
+ } as never);
+ return mod;
+}
+
+describe("apiKeyStore", () => {
+ let tempRoot: string;
+ let keychain: Map;
+
+ beforeEach(() => {
+ spawnSyncMock.mockReset();
+ safeStorageState.available = false;
+ safeStorageState.decrypted = "{}";
+ safeStorageState.encrypted = Buffer.from("encrypted");
+ keychain = new Map();
+ installSecurityMock(keychain);
+
+ process.env = { ...originalEnv, ADE_API_KEY_STORE_FORCE_KEYCHAIN: "1" };
+ delete process.env.ADE_API_KEY_STORE_DISABLE_KEYCHAIN;
+ setPlatform("darwin");
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-api-key-store-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ process.env = { ...originalEnv };
+ setPlatform(originalPlatform);
+ vi.resetModules();
+ });
+
+ it("stores Cursor keys in macOS Keychain without creating a safeStorage blob", async () => {
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ store.storeApiKey("cursor", " crsr_test_key ");
+
+ expect(store.getApiKey("cursor")).toBe("crsr_test_key");
+ expect(store.listStoredProviders()).toContain("cursor");
+ expect(keychain.get("cursor")).toBe("crsr_test_key");
+ expect(keychain.get("__ade_provider_index__")).toContain("cursor");
+ expect(fs.existsSync(path.join(tempRoot, ".ade", "secrets", "api-keys.v1.bin"))).toBe(false);
+ });
+
+ it("keeps a stored Keychain key usable when the provider index write fails", async () => {
+ installSecurityMock(keychain, { failProviderIndexWrites: true });
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ store.storeApiKey("cursor", "crsr_test_key");
+
+ expect(store.getApiKey("cursor")).toBe("crsr_test_key");
+ expect(store.listStoredProviders()).toContain("cursor");
+ expect(keychain.get("cursor")).toBe("crsr_test_key");
+ expect(keychain.has("__ade_provider_index__")).toBe(false);
+ expect(store.getApiKeyStoreStatus().macosKeychainError).toContain("provider index write failed");
+ });
+
+ it("clears provider index write errors after a later successful Keychain write", async () => {
+ installSecurityMock(keychain, { failProviderIndexWrites: true });
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ store.storeApiKey("cursor", "crsr_test_key");
+ expect(store.getApiKeyStoreStatus().macosKeychainError).toContain("provider index write failed");
+
+ installSecurityMock(keychain);
+ store.storeApiKey("openai", "openai_test_key");
+
+ expect(store.getApiKey("openai")).toBe("openai_test_key");
+ expect(store.getApiKeyStoreStatus().macosKeychainError).toBeNull();
+ });
+
+ it("removes a deleted Keychain key from memory when the provider index write fails", async () => {
+ keychain.set("__ade_provider_index__", JSON.stringify(["cursor"]));
+ keychain.set("cursor", "crsr_test_key");
+ installSecurityMock(keychain, { failProviderIndexWrites: true });
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_test_key");
+
+ store.deleteApiKey("cursor");
+
+ expect(store.getApiKey("cursor")).toBeNull();
+ expect(store.listStoredProviders()).not.toContain("cursor");
+ expect(keychain.has("cursor")).toBe(false);
+ expect(keychain.get("__ade_provider_index__")).toContain("cursor");
+ expect(store.getApiKeyStoreStatus().macosKeychainError).toContain("provider index write failed");
+ });
+
+ it("migrates a decryptable legacy safeStorage blob into macOS Keychain", async () => {
+ const secretsDir = path.join(tempRoot, ".ade", "secrets");
+ fs.mkdirSync(secretsDir, { recursive: true });
+ fs.writeFileSync(path.join(secretsDir, "api-keys.v1.bin"), Buffer.from("old-encrypted"));
+ safeStorageState.available = true;
+ safeStorageState.decrypted = JSON.stringify({
+ cursor: "crsr_old_key",
+ openai: "openai_old_key",
+ });
+
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_old_key");
+ expect(store.getApiKey("openai")).toBe("openai_old_key");
+ expect(keychain.get("cursor")).toBe("crsr_old_key");
+ expect(keychain.get("openai")).toBe("openai_old_key");
+ });
+
+ it("prefers an existing Keychain value over an older encrypted blob during migration", async () => {
+ keychain.set("cursor", "crsr_current_key");
+ const secretsDir = path.join(tempRoot, ".ade", "secrets");
+ fs.mkdirSync(secretsDir, { recursive: true });
+ fs.writeFileSync(path.join(secretsDir, "api-keys.v1.bin"), Buffer.from("old-encrypted"));
+ safeStorageState.available = true;
+ safeStorageState.decrypted = JSON.stringify({ cursor: "crsr_stale_key" });
+
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_current_key");
+ expect(keychain.get("cursor")).toBe("crsr_current_key");
+ });
+
+ it("keeps Keychain keys usable when the old encrypted blob cannot be decrypted", async () => {
+ keychain.set("__ade_provider_index__", JSON.stringify(["cursor"]));
+ keychain.set("cursor", "crsr_keychain_key");
+ const secretsDir = path.join(tempRoot, ".ade", "secrets");
+ fs.mkdirSync(secretsDir, { recursive: true });
+ fs.writeFileSync(path.join(secretsDir, "api-keys.v1.bin"), Buffer.from("unreadable"));
+ safeStorageState.available = false;
+
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_keychain_key");
+ expect(store.getApiKeyStoreStatus()).toMatchObject({
+ secureStorageAvailable: true,
+ macosKeychainAvailable: true,
+ decryptionFailed: true,
+ });
+ });
+
+ it("uses the Keychain provider index instead of probing every known provider on cold load", async () => {
+ keychain.set("__ade_provider_index__", JSON.stringify(["cursor"]));
+ keychain.set("cursor", "crsr_keychain_key");
+
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_keychain_key");
+ expect(store.listStoredProviders()).toEqual(["cursor"]);
+ expect(securityAccountsFor("find-generic-password")).toEqual([
+ "__ade_provider_index__",
+ "__ade_provider_index__",
+ "cursor",
+ ]);
+ });
+
+ it("reads an unindexed Keychain provider on demand without scanning unrelated providers", async () => {
+ keychain.set("cursor", "crsr_unindexed_key");
+
+ const store = await loadStoreModule();
+ store.initApiKeyStore(tempRoot);
+
+ expect(store.getApiKey("cursor")).toBe("crsr_unindexed_key");
+ expect(store.listStoredProviders()).toEqual(["cursor"]);
+ expect(securityAccountsFor("find-generic-password")).toEqual([
+ "__ade_provider_index__",
+ "__ade_provider_index__",
+ "cursor",
+ "__ade_provider_index__",
+ ]);
+ expect(securityAccountsFor("add-generic-password")).toContain("__ade_provider_index__");
+ });
+});
diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts
index 13ab29f3a..801eaf6bf 100644
--- a/apps/desktop/src/main/services/ai/apiKeyStore.ts
+++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
+import { spawnSync } from "node:child_process";
import type { SafeStorage } from "electron";
import { resolveAdeLayout } from "../../../shared/adeLayout";
@@ -22,21 +23,65 @@ type StoredKeys = Record;
export type ApiKeyStoreStatus = {
secureStorageAvailable: boolean;
+ macosKeychainAvailable: boolean;
+ macosKeychainService: string | null;
+ macosKeychainError: string | null;
encryptedStorePath: string | null;
legacyPlaintextDetected: boolean;
legacyPlaintextPath: string | null;
decryptionFailed: boolean;
};
+const ENV_KEY_PROVIDERS: Record = {
+ anthropic: "ANTHROPIC_API_KEY",
+ openai: "OPENAI_API_KEY",
+ google: "GOOGLE_API_KEY",
+ mistral: "MISTRAL_API_KEY",
+ deepseek: "DEEPSEEK_API_KEY",
+ xai: "XAI_API_KEY",
+ groq: "GROQ_API_KEY",
+ together: "TOGETHER_API_KEY",
+ openrouter: "OPENROUTER_API_KEY",
+ cursor: "CURSOR_API_KEY",
+};
+
+const MACOS_SECURITY_BIN = "/usr/bin/security";
+const MACOS_KEYCHAIN_SERVICE = "com.ade.desktop.api-keys.v1";
+const MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT = "__ade_provider_index__";
+const MACOS_KEYCHAIN_MISSING_PATTERNS = [
+ /could not be found/i,
+ /item could not be found/i,
+ /the specified item could not be found/i,
+];
+const SECURITY_TIMEOUT_MS = 5_000;
+
let storePath: string | null = null;
let legacyStorePath: string | null = null;
let cache: StoredKeys | null = null;
let decryptionFailed = false;
+let macosKeychainError: string | null = null;
+let missingMacosKeychainProviders = new Set();
+
+export function __setSafeStorageForTests(next: SafeStorage | null): void {
+ safeStorage = next;
+ cache = null;
+ missingMacosKeychainProviders = new Set();
+}
function isSecureStorageAvailable(): boolean {
return Boolean(safeStorage && typeof safeStorage.isEncryptionAvailable === "function" && safeStorage.isEncryptionAvailable());
}
+function isMacosKeychainAvailable(): boolean {
+ if (process.env.ADE_API_KEY_STORE_DISABLE_KEYCHAIN === "1") return false;
+ if (process.env.NODE_ENV === "test" && process.env.ADE_API_KEY_STORE_FORCE_KEYCHAIN === "1") return true;
+ return process.platform === "darwin" && fs.existsSync(MACOS_SECURITY_BIN);
+}
+
+function isPersistentSecureStorageAvailable(): boolean {
+ return isMacosKeychainAvailable() || isSecureStorageAvailable();
+}
+
function normalizeStoredKeys(value: unknown): StoredKeys {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: StoredKeys = {};
@@ -56,47 +101,249 @@ function ensureInitialized(): void {
}
}
-function ensureStore(): StoredKeys {
- if (cache) return cache;
+type SecurityResult = {
+ ok: boolean;
+ stdout: string;
+ stderr: string;
+ status: number | null;
+};
+
+function runSecurity(args: string[]): SecurityResult {
+ const result = spawnSync(MACOS_SECURITY_BIN, args, {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: SECURITY_TIMEOUT_MS,
+ });
+ return {
+ ok: result.status === 0 && !result.error,
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
+ stderr: typeof result.stderr === "string" ? result.stderr : result.error?.message ?? "",
+ status: typeof result.status === "number" ? result.status : null,
+ };
+}
+
+function securityMissing(result: SecurityResult): boolean {
+ if (result.status === 44) return true;
+ return MACOS_KEYCHAIN_MISSING_PATTERNS.some((pattern) => pattern.test(result.stderr));
+}
+
+function rememberKeychainError(action: string, result: SecurityResult): void {
+ const detail = result.stderr.trim().split(/\r?\n/)[0] || `status ${result.status ?? "unknown"}`;
+ macosKeychainError = `macOS Keychain ${action} failed: ${detail}`;
+}
+
+function clearKeychainError(): void {
+ macosKeychainError = null;
+}
+
+function trimTrailingNewline(value: string): string {
+ return value.replace(/(?:\r?\n)+$/, "");
+}
+
+function readMacosKeychainSecret(account: string): string | null {
+ if (!isMacosKeychainAvailable()) return null;
+ const result = runSecurity([
+ "find-generic-password",
+ "-a",
+ account,
+ "-s",
+ MACOS_KEYCHAIN_SERVICE,
+ "-w",
+ ]);
+ if (result.ok) {
+ clearKeychainError();
+ const value = trimTrailingNewline(result.stdout).trim();
+ return value.length ? value : null;
+ }
+ if (!securityMissing(result)) {
+ rememberKeychainError("read", result);
+ }
+ return null;
+}
+
+function writeMacosKeychainSecret(account: string, value: string): void {
+ if (!isMacosKeychainAvailable()) {
+ throw new Error("macOS Keychain is unavailable. Cannot persist API keys.");
+ }
+ // `/usr/bin/security add-generic-password` does not support a non-interactive
+ // stdin password sentinel: `-w -` stores a literal dash. Keep this path
+ // synchronous and tightly scoped until we replace it with a native binding.
+ const result = runSecurity([
+ "add-generic-password",
+ "-a",
+ account,
+ "-s",
+ MACOS_KEYCHAIN_SERVICE,
+ "-w",
+ value,
+ "-U",
+ ]);
+ if (!result.ok) {
+ rememberKeychainError("write", result);
+ throw new Error(macosKeychainError ?? "macOS Keychain write failed.");
+ }
+ clearKeychainError();
+}
+
+function deleteMacosKeychainSecret(account: string): void {
+ if (!isMacosKeychainAvailable()) return;
+ const result = runSecurity([
+ "delete-generic-password",
+ "-a",
+ account,
+ "-s",
+ MACOS_KEYCHAIN_SERVICE,
+ ]);
+ if (result.ok || securityMissing(result)) {
+ clearKeychainError();
+ return;
+ }
+ rememberKeychainError("delete", result);
+ throw new Error(macosKeychainError ?? "macOS Keychain delete failed.");
+}
+
+function normalizeProviderList(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ const providers = new Set();
+ for (const raw of value) {
+ if (typeof raw !== "string") continue;
+ const provider = raw.trim().toLowerCase();
+ if (provider.length) providers.add(provider);
+ }
+ return Array.from(providers).sort();
+}
+
+function readMacosKeychainProviderIndex(): { exists: boolean; providers: string[] } {
+ const raw = readMacosKeychainSecret(MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT);
+ if (!raw) return { exists: false, providers: [] };
+ try {
+ return { exists: true, providers: normalizeProviderList(JSON.parse(raw)) };
+ } catch {
+ macosKeychainError = "macOS Keychain provider index is unreadable.";
+ return { exists: true, providers: [] };
+ }
+}
+
+function writeMacosKeychainProviderIndex(providers: Iterable): void {
+ writeMacosKeychainSecret(
+ MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT,
+ JSON.stringify(normalizeProviderList(Array.from(providers))),
+ );
+}
+
+function tryWriteMacosKeychainProviderIndex(providers: Iterable): void {
+ try {
+ writeMacosKeychainProviderIndex(providers);
+ } catch {
+ // The provider index only powers discovery/listing. Keep the just-written
+ // secret usable in this process even if the secondary index write fails.
+ }
+}
+
+function readMacosKeychainStore(providerCandidates: Iterable): StoredKeys {
+ const out: StoredKeys = {};
+ if (!isMacosKeychainAvailable()) return out;
+ for (const provider of providerCandidates) {
+ if (provider === MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT) continue;
+ const value = readMacosKeychainSecret(provider);
+ if (value) out[provider] = value;
+ }
+ return out;
+}
+
+function loadEncryptedStore(): StoredKeys {
ensureInitialized();
if (!storePath || !legacyStorePath) {
- cache = {};
- return cache;
+ return {};
}
if (!fs.existsSync(storePath)) {
decryptionFailed = false;
- cache = {};
- return cache;
+ return {};
}
if (!isSecureStorageAvailable()) {
decryptionFailed = true;
- cache = {};
- return cache;
+ return {};
}
try {
const raw = fs.readFileSync(storePath);
const decrypted = safeStorage!.decryptString(raw);
- cache = normalizeStoredKeys(JSON.parse(decrypted));
decryptionFailed = false;
- return cache;
+ return normalizeStoredKeys(JSON.parse(decrypted));
} catch {
decryptionFailed = true;
- cache = {};
+ return {};
+ }
+}
+
+function migrateEncryptedStoreToMacosKeychain(encryptedStore: StoredKeys): void {
+ if (!isMacosKeychainAvailable()) return;
+ const providers = Object.keys(encryptedStore);
+ if (!providers.length) return;
+
+ const existingIndex = readMacosKeychainProviderIndex();
+ const nextProviders = new Set(existingIndex.providers);
+ let changed = false;
+
+ for (const provider of providers) {
+ const value = encryptedStore[provider]?.trim();
+ if (!value) continue;
+ const existingValue = readMacosKeychainSecret(provider);
+ if (!existingValue) {
+ writeMacosKeychainSecret(provider, value);
+ }
+ nextProviders.add(provider);
+ changed = true;
+ }
+
+ if (changed) {
+ writeMacosKeychainProviderIndex(nextProviders);
+ }
+}
+
+function ensureStore(): StoredKeys {
+ if (cache) return cache;
+ ensureInitialized();
+
+ const encryptedStore = loadEncryptedStore();
+ if (isMacosKeychainAvailable()) {
+ const indexBeforeMigration = readMacosKeychainProviderIndex();
+ if (!indexBeforeMigration.exists) {
+ try {
+ migrateEncryptedStoreToMacosKeychain(encryptedStore);
+ } catch {
+ // If Keychain migration is blocked, keep the decryptable encrypted
+ // store as the active fallback instead of dropping existing keys.
+ }
+ }
+
+ const index = readMacosKeychainProviderIndex();
+ if (index.exists) {
+ cache = readMacosKeychainStore(index.providers);
+ return cache;
+ }
+
+ // Without an index, avoid probing every known provider synchronously on the
+ // Electron main thread. The old encrypted store remains the migration
+ // source of truth; direct Keychain reads happen on-demand by provider.
+ cache = encryptedStore;
return cache;
}
+
+ cache = encryptedStore;
+ return cache;
}
-function persist(): void {
- if (!storePath || !cache) return;
+function persistEncryptedStore(nextStore: StoredKeys = cache ?? {}): void {
+ if (!storePath) return;
if (!isSecureStorageAvailable()) {
throw new Error("OS secure storage is unavailable. Cannot persist API keys.");
}
fs.mkdirSync(path.dirname(storePath), { recursive: true });
- const encrypted = safeStorage!.encryptString(JSON.stringify(cache));
+ const encrypted = safeStorage!.encryptString(JSON.stringify(nextStore));
fs.writeFileSync(storePath, encrypted);
try {
fs.chmodSync(storePath, 0o600);
@@ -111,12 +358,17 @@ export function initApiKeyStore(projectRoot: string): void {
legacyStorePath = layout.legacyApiKeysPath;
cache = null;
decryptionFailed = false;
+ macosKeychainError = null;
+ missingMacosKeychainProviders = new Set();
}
export function getApiKeyStoreStatus(): ApiKeyStoreStatus {
if (!storePath || !legacyStorePath) {
return {
- secureStorageAvailable: isSecureStorageAvailable(),
+ secureStorageAvailable: isPersistentSecureStorageAvailable(),
+ macosKeychainAvailable: isMacosKeychainAvailable(),
+ macosKeychainService: isMacosKeychainAvailable() ? MACOS_KEYCHAIN_SERVICE : null,
+ macosKeychainError,
encryptedStorePath: null,
legacyPlaintextDetected: false,
legacyPlaintextPath: null,
@@ -124,7 +376,10 @@ export function getApiKeyStoreStatus(): ApiKeyStoreStatus {
};
}
return {
- secureStorageAvailable: isSecureStorageAvailable(),
+ secureStorageAvailable: isPersistentSecureStorageAvailable(),
+ macosKeychainAvailable: isMacosKeychainAvailable(),
+ macosKeychainService: isMacosKeychainAvailable() ? MACOS_KEYCHAIN_SERVICE : null,
+ macosKeychainError,
encryptedStorePath: storePath,
legacyPlaintextDetected: Boolean(legacyStorePath && fs.existsSync(legacyStorePath)),
legacyPlaintextPath: legacyStorePath && fs.existsSync(legacyStorePath) ? legacyStorePath : null,
@@ -139,29 +394,35 @@ export function storeApiKey(provider: string, key: string): void {
throw new Error("Provider and key are required.");
}
const store = ensureStore();
- store[normalizedProvider] = normalizedKey;
- persist();
+ if (isMacosKeychainAvailable()) {
+ writeMacosKeychainSecret(normalizedProvider, normalizedKey);
+ store[normalizedProvider] = normalizedKey;
+ missingMacosKeychainProviders.delete(normalizedProvider);
+ const index = readMacosKeychainProviderIndex();
+ tryWriteMacosKeychainProviderIndex(new Set([...index.providers, normalizedProvider]));
+ return;
+ }
+ const nextStore = { ...store, [normalizedProvider]: normalizedKey };
+ persistEncryptedStore(nextStore);
+ cache = nextStore;
}
-const ENV_KEY_PROVIDERS: Record = {
- anthropic: "ANTHROPIC_API_KEY",
- openai: "OPENAI_API_KEY",
- google: "GOOGLE_API_KEY",
- mistral: "MISTRAL_API_KEY",
- deepseek: "DEEPSEEK_API_KEY",
- xai: "XAI_API_KEY",
- groq: "GROQ_API_KEY",
- together: "TOGETHER_API_KEY",
- openrouter: "OPENROUTER_API_KEY",
- cursor: "CURSOR_API_KEY",
-};
-
export function getApiKey(provider: string): string | null {
const normalizedProvider = provider.trim().toLowerCase();
if (!normalizedProvider.length) return null;
const store = ensureStore();
const stored = store[normalizedProvider];
if (stored) return stored;
+ if (isMacosKeychainAvailable() && !missingMacosKeychainProviders.has(normalizedProvider)) {
+ const keychainValue = readMacosKeychainSecret(normalizedProvider);
+ if (keychainValue) {
+ store[normalizedProvider] = keychainValue;
+ const index = readMacosKeychainProviderIndex();
+ tryWriteMacosKeychainProviderIndex(new Set([...index.providers, normalizedProvider]));
+ return keychainValue;
+ }
+ missingMacosKeychainProviders.add(normalizedProvider);
+ }
const envVar = ENV_KEY_PROVIDERS[normalizedProvider];
if (envVar) {
const envValue = (process.env[envVar] ?? "").trim();
@@ -174,8 +435,18 @@ export function deleteApiKey(provider: string): void {
const normalizedProvider = provider.trim().toLowerCase();
if (!normalizedProvider.length) return;
const store = ensureStore();
- delete store[normalizedProvider];
- persist();
+ if (isMacosKeychainAvailable()) {
+ deleteMacosKeychainSecret(normalizedProvider);
+ delete store[normalizedProvider];
+ missingMacosKeychainProviders.add(normalizedProvider);
+ const index = readMacosKeychainProviderIndex();
+ tryWriteMacosKeychainProviderIndex(index.providers.filter((entry) => entry !== normalizedProvider));
+ return;
+ }
+ const nextStore = { ...store };
+ delete nextStore[normalizedProvider];
+ persistEncryptedStore(nextStore);
+ cache = nextStore;
}
export function listStoredProviders(): string[] {
diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts
index 4763aef12..0c36d0f55 100644
--- a/apps/desktop/src/main/services/ai/authDetector.ts
+++ b/apps/desktop/src/main/services/ai/authDetector.ts
@@ -15,6 +15,10 @@ import { getLocalProviderDefaultEndpoint, type LocalProviderFamily } from "../..
import type { AiLocalProviderConfigs } from "../../../shared/types";
import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery";
import { resolveDroidExecutable } from "./droidExecutable";
+import {
+ reportProviderRuntimeAuthFailure,
+ reportProviderRuntimeReady,
+} from "./providerRuntimeHealth";
type CliName = "claude" | "codex" | "cursor" | "droid";
@@ -803,6 +807,7 @@ async function verifyCursorApiKey(
);
}),
]);
+ reportProviderRuntimeReady("cursor");
return {
provider: "cursor",
ok: true,
@@ -816,6 +821,9 @@ async function verifyCursorApiKey(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const authFailed = /auth|unauthorized|forbidden|invalid|api key/i.test(message);
+ if (authFailed) {
+ reportProviderRuntimeAuthFailure("cursor", "Cursor rejected the configured API key. Check the key from the Cursor dashboard integrations page.");
+ }
return {
provider: "cursor",
ok: false,
diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts
index da9dca4b4..b1375a905 100644
--- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts
+++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts
@@ -254,4 +254,30 @@ describe("buildProviderConnections", () => {
else process.env.CURSOR_API_KEY = prevKey;
}
});
+
+ it("downgrades Cursor runtime availability when SDK model access rejects the key", async () => {
+ const prevKey = process.env.CURSOR_API_KEY;
+ process.env.CURSOR_API_KEY = "test-key";
+ mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => {
+ if (provider === "cursor") {
+ return {
+ provider: "cursor",
+ state: "auth-failed",
+ message: "Cursor rejected the configured API key for agent/model access.",
+ checkedAt: "2026-05-01T12:00:00.000Z",
+ };
+ }
+ return null;
+ });
+ try {
+ const result = await buildProviderConnections(mergeCliStatuses([]));
+ expect(result.cursor.authAvailable).toBe(true);
+ expect(result.cursor.runtimeDetected).toBe(true);
+ expect(result.cursor.runtimeAvailable).toBe(false);
+ expect(result.cursor.blocker).toBe("Cursor rejected the configured API key for agent/model access.");
+ } finally {
+ if (prevKey === undefined) delete process.env.CURSOR_API_KEY;
+ else process.env.CURSOR_API_KEY = prevKey;
+ }
+ });
});
diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
index 33ed093f5..b1c691245 100644
--- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
+++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
@@ -38,6 +38,7 @@ export async function buildProviderConnections(
]);
const claudeRuntimeHealth = getProviderRuntimeHealth("claude");
const codexRuntimeHealth = getProviderRuntimeHealth("codex");
+ const cursorRuntimeHealth = getProviderRuntimeHealth("cursor");
const deriveProviderFlags = (
cli: CliAuthStatus | null,
@@ -93,6 +94,7 @@ export async function buildProviderConnections(
if (!health) return;
if (health.state === "auth-failed" || health.state === "runtime-failed") {
status.runtimeAvailable = false;
+ status.usageAvailable = false;
status.blocker = health.message
?? (health.state === "auth-failed"
? `${status.provider} runtime was detected, but ADE chat reported that login is still required.`
@@ -100,6 +102,7 @@ export async function buildProviderConnections(
} else if (health.state === "ready") {
status.runtimeAvailable = true;
status.authAvailable = true;
+ if (status.provider === "cursor") status.usageAvailable = true;
status.blocker = null;
}
}
@@ -224,7 +227,7 @@ export async function buildProviderConnections(
],
blocker: cursorBlocker,
};
- // Cursor has no runtime-health probe yet.
+ applyRuntimeHealth(cursor, cursorRuntimeHealth);
const droidCli = cliStatuses.find((entry) => entry.cli === "droid") ?? null;
const factoryEnvAuth = Boolean(process.env.FACTORY_API_KEY?.trim());
diff --git a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts
index e13700059..cb88e1a6e 100644
--- a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts
+++ b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts
@@ -1,15 +1,16 @@
import { nowIso } from "../shared/utils";
export type ProviderRuntimeHealthState = "ready" | "auth-failed" | "runtime-failed";
+export type ProviderRuntimeHealthProvider = "claude" | "codex" | "cursor";
export type ProviderRuntimeHealth = {
- provider: "claude" | "codex";
+ provider: ProviderRuntimeHealthProvider;
state: ProviderRuntimeHealthState;
message: string | null;
checkedAt: string;
};
-const providerHealth = new Map<"claude" | "codex", ProviderRuntimeHealth>();
+const providerHealth = new Map();
let providerHealthVersion = 0;
function setProviderRuntimeHealth(next: ProviderRuntimeHealth): void {
@@ -29,7 +30,7 @@ function setProviderRuntimeHealth(next: ProviderRuntimeHealth): void {
providerHealthVersion += 1;
}
-export function reportProviderRuntimeReady(provider: "claude" | "codex"): void {
+export function reportProviderRuntimeReady(provider: ProviderRuntimeHealthProvider): void {
setProviderRuntimeHealth({
provider,
state: "ready",
@@ -39,7 +40,7 @@ export function reportProviderRuntimeReady(provider: "claude" | "codex"): void {
}
export function reportProviderRuntimeAuthFailure(
- provider: "claude" | "codex",
+ provider: ProviderRuntimeHealthProvider,
message: string,
): void {
setProviderRuntimeHealth({
@@ -51,7 +52,7 @@ export function reportProviderRuntimeAuthFailure(
}
export function reportProviderRuntimeFailure(
- provider: "claude" | "codex",
+ provider: ProviderRuntimeHealthProvider,
message: string,
): void {
setProviderRuntimeHealth({
@@ -63,7 +64,7 @@ export function reportProviderRuntimeFailure(
}
export function getProviderRuntimeHealth(
- provider: "claude" | "codex",
+ provider: ProviderRuntimeHealthProvider,
): ProviderRuntimeHealth | null {
return providerHealth.get(provider) ?? null;
}
@@ -72,7 +73,7 @@ export function getProviderRuntimeHealthVersion(): number {
return providerHealthVersion;
}
-export function resetProviderRuntimeHealth(provider?: "claude" | "codex"): void {
+export function resetProviderRuntimeHealth(provider?: ProviderRuntimeHealthProvider): void {
if (provider) {
if (!providerHealth.has(provider)) return;
providerHealth.delete(provider);
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
index c2263b178..1fc3d20a2 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
@@ -93,9 +93,9 @@ describe("buildCodingAgentSystemPrompt", () => {
expect(result).toContain("never re-invokes");
});
- it("describes the Cursor ACP runtime", () => {
- const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-acp" });
- expect(result).toContain("Cursor agent via ACP");
+ it("describes the Cursor SDK runtime", () => {
+ const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-sdk" });
+ expect(result).toContain("Cursor SDK");
});
it("describes the Droid ACP runtime", () => {
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
index ab1f63e4d..5191870a5 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
@@ -12,7 +12,7 @@ type HarnessPermissionMode = "plan" | "edit" | "full-auto";
export type AdeRuntimeKind =
| "claude-agent-sdk-v2"
| "codex-cli"
- | "cursor-acp"
+ | "cursor-sdk"
| "droid-acp"
| "opencode";
@@ -29,10 +29,10 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] {
"**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.",
"**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.",
];
- case "cursor-acp":
+ case "cursor-sdk":
return [
- "**Runtime:** ADE Work chat wrapping the Cursor agent via ACP (Agent Client Protocol).",
- "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
+ "**Runtime:** ADE Work chat hosted on the Cursor SDK (`@cursor/sdk`).",
+ "**Wake-up semantics:** Each turn is driven by ADE through the SDK agent run. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
];
case "droid-acp":
return [
diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts
index affc3bce1..f69544ae3 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.test.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts
@@ -35,6 +35,7 @@ const mockState = vi.hoisted(() => ({
waiters: Array<() => void>;
aborted: boolean;
}>(),
+ openCodeTitleForNextPrompt: null as string | null,
droidSessionCounter: 0,
codexRequestPayloads: [] as Array>,
codexResponseOverrides: new Map | ((payload: Record) => Record)>(),
@@ -51,6 +52,7 @@ const mockState = vi.hoisted(() => ({
droidAcquireCalls: [] as Array>,
droidNewSessionCalls: [] as Array>,
droidPromptCalls: [] as Array>,
+ droidPooled: null as any,
emitCodexPayload(payload: Record) {
mockState.codexLineHandler?.(JSON.stringify(payload));
},
@@ -206,6 +208,18 @@ vi.mock("../opencode/openCodeRuntime", () => ({
session: {
promptAsync: vi.fn(async () => {
void (async () => {
+ if (mockState.openCodeTitleForNextPrompt) {
+ pushEvent({
+ type: "session.updated",
+ properties: {
+ info: {
+ id: sessionId,
+ title: mockState.openCodeTitleForNextPrompt,
+ },
+ },
+ });
+ mockState.openCodeTitleForNextPrompt = null;
+ }
const result = streamText({} as any) as {
fullStream?: AsyncIterable>;
};
@@ -453,6 +467,9 @@ vi.mock("./cursorSdkPool", () => ({
}
if (type === "cloud.send.stream") {
mockState.cursorSdkCloudRequests.push({ type, payload: (payload as Record) ?? {} });
+ if (mockState.cursorSdkCloudResponses.has(type)) {
+ return mockState.cursorSdkCloudResponses.get(type);
+ }
return {
agentId: "cloud-agent-1",
runId: "cloud-run-1",
@@ -462,6 +479,9 @@ vi.mock("./cursorSdkPool", () => ({
}
if (type === "cloud.followup") {
mockState.cursorSdkCloudRequests.push({ type, payload: (payload as Record) ?? {} });
+ if (mockState.cursorSdkCloudResponses.has(type)) {
+ return mockState.cursorSdkCloudResponses.get(type);
+ }
return {
agentId: (payload as Record)?.agentId ?? "cloud-agent-1",
runId: "cloud-run-2",
@@ -489,6 +509,7 @@ vi.mock("./cursorSdkPool", () => ({
return { generation: 1, pooled };
}),
releaseCursorSdkConnection: vi.fn(),
+ resolveCursorSdkUserHome: vi.fn(() => "/Users/admin"),
runCursorSdkCatalogRequest: vi.fn(async () => []),
runCursorSdkCloudRequest: vi.fn(async (args: { type: string; payload: Record }) => {
mockState.cursorSdkCloudRequests.push({ type: args.type, payload: args.payload });
@@ -502,43 +523,45 @@ vi.mock("./cursorSdkPool", () => ({
vi.mock("./droidAcpPool", () => ({
acquireDroidAcpConnection: vi.fn(async (args: Record) => {
mockState.droidAcquireCalls.push(args);
+ const pooled = {
+ connection: {
+ newSession: vi.fn(async (params: Record) => {
+ mockState.droidNewSessionCalls.push(params);
+ mockState.droidSessionCounter += 1;
+ return {
+ sessionId: `droid-acp-session-${mockState.droidSessionCounter}`,
+ models: { currentModelId: "claude-sonnet-4-5-20250929" },
+ configOptions: [],
+ };
+ }),
+ prompt: vi.fn(async (params: Record) => {
+ mockState.droidPromptCalls.push(params);
+ return {
+ stopReason: "end_turn",
+ usage: { inputTokens: 3, outputTokens: 5 },
+ };
+ }),
+ cancel: vi.fn(),
+ unstable_closeSession: vi.fn(),
+ },
+ bridge: {
+ onPermission: null,
+ onSessionUpdate: null,
+ getRootPath: () => "",
+ getDirtyFileText: null,
+ onTerminalOutputDelta: null,
+ flushTerminalOutput: null,
+ onTerminalDisposed: null,
+ },
+ terminals: new Map(),
+ terminalWorkLogBindings: new Map(),
+ terminalOutputTimers: new Map(),
+ dispose: vi.fn(),
+ };
+ mockState.droidPooled = pooled;
return {
generation: 1,
- pooled: {
- connection: {
- newSession: vi.fn(async (params: Record) => {
- mockState.droidNewSessionCalls.push(params);
- mockState.droidSessionCounter += 1;
- return {
- sessionId: `droid-acp-session-${mockState.droidSessionCounter}`,
- models: { currentModelId: "claude-sonnet-4-5-20250929" },
- configOptions: [],
- };
- }),
- prompt: vi.fn(async (params: Record) => {
- mockState.droidPromptCalls.push(params);
- return {
- stopReason: "end_turn",
- usage: { inputTokens: 3, outputTokens: 5 },
- };
- }),
- cancel: vi.fn(),
- unstable_closeSession: vi.fn(),
- },
- bridge: {
- onPermission: null,
- onSessionUpdate: null,
- getRootPath: () => "",
- getDirtyFileText: null,
- onTerminalOutputDelta: null,
- flushTerminalOutput: null,
- onTerminalDisposed: null,
- },
- terminals: new Map(),
- terminalWorkLogBindings: new Map(),
- terminalOutputTimers: new Map(),
- dispose: vi.fn(),
- },
+ pooled,
};
}),
releaseDroidAcpConnection: vi.fn(),
@@ -885,6 +908,12 @@ async function waitForEvent(
throw new Error("Timed out waiting for agent chat event.");
}
+async function waitForSessionTitle(sessionService: ReturnType, sessionId: string, title: string): Promise {
+ await vi.waitFor(() => {
+ expect(sessionService.get(sessionId)?.title).toBe(title);
+ }, { timeout: 1_000 });
+}
+
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -904,6 +933,7 @@ beforeEach(() => {
mockState.codexTurnCounter = 0;
mockState.openCodeSessionCounter = 0;
mockState.openCodeSessions.clear();
+ mockState.openCodeTitleForNextPrompt = null;
mockState.droidSessionCounter = 0;
mockState.codexRequestPayloads = [];
mockState.codexResponseOverrides.clear();
@@ -920,6 +950,9 @@ beforeEach(() => {
mockState.droidAcquireCalls = [];
mockState.droidNewSessionCalls = [];
mockState.droidPromptCalls = [];
+ mockState.droidPooled = null;
+ vi.mocked(startOpenCodeSession).mockClear();
+ vi.mocked(buildOpenCodePromptParts).mockClear();
vi.mocked(acquireCursorSdkConnection).mockClear();
vi.mocked(acquireDroidAcpConnection).mockClear();
vi.mocked(streamText).mockReset();
@@ -3260,8 +3293,11 @@ describe("createAgentChatService", () => {
const promptsDir = path.join(tmpRoot, ".codex", "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(path.join(promptsDir, "review.md"), "This project prompt must not replace built-in review.");
+ vi.mocked(detectAllAuth).mockResolvedValue([
+ { type: "cli-subscription", cli: "claude", authenticated: true },
+ ] as any);
- const { service } = createService();
+ const { service, aiIntegrationService } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
@@ -3271,12 +3307,109 @@ describe("createAgentChatService", () => {
await service.sendMessage({
sessionId: session.id,
text: "/review",
- });
+ }, { awaitDispatch: true });
await vi.waitFor(() => {
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "review/start")).toBe(true);
});
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false);
+ expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled();
+ });
+
+ describe("runtime-native chat titles", () => {
+ it("adopts Codex app-server thread names from lifecycle responses", async () => {
+ mockState.codexResponseOverrides.set("thread/start", () => ({
+ thread: { id: "thread-runtime-title", name: "Runtime Naming Investigation" },
+ }));
+ const { service, sessionService } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.4",
+ });
+
+ await service.sendMessage({ sessionId: session.id, text: "Check session names." });
+
+ await waitForSessionTitle(sessionService, session.id, "Runtime Naming Investigation");
+ expect(sessionService.get(session.id)?.manuallyNamed).toBe(false);
+ });
+
+ it("adopts Codex thread/name/updated notifications without overwriting manual names", async () => {
+ const { service, sessionService } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.4",
+ });
+
+ await service.sendMessage({ sessionId: session.id, text: "Name this from runtime." });
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+
+ mockState.emitCodexPayload({
+ jsonrpc: "2.0",
+ method: "thread/name/updated",
+ params: { threadId: "thread-1", threadName: "Captured Runtime Title" },
+ });
+ await waitForSessionTitle(sessionService, session.id, "Captured Runtime Title");
+
+ await service.updateSession({ sessionId: session.id, title: "Manual Title", manuallyNamed: true });
+ mockState.emitCodexPayload({
+ jsonrpc: "2.0",
+ method: "thread/name/updated",
+ params: { threadId: "thread-1", name: "Should Not Win" },
+ });
+ await waitForSessionTitle(sessionService, session.id, "Manual Title");
+ });
+
+ it("lets OpenCode session.updated titles beat ADE AI fallback", async () => {
+ streamText.mockReturnValue({
+ fullStream: (async function* () {
+ yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } };
+ })(),
+ });
+ mockState.openCodeTitleForNextPrompt = "OpenCode Native Title";
+ const { service, sessionService, aiIntegrationService } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "opencode",
+ model: "",
+ modelId: "opencode/anthropic/claude-sonnet-4-6",
+ });
+
+ await service.sendMessage({ sessionId: session.id, text: "Use runtime title." }, { awaitDispatch: true });
+
+ await waitForSessionTitle(sessionService, session.id, "OpenCode Native Title");
+ expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled();
+ expect(vi.mocked(startOpenCodeSession).mock.calls.at(-1)?.[0]).toEqual(
+ expect.objectContaining({ title: null }),
+ );
+ });
+
+ it("adopts Droid ACP session_info_update titles", async () => {
+ const { service, sessionService } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "droid",
+ model: "custom:claude-sonnet-4-6-thinking-32000",
+ modelId: "droid/custom:claude-sonnet-4-6-thinking-32000",
+ });
+
+ await service.sendMessage({ sessionId: session.id, text: "Use ACP title." }, { awaitDispatch: true });
+ await vi.waitFor(() => {
+ expect(typeof mockState.droidPooled.bridge.onSessionUpdate).toBe("function");
+ }, { timeout: 1_000 });
+ mockState.droidPooled.bridge.onSessionUpdate?.({
+ sessionId: "droid-acp-session-1",
+ update: {
+ sessionUpdate: "session_info_update",
+ title: "Droid Native Title",
+ },
+ });
+
+ await waitForSessionTitle(sessionService, session.id, "Droid Native Title");
+ });
});
// --------------------------------------------------------------------------
@@ -5456,7 +5589,7 @@ describe("createAgentChatService", () => {
| { mode?: unknown; settings?: { model?: unknown; reasoning_effort?: unknown; developer_instructions?: unknown } }
| undefined;
- expect(params?.approvalPolicy).toBe("unlessTrusted");
+ expect(params?.approvalPolicy).toBe("untrusted");
expect(params?.sandboxPolicy?.type).toBe("readOnly");
expect(params?.effort).toBe("medium");
expect(collaborationMode?.mode).toBe("plan");
@@ -5509,7 +5642,7 @@ describe("createAgentChatService", () => {
} | undefined;
const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined;
- expect(params?.approvalPolicy).toBe("onRequest");
+ expect(params?.approvalPolicy).toBe("on-request");
expect(params?.sandboxPolicy?.type).toBe("workspaceWrite");
expect(params?.effort).toBe("medium");
expect(collaborationMode?.mode).toBe("default");
@@ -5573,7 +5706,7 @@ describe("createAgentChatService", () => {
reasoningEffort?: unknown;
} | undefined;
expect(params?.approvalPolicy).toBe("never");
- expect(params?.sandbox).toBe("dangerFullAccess");
+ expect(params?.sandbox).toBe("danger-full-access");
expect(params?.reasoningEffort).toBe("medium");
const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
@@ -5587,7 +5720,87 @@ describe("createAgentChatService", () => {
expect(turnStartParams?.effort).toBe("medium");
});
- it("persists the runtime-confirmed Codex reasoning effort while applying effective thread policy", async () => {
+ it("serializes every Codex permission mode to the app-server wire shapes", async () => {
+ vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
+ if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" };
+ if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" };
+ if (mode === "default") return { approvalPolicy: "on-request", sandbox: "workspace-write" };
+ if (mode === "config-toml") return null;
+ return { approvalPolicy: "on-request", sandbox: "read-only" };
+ });
+
+ const cases = [
+ {
+ mode: "plan" as const,
+ approvalPolicy: "on-request",
+ lifecycleSandbox: "read-only",
+ turnSandboxType: "readOnly",
+ },
+ {
+ mode: "default" as const,
+ approvalPolicy: "on-request",
+ lifecycleSandbox: "workspace-write",
+ turnSandboxType: "workspaceWrite",
+ },
+ {
+ mode: "edit" as const,
+ approvalPolicy: "untrusted",
+ lifecycleSandbox: "workspace-write",
+ turnSandboxType: "workspaceWrite",
+ },
+ {
+ mode: "full-auto" as const,
+ approvalPolicy: "never",
+ lifecycleSandbox: "danger-full-access",
+ turnSandboxType: "dangerFullAccess",
+ },
+ {
+ mode: "config-toml" as const,
+ approvalPolicy: undefined,
+ lifecycleSandbox: undefined,
+ turnSandboxType: undefined,
+ },
+ ];
+
+ for (const scenario of cases) {
+ mockState.codexRequestPayloads = [];
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.5",
+ permissionMode: scenario.mode,
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: `Probe ${scenario.mode} permissions.`,
+ });
+
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true);
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+
+ const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start");
+ const threadParams = threadStartRequest?.params as {
+ approvalPolicy?: unknown;
+ sandbox?: unknown;
+ } | undefined;
+ expect(threadParams?.approvalPolicy).toBe(scenario.approvalPolicy);
+ expect(threadParams?.sandbox).toBe(scenario.lifecycleSandbox);
+
+ const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
+ const turnParams = turnStartRequest?.params as {
+ approvalPolicy?: unknown;
+ sandboxPolicy?: { type?: unknown };
+ } | undefined;
+ expect(turnParams?.approvalPolicy).toBe(scenario.approvalPolicy);
+ expect(turnParams?.sandboxPolicy?.type).toBe(scenario.turnSandboxType);
+ }
+ });
+
+ it("persists the runtime-effective Codex reasoning effort while applying effective thread policy", async () => {
mockState.codexResponseOverrides.set("thread/start", () => ({
thread: { id: "thread-effective-start" },
approvalPolicy: "onFailure",
@@ -5627,7 +5840,7 @@ describe("createAgentChatService", () => {
sandboxPolicy?: { type?: unknown };
effort?: unknown;
} | undefined;
- expect(turnStartParams?.approvalPolicy).toBe("onFailure");
+ expect(turnStartParams?.approvalPolicy).toBe("on-failure");
expect(turnStartParams?.sandboxPolicy?.type).toBe("workspaceWrite");
expect(turnStartParams?.effort).toBe("high");
@@ -5709,7 +5922,7 @@ describe("createAgentChatService", () => {
reasoningEffort?: unknown;
} | undefined;
expect(params?.approvalPolicy).toBe("never");
- expect(params?.sandbox).toBe("dangerFullAccess");
+ expect(params?.sandbox).toBe("danger-full-access");
expect(params?.reasoningEffort).toBe("medium");
const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
@@ -5725,6 +5938,112 @@ describe("createAgentChatService", () => {
expect(turnStartParams?.effort).toBe("medium");
});
+ it("uses each updated Codex reasoning effort on the next post-turn send", async () => {
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.5",
+ });
+
+ const completeLatestTurn = async (): Promise => {
+ mockState.emitCodexPayload({
+ jsonrpc: "2.0",
+ method: "turn/completed",
+ params: {
+ turn: {
+ id: `turn-${mockState.codexTurnCounter}`,
+ status: "completed",
+ },
+ },
+ });
+ await vi.waitFor(async () => {
+ expect((await service.getSessionSummary(session.id))?.status).toBe("idle");
+ });
+ };
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Initial turn.",
+ });
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+ await completeLatestTurn();
+
+ for (const effort of ["low", "medium", "high", "xhigh"]) {
+ await service.updateSession({
+ sessionId: session.id,
+ reasoningEffort: effort,
+ });
+ mockState.codexRequestPayloads = [];
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: `Use ${effort} reasoning now.`,
+ });
+
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+ const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
+ expect((turnStartRequest?.params as { effort?: unknown } | undefined)?.effort).toBe(effort);
+ await completeLatestTurn();
+ }
+ });
+
+ it("applies Codex reasoning effort changes made during an active turn to the next turn", async () => {
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.5",
+ reasoningEffort: "low",
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Start with low reasoning.",
+ });
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+
+ const firstTurnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
+ expect((firstTurnStartRequest?.params as { effort?: unknown } | undefined)?.effort).toBe("low");
+
+ await service.updateSession({
+ sessionId: session.id,
+ reasoningEffort: "xhigh",
+ });
+
+ mockState.emitCodexPayload({
+ jsonrpc: "2.0",
+ method: "turn/completed",
+ params: {
+ turn: {
+ id: `turn-${mockState.codexTurnCounter}`,
+ status: "completed",
+ },
+ },
+ });
+ await vi.waitFor(async () => {
+ expect((await service.getSessionSummary(session.id))?.status).toBe("idle");
+ });
+
+ mockState.codexRequestPayloads = [];
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Now use the updated reasoning.",
+ });
+
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+ const secondTurnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
+ expect((secondTurnStartRequest?.params as { effort?: unknown } | undefined)?.effort).toBe("xhigh");
+ });
+
it("re-resumes Codex threads when switching from config-toml to full-auto flags", async () => {
vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
if (mode === "full-auto") {
@@ -5788,7 +6107,7 @@ describe("createAgentChatService", () => {
const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume");
const resumeParams = resumeRequest?.params as { approvalPolicy?: unknown; sandbox?: unknown } | undefined;
expect(resumeParams?.approvalPolicy).toBe("never");
- expect(resumeParams?.sandbox).toBe("dangerFullAccess");
+ expect(resumeParams?.sandbox).toBe("danger-full-access");
});
it("does not auto-upgrade default Codex chats into plan mode", async () => {
@@ -5865,7 +6184,7 @@ describe("createAgentChatService", () => {
expect(sessionService.reopen).toHaveBeenCalledWith(session.id);
});
- it("persists runtime-confirmed Codex reasoning effort while applying effective policy on resume", async () => {
+ it("keeps runtime-effective Codex reasoning effort while applying effective policy on resume", async () => {
mockState.codexResponseOverrides.set("thread/resume", () => ({
thread: { id: "thread-effective-resume" },
approvalPolicy: "onFailure",
@@ -9276,6 +9595,51 @@ describe("createAgentChatService", () => {
)).toBe(false);
});
+ it("does not pass ADE default titles as Cursor SDK agent names", async () => {
+ process.env.CURSOR_API_KEY = "cursor-test-key";
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "cursor",
+ model: "composer-2",
+ modelId: "cursor/composer-2",
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Run locally.",
+ }, { awaitDispatch: true });
+
+ expect(mockState.cursorSdkAcquireCalls.at(-1)).toEqual(
+ expect.objectContaining({ agentName: null }),
+ );
+ });
+
+ it("passes only manual titles as Cursor SDK agent names", async () => {
+ process.env.CURSOR_API_KEY = "cursor-test-key";
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "cursor",
+ model: "composer-2",
+ modelId: "cursor/composer-2",
+ });
+ await service.updateSession({
+ sessionId: session.id,
+ title: "Manual Cursor Title",
+ manuallyNamed: true,
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Run locally.",
+ }, { awaitDispatch: true });
+
+ expect(mockState.cursorSdkAcquireCalls.at(-1)).toEqual(
+ expect.objectContaining({ agentName: "Manual Cursor Title" }),
+ );
+ });
+
it("buffers streamed Cursor SDK control blocks before rendering ADE plan UI", async () => {
process.env.CURSOR_API_KEY = "cursor-test-key";
const events: AgentChatEventEnvelope[] = [];
@@ -9983,6 +10347,46 @@ describe("createAgentChatService", () => {
expect(refreshed?.cursorPromotedTurnId).toBeTruthy();
});
+ it("adopts Cursor Cloud auto-generated agent names and omits ADE defaults", async () => {
+ process.env.CURSOR_API_KEY = "cursor-test-key";
+ mockState.cursorSdkCloudResponses.set("cloud.send.stream", {
+ agentId: "cloud-agent-1",
+ runId: "cloud-run-1",
+ status: "finished",
+ result: { status: "finished" },
+ agentName: "Cursor Cloud Native Title",
+ });
+ const events: AgentChatEventEnvelope[] = [];
+ const { service, sessionService } = createService({
+ onEvent: (event: AgentChatEventEnvelope) => events.push(event),
+ });
+
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "cursor",
+ model: "composer-2",
+ modelId: "cursor/composer-2",
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Let Cursor Cloud name this.",
+ runtime: "cloud",
+ cloudOverrides: { repoUrl: "https://github.com/example/repo.git" },
+ } as any, { awaitDispatch: true });
+
+ await waitForEvent(
+ events,
+ (event): event is AgentChatEventEnvelope & { event: Extract } =>
+ event.event.type === "done" && event.sessionId === session.id,
+ );
+ await waitForSessionTitle(sessionService, session.id, "Cursor Cloud Native Title");
+
+ const sent = mockState.cursorSdkCloudRequests.find((r) => r.type === "cloud.send.stream");
+ expect(sent?.payload.agentName).toBeUndefined();
+ expect(sessionService.get(session.id)?.manuallyNamed).toBe(false);
+ });
+
it("uses cloud.followup with the durable agentId on subsequent cloud sends", async () => {
process.env.CURSOR_API_KEY = "cursor-test-key";
const events: AgentChatEventEnvelope[] = [];
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 4ee6c569d..094846aa2 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -100,6 +100,7 @@ import type {
AgentChatNoticeDetail,
AgentChatInteractionMode,
AgentChatInterruptArgs,
+ AgentChatModelCatalog,
AgentChatModelInfo,
AgentChatProvider,
AgentChatRespondToInputArgs,
@@ -143,12 +144,18 @@ import {
MODEL_REGISTRY,
pickDefaultCursorDescriptorFromCliList,
pickDefaultDroidDescriptorFromCliList,
+ getRuntimeModelRefForDescriptor,
resolveModelAlias,
resolveModelDescriptorForProvider,
resolveProviderGroupForModel,
type LocalProviderFamily,
type ModelDescriptor,
+ type ModelProviderGroup,
} from "../../../shared/modelRegistry";
+import {
+ buildProviderGroupBlocks,
+ createModelOrderMap,
+} from "../../../shared/modelCatalog";
import { canSwitchChatSessionModel } from "../../../shared/chatModelSwitching";
import { detectAllAuth } from "../ai/authDetector";
import type { PermissionMode } from "../ai/tools/universalTools";
@@ -208,6 +215,7 @@ import { resolveDroidExecutable } from "../ai/droidExecutable";
import {
acquireCursorSdkConnection,
releaseCursorSdkConnection,
+ resolveCursorSdkUserHome,
runCursorSdkCloudRequest,
type CursorSdkPooled,
type CursorSdkRuntimeMeta,
@@ -303,6 +311,7 @@ type PersistedChatState = {
cursorModeSnapshot?: AgentChatCursorModeSnapshot;
cursorModeId?: string | null;
cursorConfigValues?: Record;
+ runtimeTitleAdopted?: boolean;
permissionMode?: AgentChatSession["permissionMode"];
identityKey?: AgentChatIdentityKey;
surface?: AgentChatSurface;
@@ -1007,6 +1016,7 @@ type ManagedChatSession = {
autoTitleSeed: string | null;
autoTitleStage: "none" | "initial" | "final";
autoTitleInFlight: boolean;
+ runtimeTitleAdopted: boolean;
manuallyNamed: boolean;
summaryInFlight: boolean;
activeAssistantMessageId: string | null;
@@ -1207,6 +1217,8 @@ Return only the base name text (no model suffixes).
- Describe the task or feature from the user's message.
- No quotes, no emoji, no trailing punctuation.`;
const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = [
+ { effort: "none", description: "No extra reasoning when supported by the runtime." },
+ { effort: "minimal", description: "Minimal reasoning for fastest responses." },
{ effort: "low", description: "Fastest turn-around with shallow reasoning." },
{ effort: "medium", description: "Balanced reasoning depth and speed." },
{ effort: "high", description: "Deeper reasoning for multi-step implementation." },
@@ -1217,6 +1229,7 @@ const CLAUDE_REASONING_EFFORTS: Array<{ effort: string; description: string }> =
{ effort: "low", description: "Quick responses with minimal reasoning." },
{ effort: "medium", description: "Balanced reasoning depth and speed." },
{ effort: "high", description: "Deep reasoning for complex tasks." },
+ { effort: "xhigh", description: "Extra-high reasoning depth for Opus 4.7." },
{ effort: "max", description: "Maximum reasoning depth. Best for Opus on hard problems." },
];
@@ -1235,16 +1248,16 @@ const KNOWN_CLAUDE_EFFORTS = new Set(CLAUDE_REASONING_EFFORTS.map((e) => e.effor
function codexModelInfoFromDescriptor(
descriptor: ModelDescriptor,
- overrides?: Partial>,
+ overrides?: Partial>,
): AgentChatModelInfo {
return {
id: descriptor.providerModelId,
displayName: descriptor.displayName,
description: overrides?.description ?? describeCodexModel(descriptor.displayName),
isDefault: overrides?.isDefault ?? descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id,
- reasoningEfforts: descriptor.reasoningTiers?.length
+ reasoningEfforts: overrides?.reasoningEfforts ?? (descriptor.reasoningTiers?.length
? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort))
- : CODEX_REASONING_EFFORTS,
+ : CODEX_REASONING_EFFORTS),
modelId: descriptor.id,
family: descriptor.family,
supportsReasoning: descriptor.capabilities.reasoning,
@@ -1266,6 +1279,11 @@ const CLAUDE_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProv
? CLAUDE_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort))
: [],
maxThinkingTokens: descriptor.capabilities.reasoning ? CLAUDE_EFFORT_TO_TOKENS.high : null,
+ modelId: descriptor.id,
+ family: descriptor.family,
+ supportsReasoning: descriptor.capabilities.reasoning,
+ supportsTools: descriptor.capabilities.tools,
+ color: descriptor.color,
}));
function normalizeReasoningEffort(value: unknown): string | null {
@@ -1274,6 +1292,14 @@ function normalizeReasoningEffort(value: unknown): string | null {
return normalized.length > 0 ? normalized : null;
}
+function catalogDescriptorInfoKey(
+ group: ModelProviderGroup,
+ providerKey: string,
+ descriptorId: string,
+): string {
+ return `${group}:${providerKey}:${descriptorId}`;
+}
+
function resolveSessionModelDescriptor(session: AgentChatSession): ModelDescriptor | null {
if (session.modelId) {
return getModelById(session.modelId) ?? resolveModelAlias(session.modelId) ?? null;
@@ -1370,7 +1396,7 @@ function normalizeUsagePayload(
const KNOWN_CODEX_EFFORTS = new Set(CODEX_REASONING_EFFORTS.map((e) => e.effort));
const EFFORT_ALIASES: Record> = {
- codex: { minimal: "low", max: "xhigh", none: "low" },
+ codex: { max: "xhigh" },
claude: {},
};
@@ -1378,18 +1404,37 @@ function validateReasoningEffort(provider: "codex" | "claude", effort: string |
if (!effort) return null;
const aliased = EFFORT_ALIASES[provider]?.[effort] ?? effort;
const known = provider === "codex" ? KNOWN_CODEX_EFFORTS : KNOWN_CLAUDE_EFFORTS;
- const fallback = provider === "codex" ? DEFAULT_REASONING_EFFORT : "medium";
- return known.has(aliased) ? aliased : fallback;
+ return known.has(aliased) ? aliased : null;
+}
+
+function validateReasoningEffortForDescriptor(
+ provider: "codex" | "claude",
+ effort: string | null | undefined,
+ descriptor?: ModelDescriptor | null,
+): string | null {
+ const validated = validateReasoningEffort(provider, effort);
+ if (!validated) return null;
+ if (descriptor?.reasoningTiers?.length && !descriptor.reasoningTiers.includes(validated)) {
+ return null;
+ }
+ return validated;
}
function resolveCodexReasoningEffortForRuntime(
primary: string | null | undefined,
fallback?: string | null,
+ descriptor?: ModelDescriptor | null,
): string {
+ const descriptorDefault =
+ validateReasoningEffortForDescriptor("codex", DEFAULT_REASONING_EFFORT, descriptor)
+ ?? descriptor?.reasoningTiers
+ ?.map((tier) => validateReasoningEffort("codex", tier))
+ .find((tier): tier is string => Boolean(tier))
+ ?? DEFAULT_REASONING_EFFORT;
return (
- validateReasoningEffort("codex", normalizeReasoningEffort(primary))
- ?? validateReasoningEffort("codex", normalizeReasoningEffort(fallback))
- ?? DEFAULT_REASONING_EFFORT
+ validateReasoningEffortForDescriptor("codex", normalizeReasoningEffort(primary), descriptor)
+ ?? validateReasoningEffortForDescriptor("codex", normalizeReasoningEffort(fallback), descriptor)
+ ?? descriptorDefault
);
}
@@ -1406,7 +1451,7 @@ function buildClaudeV2ExecutableArgs(args: {
if (args.supportsReasoning) {
executableArgs.push("--thinking", "adaptive", "--thinking-display", "summarized");
const effort = args.effort;
- if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") {
+ if (effort === "low" || effort === "medium" || effort === "high" || effort === "xhigh" || effort === "max") {
executableArgs.push("--effort", effort);
}
}
@@ -1749,7 +1794,14 @@ function normalizePreview(text: string, maxChars = 220): string | null {
const REJECTED_TITLES = new Set([
"completed", "complete", "done", "finished", "resolved",
- "success", "session closed", "chat completed"
+ "success", "session closed", "chat completed",
+ "model", "models", "status", "permissions", "permission",
+ "help", "login", "logout", "clear", "compact", "resume",
+ "new", "quit", "exit", "debug config", "statusline", "theme",
+ "untitled", "untitled chat", "new chat", "new session",
+ "chat", "session", "ai chat", "codex chat", "claude chat",
+ "cursor chat", "droid chat", "opencode chat", "open code chat",
+ "cursor agent", "local agent"
]);
const GENERIC_REMAINDER_TOKENS = new Set([
@@ -1765,9 +1817,11 @@ function sanitizeAutoTitle(raw: string, maxChars = AUTO_TITLE_MAX_CHARS): string
.replace(/[^\p{L}\p{N})\]]+$/gu, "")
.trim();
if (!normalized.length) return null;
+ if (isProviderSlashCommandInput(normalized)) return null;
const collapsed = normalized.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
if (REJECTED_TITLES.has(collapsed)) return null;
+ if (/^(new session|new chat|untitled chat|untitled)\b/u.test(collapsed)) return null;
if (/^(completed?|done|finished|resolved|success)\b/u.test(collapsed)) {
const remainder = collapsed.replace(/^(completed?|done|finished|resolved|success)\b/u, "").trim();
@@ -1821,10 +1875,45 @@ function defaultChatSessionTitle(provider: AgentChatProvider): string {
}
const DEFAULT_SESSION_TITLES = new Set(["Codex Chat", "Claude Chat", "AI Chat", "Cursor Chat", "Droid Chat"]);
+const DEFAULT_SESSION_TITLES_NORMALIZED = new Set(
+ [...DEFAULT_SESSION_TITLES, "OpenCode Chat", "Open Code Chat"]
+ .map((title) => title.toLowerCase()),
+);
+const CURSOR_RUNTIME_AUTH_ERROR =
+ "Cursor rejected the configured API key for agent/model access. Re-enter a Cursor API key from the Cursor dashboard integrations page.";
+
+function isCursorRuntimeAuthError(error: unknown): boolean {
+ const statusCode = readErrorStatusCode(error);
+ if (statusCode === 401 || statusCode === 403) return true;
+ const message = readErrorMessage(error).toLowerCase();
+ if (/\b(authentication|unauthorized|forbidden)\b/i.test(message)) return true;
+ return /\bapi[- ]?key\b/i.test(message)
+ && /\b(invalid|missing|required|revoked|expired|rejected|unauthorized|forbidden|not provided|not found)\b/i.test(message);
+}
function hasCustomChatSessionTitle(title: string | null | undefined, provider: AgentChatProvider): boolean {
const normalized = String(title ?? "").trim();
- return normalized.length > 0 && normalized !== defaultChatSessionTitle(provider);
+ return normalized.length > 0
+ && normalized !== defaultChatSessionTitle(provider)
+ && !isProviderSlashCommandInput(normalized);
+}
+
+function extractRuntimeTitle(value: unknown): string | null {
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed.length ? trimmed : null;
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const record = value as Record;
+ for (const candidate of [record.title, record.name, record.threadName, record.agentName]) {
+ const title = extractRuntimeTitle(candidate);
+ if (title) return title;
+ }
+ for (const key of ["thread", "session", "info", "agent"]) {
+ const title = extractRuntimeTitle(record[key]);
+ if (title) return title;
+ }
+ return null;
}
function resumeCommandForProvider(provider: AgentChatProvider, sessionId: string): string {
@@ -2317,19 +2406,8 @@ function codexSandboxPolicyType(sandbox: AgentChatCodexSandbox): string {
}
}
-function codexApprovalPolicyWireValue(approvalPolicy: AgentChatCodexApprovalPolicy): string {
- switch (approvalPolicy) {
- case "untrusted":
- return "unlessTrusted";
- case "on-request":
- return "onRequest";
- case "on-failure":
- return "onFailure";
- case "never":
- return "never";
- default:
- return approvalPolicy satisfies never;
- }
+function codexApprovalPolicyWireValue(approvalPolicy: AgentChatCodexApprovalPolicy): AgentChatCodexApprovalPolicy {
+ return approvalPolicy;
}
/** Spread-ready codex thread lifecycle policy args or empty object if null. */
@@ -2337,7 +2415,8 @@ function codexPolicyArgs(policy: ReturnType): Recor
return policy
? {
approvalPolicy: codexApprovalPolicyWireValue(policy.approvalPolicy),
- sandbox: codexSandboxPolicyType(policy.sandbox),
+ // Thread lifecycle uses SandboxMode literals; turn/start uses SandboxPolicy.type.
+ sandbox: policy.sandbox,
}
: {};
}
@@ -2353,7 +2432,10 @@ function codexTurnPolicyArgs(policy: ReturnType): R
}
type CodexThreadLifecycleResponse = {
- thread?: { id?: string };
+ thread?: { id?: string; name?: string | null; title?: string | null; threadName?: string | null };
+ name?: string | null;
+ title?: string | null;
+ threadName?: string | null;
approvalPolicy?: unknown;
sandbox?: unknown;
reasoningEffort?: unknown;
@@ -2386,6 +2468,13 @@ function normalizeCodexRuntimeSandbox(value: unknown): AgentChatCodexSandbox | u
function applyCodexEffectiveThreadState(
managed: ManagedChatSession,
response: CodexThreadLifecycleResponse | null | undefined,
+ options: {
+ requestedReasoningEffort?: string | null;
+ onReasoningMismatch?: (mismatch: {
+ requestedReasoningEffort: string;
+ runtimeReasoningEffort: string;
+ }) => void;
+ } = {},
): void {
if (!response) return;
@@ -2406,6 +2495,19 @@ function applyCodexEffectiveThreadState(
),
);
if (reasoningEffort) {
+ const requestedReasoningEffort = validateReasoningEffort(
+ "codex",
+ normalizeReasoningEffort(options.requestedReasoningEffort),
+ );
+ if (requestedReasoningEffort && reasoningEffort !== requestedReasoningEffort) {
+ options.onReasoningMismatch?.({
+ requestedReasoningEffort,
+ runtimeReasoningEffort: reasoningEffort,
+ });
+ managed.session.reasoningEffort = reasoningEffort;
+ managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode;
+ return;
+ }
managed.session.reasoningEffort = reasoningEffort;
}
@@ -5224,7 +5326,35 @@ export function createAgentChatService(args: {
].join("\n");
};
- const setManagedSessionTitle = (managed: ManagedChatSession, rawTitle: string): string | null => {
+ const sessionIsManuallyNamed = (managed: ManagedChatSession): boolean => {
+ if (managed.manuallyNamed) return true;
+ const row = sessionService.get(managed.session.id);
+ if (row?.manuallyNamed === true) {
+ managed.manuallyNamed = true;
+ return true;
+ }
+ return false;
+ };
+
+ const normalizeRuntimeSessionTitle = (managed: ManagedChatSession, rawTitle: unknown): string | null => {
+ const title = sanitizeAutoTitle(extractRuntimeTitle(rawTitle) ?? "");
+ if (!title) return null;
+ const normalized = title.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
+ if (!normalized.length || DEFAULT_SESSION_TITLES_NORMALIZED.has(normalized)) return null;
+ if (normalized === defaultChatSessionTitle(managed.session.provider).toLowerCase()) return null;
+ return title;
+ };
+
+ const manualSessionTitleForRuntime = (managed: ManagedChatSession): string | null => {
+ if (!sessionIsManuallyNamed(managed)) return null;
+ return sanitizeAutoTitle(sessionService.get(managed.session.id)?.title ?? "");
+ };
+
+ const setManagedSessionTitle = (
+ managed: ManagedChatSession,
+ rawTitle: string,
+ options: { syncToRuntime?: boolean } = {},
+ ): string | null => {
const title = sanitizeAutoTitle(rawTitle);
if (!title) return null;
@@ -5232,9 +5362,10 @@ export function createAgentChatService(args: {
if (currentTitle?.trim() === title) return title;
sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false });
+ managed.manuallyNamed = false;
- // Sync title to Codex thread if applicable
- if (managed.session.provider === "codex" && managed.session.threadId && managed.runtime?.kind === "codex") {
+ // Sync ADE-generated titles to Codex so the app-server and ADE agree.
+ if (options.syncToRuntime !== false && managed.session.provider === "codex" && managed.session.threadId && managed.runtime?.kind === "codex") {
managed.runtime.request("thread/name/set", {
threadId: managed.session.threadId,
name: title,
@@ -5244,6 +5375,33 @@ export function createAgentChatService(args: {
return title;
};
+ const adoptRuntimeSessionTitle = (
+ managed: ManagedChatSession,
+ rawTitle: unknown,
+ source: string,
+ ): string | null => {
+ if (managed.deleted) return null;
+ if (sessionIsManuallyNamed(managed)) return null;
+ const title = normalizeRuntimeSessionTitle(managed, rawTitle);
+ if (!title) return null;
+
+ const currentTitle = sessionService.get(managed.session.id)?.title ?? null;
+ if (currentTitle?.trim() !== title) {
+ sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false });
+ }
+ managed.manuallyNamed = false;
+ managed.runtimeTitleAdopted = true;
+ managed.autoTitleStage = "initial";
+ logger.info("agent_chat.runtime_title_adopted", {
+ sessionId: managed.session.id,
+ provider: managed.session.provider,
+ source,
+ titleLength: title.length,
+ });
+ persistChatState(managed);
+ return title;
+ };
+
const maybeAutoTitleSession = async (
managed: ManagedChatSession,
args: { stage: "initial" | "final"; latestUserText?: string | null; summary?: string | null }
@@ -5251,7 +5409,8 @@ export function createAgentChatService(args: {
if (managed.deleted) return;
const config = resolveChatConfig();
if (!config.titleGenerationEnabled) return;
- if (managed.manuallyNamed) return;
+ if (sessionIsManuallyNamed(managed)) return;
+ if (managed.runtimeTitleAdopted) return;
if (managed.autoTitleInFlight) return;
if (args.stage === "initial" && managed.autoTitleStage !== "none") return;
if (args.stage === "final") {
@@ -5316,7 +5475,8 @@ export function createAgentChatService(args: {
taskType: "session_title",
});
// Re-check after async — user may have manually renamed while the request was in flight.
- if (managed.manuallyNamed) return;
+ if (sessionIsManuallyNamed(managed)) return;
+ if (managed.runtimeTitleAdopted) return;
const nextTitle = setManagedSessionTitle(managed, result.text);
if (!nextTitle) return;
managed.autoTitleStage = args.stage;
@@ -5438,7 +5598,7 @@ export function createAgentChatService(args: {
}
const handle = await startOpenCodeSession({
directory: managed.laneWorktreePath,
- title: sessionService.get(managed.session.id)?.title ?? defaultChatSessionTitle("opencode"),
+ title: manualSessionTitleForRuntime(managed),
sessionId: persisted?.providerSessionId,
projectConfig: configSnapshot.effective,
discoveredLocalModels,
@@ -5448,6 +5608,7 @@ export function createAgentChatService(args: {
leaseKind: "shared",
logger,
});
+ adoptRuntimeSessionTitle(managed, handle.initialTitle, "opencode_session_create");
const runtime: OpenCodeRuntime = {
kind: "opencode",
@@ -5910,6 +6071,7 @@ export function createAgentChatService(args: {
...(managed.session.cursorModeSnapshot ? { cursorModeSnapshot: managed.session.cursorModeSnapshot } : {}),
...(managed.session.cursorModeId !== undefined ? { cursorModeId: managed.session.cursorModeId } : {}),
...(managed.session.cursorConfigValues ? { cursorConfigValues: managed.session.cursorConfigValues } : {}),
+ ...(managed.runtimeTitleAdopted ? { runtimeTitleAdopted: true } : {}),
...(managed.session.permissionMode ? { permissionMode: managed.session.permissionMode } : {}),
...(managed.session.identityKey ? { identityKey: managed.session.identityKey } : {}),
...(managed.session.surface ? { surface: managed.session.surface } : {}),
@@ -5978,11 +6140,7 @@ export function createAgentChatService(args: {
...(managed.lastLaneDirectiveKey
? { lastLaneDirectiveKey: managed.lastLaneDirectiveKey }
: prevPersisted?.lastLaneDirectiveKey ? { lastLaneDirectiveKey: prevPersisted.lastLaneDirectiveKey } : {}),
- manuallyNamed: Boolean(managed.manuallyNamed)
- || (() => {
- const trimmedTitle = String(sessionService.get(managed.session.id)?.title || "").trim();
- return trimmedTitle.length > 0 && !DEFAULT_SESSION_TITLES.has(trimmedTitle);
- })(),
+ manuallyNamed: Boolean(managed.manuallyNamed) || sessionService.get(managed.session.id)?.manuallyNamed === true,
...(hasLivePendingInput(managed) ? { awaitingInput: true } : {}),
...(managed.session.requestedCwd != null && String(managed.session.requestedCwd).trim().length
? { requestedCwd: String(managed.session.requestedCwd).trim() }
@@ -6162,6 +6320,7 @@ export function createAgentChatService(args: {
? { lastLaneDirectiveKey: record.lastLaneDirectiveKey.trim() }
: {}),
...(record.manuallyNamed === true ? { manuallyNamed: true } : {}),
+ ...(record.runtimeTitleAdopted === true ? { runtimeTitleAdopted: true } : {}),
...(record.awaitingInput === true ? { awaitingInput: true } : {}),
...(typeof record.requestedCwd === "string" && record.requestedCwd.trim().length
? { requestedCwd: record.requestedCwd.trim() }
@@ -7061,11 +7220,8 @@ export function createAgentChatService(args: {
autoTitleSeed: null,
autoTitleStage: hasCustomChatSessionTitle(row.title, provider) ? "initial" : "none",
autoTitleInFlight: false,
- manuallyNamed: persisted?.manuallyNamed === true
- || (() => {
- const trimmedTitle = String(row.title || "").trim();
- return trimmedTitle.length > 0 && !DEFAULT_SESSION_TITLES.has(trimmedTitle);
- })(),
+ runtimeTitleAdopted: persisted?.runtimeTitleAdopted === true,
+ manuallyNamed: persisted?.manuallyNamed === true || row.manuallyNamed === true,
summaryInFlight: false,
continuitySummary: persisted?.continuitySummary ?? null,
continuitySummaryUpdatedAt: persisted?.continuitySummaryUpdatedAt ?? null,
@@ -8656,6 +8812,11 @@ export function createAgentChatService(args: {
continue;
}
+ if (event.type === "session.created" || event.type === "session.updated") {
+ adoptRuntimeSessionTitle(managed, event.properties.info, `opencode_${event.type}`);
+ continue;
+ }
+
if (event.type === "session.compacted") {
emitChatEvent(managed, {
type: "context_compact",
@@ -9833,6 +9994,13 @@ export function createAgentChatService(args: {
return;
}
+ if (method === "thread/name/updated" || method === "thread/updated") {
+ if (extractRuntimeTitle(params)) {
+ adoptRuntimeSessionTitle(managed, params, `codex_${method.replace(/[^\w]+/g, "_")}`);
+ return;
+ }
+ }
+
const isIgnoredTurn = turnIdFromParams ? runtime.ignoredTurnIds.has(turnIdFromParams) : false;
const isTerminalTurnNotification =
method === "turn/completed"
@@ -10616,10 +10784,12 @@ export function createAgentChatService(args: {
runtime: CodexRuntime,
codexPolicy: CodexPolicy,
): Promise => {
- const reasoningEffort = validateReasoningEffort(
- "codex",
- normalizeReasoningEffort(managed.session.reasoningEffort),
- ) ?? DEFAULT_REASONING_EFFORT;
+ const descriptor = resolveSessionModelDescriptor(managed.session);
+ const reasoningEffort = resolveCodexReasoningEffortForRuntime(
+ managed.session.reasoningEffort,
+ null,
+ descriptor,
+ );
managed.session.reasoningEffort = reasoningEffort;
const startResponse = await runtime.request("thread/start", {
model: managed.session.model,
@@ -10629,7 +10799,16 @@ export function createAgentChatService(args: {
experimentalRawEvents: false,
persistExtendedHistory: true
});
- applyCodexEffectiveThreadState(managed, startResponse);
+ applyCodexEffectiveThreadState(managed, startResponse, {
+ requestedReasoningEffort: reasoningEffort,
+ onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", {
+ sessionId: managed.session.id,
+ phase: "thread_start",
+ model: managed.session.model,
+ ...mismatch,
+ }),
+ });
+ adoptRuntimeSessionTitle(managed, startResponse, "codex_thread_start");
const newThreadId = typeof startResponse.thread?.id === "string" ? startResponse.thread.id : undefined;
if (newThreadId) {
managed.session.threadId = newThreadId;
@@ -10783,7 +10962,7 @@ export function createAgentChatService(args: {
});
if (claudeSupportsReasoning) {
const effort = managed.session.reasoningEffort;
- if (effort === "low" || effort === "medium" || effort === "high" || effort === "max") {
+ if (effort === "low" || effort === "medium" || effort === "high" || effort === "xhigh" || effort === "max") {
opts.effort = effort as any;
}
const tokens = effort ? CLAUDE_EFFORT_TO_TOKENS[effort] : undefined;
@@ -11347,6 +11526,7 @@ export function createAgentChatService(args: {
autoTitleSeed: null,
autoTitleStage: "none",
autoTitleInFlight: false,
+ runtimeTitleAdopted: false,
manuallyNamed: false,
summaryInFlight: false,
continuitySummary: null,
@@ -11441,6 +11621,9 @@ export function createAgentChatService(args: {
return codexModelInfoFromDescriptor(descriptor, {
description: appServerEntry?.description ?? describeCodexModel(descriptor.displayName),
isDefault: descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id,
+ reasoningEfforts: appServerEntry?.reasoningEfforts?.length
+ ? appServerEntry.reasoningEfforts
+ : undefined,
});
});
@@ -11496,7 +11679,12 @@ export function createAgentChatService(args: {
reasoningEfforts: descriptor.capabilities.reasoning && descriptor.reasoningTiers?.length
? CLAUDE_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort))
: [],
- maxThinkingTokens: descriptor.capabilities.reasoning ? CLAUDE_EFFORT_TO_TOKENS.high : null
+ maxThinkingTokens: descriptor.capabilities.reasoning ? CLAUDE_EFFORT_TO_TOKENS.high : null,
+ modelId: descriptor.id,
+ family: descriptor.family,
+ supportsReasoning: descriptor.capabilities.reasoning,
+ supportsTools: descriptor.capabilities.tools,
+ color: descriptor.color
};
});
@@ -11600,7 +11788,11 @@ export function createAgentChatService(args: {
? rawEffort
: effectiveProvider === "cursor" || effectiveProvider === "droid"
? null
- : validateReasoningEffort(effectiveProvider === "claude" ? "claude" : "codex", rawEffort);
+ : validateReasoningEffortForDescriptor(
+ effectiveProvider === "claude" ? "claude" : "codex",
+ rawEffort,
+ resolvedDescriptor,
+ );
const normalizedCursorModeId = typeof requestedCursorModeId === "string"
? (requestedCursorModeId.trim() || null)
: requestedCursorModeId === null
@@ -11746,6 +11938,7 @@ export function createAgentChatService(args: {
autoTitleSeed: null,
autoTitleStage: "none",
autoTitleInFlight: false,
+ runtimeTitleAdopted: false,
manuallyNamed: false,
summaryInFlight: false,
continuitySummary: null,
@@ -11964,6 +12157,15 @@ export function createAgentChatService(args: {
) {
throw new Error(claudeRuntimeHealth.message ?? CLAUDE_RUNTIME_AUTH_ERROR);
}
+ const cursorRuntimeHealth = managed.session.provider === "cursor"
+ ? getProviderRuntimeHealth("cursor")
+ : null;
+ if (
+ managed.session.provider === "cursor"
+ && (cursorRuntimeHealth?.state === "auth-failed" || cursorRuntimeHealth?.state === "runtime-failed")
+ ) {
+ throw new Error(cursorRuntimeHealth.message ?? CURSOR_RUNTIME_AUTH_ERROR);
+ }
if (managed.session.status === "ended") {
sessionService.reopen(sessionId);
@@ -11982,13 +12184,6 @@ export function createAgentChatService(args: {
throw new Error("Turn is already active.");
}
- if (!managed.autoTitleSeed) {
- managed.autoTitleSeed = visibleText;
- void maybeAutoTitleSession(managed, {
- stage: "initial",
- latestUserText: visibleText,
- });
- }
if (managed.session.provider === "claude") {
managed.session.interactionMode = interactionMode ?? managed.session.interactionMode ?? "default";
managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode;
@@ -12040,6 +12235,16 @@ export function createAgentChatService(args: {
computerUseArtifactBrokerRef?.getBackendStatus() ?? null,
),
]);
+ const autoTitleSeed = providerSlashCommand
+ ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? null
+ : visibleText;
+ if (!managed.autoTitleSeed && autoTitleSeed) {
+ managed.autoTitleSeed = autoTitleSeed;
+ void maybeAutoTitleSession(managed, {
+ stage: "initial",
+ latestUserText: autoTitleSeed,
+ });
+ }
if (executionMode) {
managed.session.executionMode = executionMode;
} else if (managed.session.executionMode == null) {
@@ -12068,10 +12273,10 @@ export function createAgentChatService(args: {
if (managed.closed) return;
const descriptor = resolveSessionModelDescriptor(managed.session);
- const acpError = (managed.session.provider === "cursor" || managed.session.provider === "droid")
+ const acpError = managed.session.provider === "droid"
? classifyAcpHostError(
error,
- managed.session.provider === "droid" ? "Factory Droid" : "Cursor",
+ "Factory Droid",
descriptor?.displayName ?? managed.session.model,
)
: null;
@@ -13158,6 +13363,8 @@ export function createAgentChatService(args: {
void refreshDroidSessionState(owner, rt, "session_update").then(() => {
persistChatState(owner);
});
+ } else if (note.update.sessionUpdate === "session_info_update") {
+ adoptRuntimeSessionTitle(owner, note.update, "droid_session_info_update");
}
const turnId = rt.activeTurnId ?? "";
const resolveTerminal = (tid: string) => {
@@ -13351,6 +13558,7 @@ export function createAgentChatService(args: {
policy,
laneRoot: managed.laneWorktreePath,
sessionAllowedTools: runtime.sdkApprovedTools,
+ userHomeDir: resolveCursorSdkUserHome(),
});
if (preflight === "allow") return allowCursorHook();
if (preflight === "deny") {
@@ -13435,18 +13643,30 @@ export function createAgentChatService(args: {
persisted?.cursorSdkAgentProtocolVersion === CURSOR_SDK_AGENT_PROTOCOL_VERSION
? persisted.cursorSdkAgentId ?? null
: null;
- const acquired = await acquireCursorSdkConnection({
- poolKey,
- projectRoot,
- workspacePath: managed.laneWorktreePath,
- modelSdkId: launchModelSdkId,
- apiKey,
- agentId: persistedCursorSdkAgentId,
- agentName: sessionService.get(managed.session.id)?.title ?? defaultChatSessionTitle("cursor"),
- sessionId: managed.session.id,
- policy,
- logger,
- });
+ let acquired: Awaited>;
+ try {
+ acquired = await acquireCursorSdkConnection({
+ poolKey,
+ projectRoot,
+ workspacePath: managed.laneWorktreePath,
+ modelSdkId: launchModelSdkId,
+ apiKey,
+ agentId: persistedCursorSdkAgentId,
+ agentName: manualSessionTitleForRuntime(managed),
+ sessionId: managed.session.id,
+ policy,
+ logger,
+ });
+ reportProviderRuntimeReady("cursor");
+ } catch (error) {
+ const errorMessage = readErrorMessage(error);
+ if (isCursorRuntimeAuthError(error)) {
+ reportProviderRuntimeAuthFailure("cursor", CURSOR_RUNTIME_AUTH_ERROR);
+ } else {
+ reportProviderRuntimeFailure("cursor", errorMessage);
+ }
+ throw error;
+ }
const pooled = acquired.pooled;
const rt: CursorRuntime = {
kind: "cursor",
@@ -13612,6 +13832,7 @@ export function createAgentChatService(args: {
const resultRecord = asRecord(result);
const resultStatus = typeof resultRecord?.status === "string" ? resultRecord.status : "";
+ adoptRuntimeSessionTitle(managed, resultRecord, "cursor_sdk_run_result");
const doneEvent = mapCursorSdkRunResultToDoneEvent(result, {
turnId,
model: managed.session.model,
@@ -13942,10 +14163,12 @@ export function createAgentChatService(args: {
);
} else {
const repoUrl = await resolveCloudRepoUrl(managed, args.cloudOverrides);
+ const manualAgentName = manualSessionTitleForRuntime(managed);
const payload: CursorSdkCloudSendStreamPayload = {
apiKey,
promptText,
repoUrl,
+ ...(manualAgentName ? { agentName: manualAgentName } : {}),
...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}),
...(args.cloudOverrides?.startingRef ? { startingRef: args.cloudOverrides.startingRef } : {}),
...(args.cloudOverrides?.prUrl !== undefined ? { prUrl: args.cloudOverrides.prUrl } : {}),
@@ -13973,6 +14196,7 @@ export function createAgentChatService(args: {
const innerResult = "result" in startedRecord ? startedRecord.result : startedRecord;
const resultRecord = asRecord(innerResult) ?? asRecord(startedRecord) ?? null;
const resultStatus = typeof resultRecord?.status === "string" ? resultRecord.status : "";
+ adoptRuntimeSessionTitle(managed, startedRecord, "cursor_cloud_agent_info");
if (runStartedAgentId) {
managed.session.cursorCloudAgentId = runStartedAgentId;
@@ -14962,7 +15186,11 @@ export function createAgentChatService(args: {
if (managed.session.provider === "codex") {
const runtime = await ensureCodexSessionRuntime(managed);
- const nextReasoningEffort = validateReasoningEffort("codex", normalizeReasoningEffort(reasoningEffort));
+ const nextReasoningEffort = validateReasoningEffortForDescriptor(
+ "codex",
+ normalizeReasoningEffort(reasoningEffort),
+ resolveSessionModelDescriptor(managed.session),
+ );
if (nextReasoningEffort) {
managed.session.reasoningEffort = nextReasoningEffort;
} else if (!managed.session.reasoningEffort) {
@@ -14995,6 +15223,7 @@ export function createAgentChatService(args: {
const resumeReasoningEffort = resolveCodexReasoningEffortForRuntime(
managed.session.reasoningEffort,
readPersistedState(sessionId)?.reasoningEffort,
+ resolveSessionModelDescriptor(managed.session),
);
managed.session.reasoningEffort = resumeReasoningEffort;
const resumeResponse = await runtime.request("thread/resume", {
@@ -15005,7 +15234,17 @@ export function createAgentChatService(args: {
...codexPolicyArgs(codexPolicy),
persistExtendedHistory: true
});
- applyCodexEffectiveThreadState(managed, resumeResponse);
+ applyCodexEffectiveThreadState(managed, resumeResponse, {
+ requestedReasoningEffort: resumeReasoningEffort,
+ onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", {
+ sessionId: managed.session.id,
+ phase: "thread_resume",
+ model: managed.session.model,
+ threadId: threadIdToResume,
+ ...mismatch,
+ }),
+ });
+ adoptRuntimeSessionTitle(managed, resumeResponse, "codex_thread_resume");
const resumedThreadId = typeof resumeResponse.thread?.id === "string"
? resumeResponse.thread.id
: threadIdToResume;
@@ -15061,7 +15300,11 @@ export function createAgentChatService(args: {
return;
}
- const nextClaudeEffort = validateReasoningEffort("claude", normalizeReasoningEffort(reasoningEffort));
+ const nextClaudeEffort = validateReasoningEffortForDescriptor(
+ "claude",
+ normalizeReasoningEffort(reasoningEffort),
+ resolveSessionModelDescriptor(managed.session),
+ );
if (nextClaudeEffort) {
managed.session.reasoningEffort = nextClaudeEffort;
}
@@ -15805,6 +16048,7 @@ export function createAgentChatService(args: {
managed.session.reasoningEffort = resolveCodexReasoningEffortForRuntime(
managed.session.reasoningEffort,
persisted?.reasoningEffort,
+ resolveSessionModelDescriptor(managed.session),
);
const threadId = persisted?.threadId ?? managed.session.threadId;
if (threadId) {
@@ -15818,7 +16062,17 @@ export function createAgentChatService(args: {
...codexPolicyArgs(codexPolicy),
persistExtendedHistory: true
});
- applyCodexEffectiveThreadState(managed, resumeResponse);
+ applyCodexEffectiveThreadState(managed, resumeResponse, {
+ requestedReasoningEffort: managed.session.reasoningEffort,
+ onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", {
+ sessionId: managed.session.id,
+ phase: "resume_session",
+ model: managed.session.model,
+ threadId,
+ ...mismatch,
+ }),
+ });
+ adoptRuntimeSessionTitle(managed, resumeResponse, "codex_thread_resume");
const resumedThreadId = typeof resumeResponse.thread?.id === "string"
? resumeResponse.thread.id
: threadId;
@@ -16471,7 +16725,9 @@ export function createAgentChatService(args: {
const apiKey = getCursorSdkApiKey();
if (!apiKey) return [];
try {
- const ordered = await discoverCursorSdkModelDescriptors(apiKey);
+ const ordered = await discoverCursorSdkModelDescriptors(apiKey, {
+ mode: args.activateRuntime ? "probe" : "cached-or-fallback",
+ });
const preferred = pickDefaultCursorDescriptorFromCliList(ordered);
return ordered.map((d) => ({
id: d.id,
@@ -16482,6 +16738,11 @@ export function createAgentChatService(args: {
effort: tier,
description: `${tier} reasoning`,
})) ?? [],
+ modelId: d.id,
+ family: d.family,
+ supportsReasoning: d.capabilities.reasoning,
+ supportsTools: d.capabilities.tools,
+ color: d.color,
}));
} catch {
return [];
@@ -16503,6 +16764,11 @@ export function createAgentChatService(args: {
effort: tier,
description: `${tier} reasoning`,
})) ?? [],
+ modelId: d.id,
+ family: d.family,
+ supportsReasoning: d.capabilities.reasoning,
+ supportsTools: d.capabilities.tools,
+ color: d.color,
}));
} catch {
return [];
@@ -16554,6 +16820,11 @@ export function createAgentChatService(args: {
description: string;
isDefault: boolean;
reasoningEfforts: Array<{ effort: string; description: string }>;
+ modelId: string;
+ family: string;
+ supportsReasoning: boolean;
+ supportsTools: boolean;
+ color: string;
}> = [];
let firstRow = true;
for (const id of modelIds) {
@@ -16569,6 +16840,11 @@ export function createAgentChatService(args: {
effort: tier,
description: `${tier} reasoning`,
})),
+ modelId: descriptor.id,
+ family: descriptor.family,
+ supportsReasoning: descriptor.capabilities.reasoning,
+ supportsTools: descriptor.capabilities.tools,
+ color: descriptor.color,
});
firstRow = false;
}
@@ -16596,6 +16872,11 @@ export function createAgentChatService(args: {
effort: tier,
description: `${tier} reasoning`
})) ?? [],
+ modelId: m.id,
+ family: m.family,
+ supportsReasoning: m.capabilities.reasoning,
+ supportsTools: m.capabilities.tools,
+ color: m.color,
}));
}
} catch {
@@ -16628,6 +16909,101 @@ export function createAgentChatService(args: {
}
};
+ const getModelCatalog = async (): Promise => {
+ const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid", "opencode"];
+ const modelsByProvider = await Promise.all(
+ catalogProviders.map(async (provider) => {
+ try {
+ return {
+ provider,
+ models: await getAvailableModels({ provider, activateRuntime: provider === "cursor" }),
+ };
+ } catch {
+ return { provider, models: [] };
+ }
+ }),
+ );
+
+ const descriptorInfo = new Map();
+ const descriptors: ModelDescriptor[] = [];
+ for (const { provider, models } of modelsByProvider) {
+ for (const info of models) {
+ const descriptor =
+ resolveModelDescriptorForProvider(info.modelId ?? info.id, provider)
+ ?? resolveModelDescriptorForProvider(info.id, provider);
+ if (!descriptor) continue;
+ const runtimeTiers = info.reasoningEfforts
+ ?.map((entry) => normalizeReasoningEffort(entry.effort))
+ .filter((entry): entry is string => Boolean(entry));
+ const patched: ModelDescriptor = {
+ ...descriptor,
+ displayName: info.displayName?.trim() || descriptor.displayName,
+ ...(info.color ? { color: info.color } : {}),
+ capabilities: {
+ ...descriptor.capabilities,
+ ...(typeof info.supportsReasoning === "boolean" ? { reasoning: info.supportsReasoning } : {}),
+ ...(typeof info.supportsTools === "boolean" ? { tools: info.supportsTools } : {}),
+ },
+ ...(runtimeTiers?.length ? { reasoningTiers: runtimeTiers } : {}),
+ };
+ descriptors.push(patched);
+ descriptorInfo.set(catalogDescriptorInfoKey(provider, patched.family, patched.id), { provider, info });
+ }
+ }
+
+ const opencodeInventory = peekOpenCodeInventoryCache({
+ projectRoot,
+ projectConfig: projectConfigService.get().effective,
+ });
+ const blocks = buildProviderGroupBlocks(descriptors, createModelOrderMap(), opencodeInventory?.providers);
+
+ return {
+ fetchedAt: nowIso(),
+ groups: blocks.map((group) => ({
+ key: group.key,
+ displayName: group.label,
+ providers: group.providers.map((provider) => ({
+ key: provider.key,
+ displayName: provider.label,
+ badgeColor: provider.badgeColor,
+ modelCount: provider.modelCount,
+ subsections: provider.subsections.map((subsection) => ({
+ key: subsection.key,
+ label: subsection.label,
+ models: subsection.models.map((descriptor) => {
+ const entry = descriptorInfo.get(catalogDescriptorInfoKey(group.key, provider.key, descriptor.id));
+ const runtimeProvider = entry?.provider ?? resolveProviderGroupForModel(descriptor);
+ const runtimeModelId = entry?.info.id ?? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider);
+ const reasoningEfforts = entry?.info.reasoningEfforts
+ ?? descriptor.reasoningTiers?.map((tier) => ({
+ effort: tier,
+ description: `${tier} reasoning`,
+ }));
+ return {
+ id: descriptor.id,
+ runtimeModelId,
+ provider: runtimeProvider,
+ providerKey: provider.key,
+ groupKey: group.key,
+ displayName: descriptor.displayName,
+ description: entry?.info.description ?? null,
+ isDefault: entry?.info.isDefault ?? false,
+ ...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
+ maxThinkingTokens: entry?.info.maxThinkingTokens ?? null,
+ modelId: descriptor.id,
+ family: descriptor.family,
+ supportsReasoning: descriptor.capabilities.reasoning,
+ supportsTools: descriptor.capabilities.tools,
+ color: descriptor.color,
+ isAvailable: Boolean(entry),
+ };
+ }),
+ })),
+ })),
+ })),
+ };
+ };
+
const dispose = async ({ sessionId }: AgentChatDisposeArgs): Promise => {
const managed = ensureManagedSession(sessionId);
@@ -16924,6 +17300,9 @@ export function createAgentChatService(args: {
teardownRuntime(managed, "model_switch");
refreshReconstructionContext(managed);
}
+ if (modelChanged) {
+ managed.runtimeTitleAdopted = false;
+ }
const currentTitle = sessionService.get(sessionId)?.title ?? null;
managed.session.provider = nextProvider;
@@ -16958,7 +17337,14 @@ export function createAgentChatService(args: {
// Apply reasoningEffort BEFORE pre-warming so the V2 session is created
// with the correct thinking configuration.
if (reasoningEffort !== undefined) {
- managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort);
+ const requested = normalizeReasoningEffort(reasoningEffort);
+ managed.session.reasoningEffort = nextProvider === "codex"
+ ? validateReasoningEffortForDescriptor("codex", requested, descriptor)
+ : nextProvider === "claude"
+ ? validateReasoningEffortForDescriptor("claude", requested, descriptor)
+ : nextProvider === "opencode"
+ ? requested
+ : null;
}
// Pre-warm the Claude V2 session when the user selects an Anthropic model.
@@ -16981,7 +17367,15 @@ export function createAgentChatService(args: {
}
} else if (reasoningEffort !== undefined) {
const prev = managed.session.reasoningEffort ?? null;
- managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort);
+ const requested = normalizeReasoningEffort(reasoningEffort);
+ const descriptor = resolveSessionModelDescriptor(managed.session);
+ managed.session.reasoningEffort = managed.session.provider === "codex"
+ ? validateReasoningEffortForDescriptor("codex", requested, descriptor)
+ : managed.session.provider === "claude"
+ ? validateReasoningEffortForDescriptor("claude", requested, descriptor)
+ : managed.session.provider === "opencode"
+ ? requested
+ : null;
const next = managed.session.reasoningEffort ?? null;
// When reasoning effort changes on a Claude session with an active V2
// session, invalidate the V2 session so it is recreated on the next turn
@@ -17130,10 +17524,12 @@ export function createAgentChatService(args: {
} else {
managed.manuallyNamed = false;
}
+ managed.runtimeTitleAdopted = false;
}
// Allow resetting manuallyNamed independently when no title change is provided
if (manuallyNamed !== undefined && title === undefined) {
managed.manuallyNamed = manuallyNamed;
+ if (manuallyNamed) managed.runtimeTitleAdopted = false;
}
persistChatState(managed);
@@ -17596,6 +17992,7 @@ export function createAgentChatService(args: {
respondToInput,
requestChatInput,
getAvailableModels,
+ getModelCatalog,
getSlashCommands,
codexFuzzyFileSearch,
dispose,
diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts
index 464f19e46..f161ad109 100644
--- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts
+++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts
@@ -1,5 +1,42 @@
-import { describe, expect, it } from "vitest";
-import { parseCursorCliModelsStdout } from "./cursorModelsDiscovery";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const cursorModelsListMock = vi.hoisted(() => vi.fn());
+const reportProviderRuntimeAuthFailureMock = vi.hoisted(() => vi.fn());
+const reportProviderRuntimeReadyMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@cursor/sdk", () => ({
+ Cursor: {
+ models: {
+ list: (...args: unknown[]) => cursorModelsListMock(...args),
+ },
+ },
+}));
+
+vi.mock("../ai/providerRuntimeHealth", () => ({
+ reportProviderRuntimeAuthFailure: (...args: unknown[]) => reportProviderRuntimeAuthFailureMock(...args),
+ reportProviderRuntimeReady: (...args: unknown[]) => reportProviderRuntimeReadyMock(...args),
+}));
+
+import {
+ clearCursorCliModelsCache,
+ discoverCursorSdkModelDescriptors,
+ listCursorModelsFromSdk,
+ parseCursorCliModelsStdout,
+ probeCursorSdkModelDiscovery,
+} from "./cursorModelsDiscovery";
+
+beforeEach(() => {
+ cursorModelsListMock.mockReset();
+ reportProviderRuntimeAuthFailureMock.mockReset();
+ reportProviderRuntimeReadyMock.mockReset();
+ clearCursorCliModelsCache();
+ vi.useRealTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
describe("parseCursorCliModelsStdout", () => {
it("parses table lines with optional (current) suffix", () => {
@@ -22,4 +59,168 @@ describe("parseCursorCliModelsStdout", () => {
const rows = parseCursorCliModelsStdout("auto - Auto\nauto - Auto");
expect(rows).toHaveLength(1);
});
+
+ it("returns safe Cursor SDK fallbacks immediately while warming exact models", async () => {
+ let resolveModels!: (rows: Array<{ id: string; displayName?: string }>) => void;
+ cursorModelsListMock.mockReturnValue(new Promise>((resolve) => {
+ resolveModels = resolve;
+ }));
+
+ const initial = await discoverCursorSdkModelDescriptors("crsr_test");
+
+ expect(initial.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]);
+ await vi.waitFor(() => {
+ expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: "crsr_test" });
+ });
+
+ resolveModels([
+ { id: "claude-4.6-sonnet-medium", displayName: "Sonnet 4.6 Medium" },
+ { id: "auto", displayName: "Auto" },
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const warmed = await discoverCursorSdkModelDescriptors("crsr_test");
+ expect(warmed.map((descriptor) => descriptor.id)).toEqual([
+ "cursor/auto",
+ "cursor/claude-4.6-sonnet-medium",
+ ]);
+ expect(cursorModelsListMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("can warm exact Cursor SDK models without returning fallback rows", async () => {
+ let resolveModels!: (rows: Array<{ id: string; displayName?: string }>) => void;
+ cursorModelsListMock.mockReturnValue(new Promise>((resolve) => {
+ resolveModels = resolve;
+ }));
+
+ const initial = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-only" });
+
+ expect(initial).toEqual([]);
+ await vi.waitFor(() => {
+ expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: "crsr_test" });
+ });
+
+ resolveModels([
+ { id: "claude-4.6-sonnet-medium", displayName: "Sonnet 4.6 Medium" },
+ { id: "auto", displayName: "Auto" },
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const warmed = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-only" });
+ expect(warmed.map((descriptor) => descriptor.id)).toEqual([
+ "cursor/auto",
+ "cursor/claude-4.6-sonnet-medium",
+ ]);
+ });
+
+ it("probes exact Cursor SDK models when requested", async () => {
+ cursorModelsListMock.mockResolvedValue([
+ { id: "claude-4.6-sonnet-medium", displayName: "Sonnet 4.6 Medium" },
+ { id: "composer-2", displayName: "Composer 2" },
+ { id: "auto", displayName: "Auto" },
+ ]);
+
+ const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" });
+
+ expect(descriptors.map((descriptor) => descriptor.id)).toEqual([
+ "cursor/auto",
+ "cursor/claude-4.6-sonnet-medium",
+ "cursor/composer-2",
+ ]);
+ expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: "crsr_test" });
+ expect(reportProviderRuntimeReadyMock).toHaveBeenCalledWith("cursor");
+ });
+
+ it("falls back to Cursor's official models API when SDK model listing fails", async () => {
+ cursorModelsListMock.mockRejectedValue(new Error("SDK model listing failed"));
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({
+ models: ["claude-4-sonnet-thinking", "o3", "claude-4-opus-thinking"],
+ }),
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" });
+
+ expect(descriptors.map((descriptor) => descriptor.id)).toEqual([
+ "cursor/claude-4-opus-thinking",
+ "cursor/claude-4-sonnet-thinking",
+ "cursor/o3",
+ ]);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://api.cursor.com/v0/models",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer crsr_test",
+ }),
+ }),
+ );
+ expect(reportProviderRuntimeReadyMock).not.toHaveBeenCalled();
+ });
+
+ it("uses only conservative fallback rows when Cursor model APIs cannot enumerate", async () => {
+ cursorModelsListMock.mockRejectedValue(new Error("SDK model listing failed"));
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 503 })));
+
+ const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" });
+ expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]);
+ });
+
+ it("does not show fallback models when Cursor rejects agent/model auth", async () => {
+ cursorModelsListMock.mockRejectedValue(new Error("AuthenticationError (status=401, endpoint=GET /v1/models)"));
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 401 })));
+
+ await expect(probeCursorSdkModelDiscovery("crsr_test", { timeoutMs: 1_000 })).resolves.toMatchObject({
+ rows: [],
+ failureKind: "auth",
+ });
+
+ const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" });
+ expect(descriptors).toEqual([]);
+ });
+
+ it("does not reuse cached Cursor SDK rows for explicit probe verification", async () => {
+ cursorModelsListMock.mockResolvedValueOnce([{ id: "cached-model", name: "Cached Model" }]);
+ await expect(probeCursorSdkModelDiscovery("crsr_test", { timeoutMs: 1_000 })).resolves.toMatchObject({
+ rows: [{ id: "cached-model" }],
+ failureKind: null,
+ });
+
+ cursorModelsListMock.mockRejectedValue(new Error("AuthenticationError (status=401, endpoint=GET /v1/models)"));
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 401 })));
+
+ const freshProbe = await probeCursorSdkModelDiscovery("crsr_test", { timeoutMs: 1_000 });
+ expect(freshProbe).toMatchObject({
+ rows: [],
+ failureKind: "auth",
+ });
+ expect(freshProbe.fromCache).toBeUndefined();
+ });
+
+ it("suppresses fallback rows after a warm auth failure", async () => {
+ cursorModelsListMock.mockRejectedValue(new Error("AuthenticationError (status=401, endpoint=GET /v1/models)"));
+ const fetchMock = vi.fn(async () => ({ ok: false, status: 401 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const initial = await discoverCursorSdkModelDescriptors("crsr_test");
+ expect(initial.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]);
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalled();
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const afterFailure = await discoverCursorSdkModelDescriptors("crsr_test");
+ expect(afterFailure).toEqual([]);
+ });
+
+ it("bounds direct Cursor SDK model list discovery with a timeout", async () => {
+ vi.useFakeTimers();
+ cursorModelsListMock.mockReturnValue(new Promise(() => undefined));
+
+ const rowsPromise = listCursorModelsFromSdk("crsr_test", { timeoutMs: 25 });
+ await vi.advanceTimersByTimeAsync(26);
+
+ await expect(rowsPromise).resolves.toEqual([]);
+ });
});
diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts
index 61a050637..2036e856e 100644
--- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts
+++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts
@@ -6,15 +6,50 @@ import {
import { spawnAsync } from "../shared/utils";
import type { SDKModel } from "@cursor/sdk";
import { createHash } from "node:crypto";
+import {
+ reportProviderRuntimeAuthFailure,
+ reportProviderRuntimeReady,
+} from "../ai/providerRuntimeHealth";
export type CursorCliModelRow = { id: string; displayName?: string };
-type CursorCliModelDiscoveryMode = "probe" | "cached-or-fallback";
+type CursorCliModelDiscoveryMode = "probe" | "cached-or-fallback" | "cached-only";
+export type CursorModelDiscoveryFailureKind = "auth" | "timeout" | "unavailable";
+export type CursorSdkModelDiscoveryResult = {
+ rows: CursorCliModelRow[];
+ failureKind: CursorModelDiscoveryFailureKind | null;
+ errorMessage: string | null;
+ fromCache?: boolean;
+};
let cached: { at: number; models: CursorCliModelRow[] } | null = null;
let sdkCached: { at: number; keyHash: string; models: CursorCliModelRow[] } | null = null;
+let sdkWarmInFlight: { keyHash: string; promise: Promise } | null = null;
+let sdkLastFailure: { at: number; keyHash: string; kind: CursorModelDiscoveryFailureKind; message: string } | null = null;
+let sdkCacheGeneration = 0;
const TTL_MS = 120_000;
+const SDK_MODEL_LIST_TIMEOUT_MS = 5_000;
+const CURSOR_MODELS_API_URL = "https://api.cursor.com/v0/models";
+const CURSOR_AGENT_AUTH_BLOCKER =
+ "Cursor rejected the configured API key for agent/model access. Re-enter a Cursor API key from the Cursor dashboard integrations page.";
+
+class CursorModelDiscoveryError extends Error {
+ readonly kind: CursorModelDiscoveryFailureKind;
-const FALLBACK_SDK_IDS = ["auto", "composer-2"];
+ constructor(kind: CursorModelDiscoveryFailureKind, message: string) {
+ super(message);
+ this.name = "CursorModelDiscoveryError";
+ this.kind = kind;
+ }
+}
+
+const MINIMAL_FALLBACK_SDK_ROWS: CursorCliModelRow[] = [
+ { id: "auto", displayName: "Auto" },
+ { id: "composer-2", displayName: "Composer 2" },
+];
+
+function fallbackCursorSdkRows(): CursorCliModelRow[] {
+ return MINIMAL_FALLBACK_SDK_ROWS;
+}
function stripAnsi(text: string): string {
return text.replace(/\u001b\[[0-9;]*m/g, "");
@@ -53,6 +88,9 @@ export function parseCursorCliModelsStdout(stdout: string): CursorCliModelRow[]
export function clearCursorCliModelsCache(): void {
cached = null;
sdkCached = null;
+ sdkWarmInFlight = null;
+ sdkLastFailure = null;
+ sdkCacheGeneration += 1;
}
function hashKeyForCache(key: string | null | undefined): string {
@@ -60,6 +98,75 @@ function hashKeyForCache(key: string | null | undefined): string {
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}
+function readErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim()) return error.message.trim();
+ if (error && typeof error === "object") {
+ const record = error as Record;
+ if (typeof record.message === "string" && record.message.trim()) return record.message.trim();
+ }
+ return String(error ?? "Unknown Cursor model discovery error.");
+}
+
+function readErrorStatus(error: unknown): number | null {
+ if (!error || typeof error !== "object") return null;
+ const record = error as Record;
+ for (const key of ["status", "statusCode"] as const) {
+ if (typeof record[key] === "number") return record[key];
+ if (typeof record[key] === "string" && /^\d+$/.test(record[key])) return Number(record[key]);
+ }
+ const embedded = readErrorMessage(error).match(/\b(?:status|statusCode|HTTP)\s*[=:]?\s*(401|403|408|429|5\d\d)\b/i);
+ return embedded ? Number(embedded[1]) : null;
+}
+
+function classifyCursorModelDiscoveryError(error: unknown): CursorModelDiscoveryFailureKind {
+ if (error instanceof CursorModelDiscoveryError) return error.kind;
+ const status = readErrorStatus(error);
+ if (status === 401 || status === 403) return "auth";
+ const message = readErrorMessage(error).toLowerCase();
+ if (
+ message.includes("authentication")
+ || message.includes("unauthorized")
+ || message.includes("forbidden")
+ || message.includes("invalid api key")
+ || message.includes("api key")
+ ) {
+ return "auth";
+ }
+ if (message.includes("timeout") || message.includes("timed out") || status === 408) return "timeout";
+ return "unavailable";
+}
+
+function toCursorModelDiscoveryError(error: unknown): CursorModelDiscoveryError {
+ if (error instanceof CursorModelDiscoveryError) return error;
+ return new CursorModelDiscoveryError(
+ classifyCursorModelDiscoveryError(error),
+ readErrorMessage(error),
+ );
+}
+
+function recordCursorModelDiscoverySuccess(rows: CursorCliModelRow[]): void {
+ if (rows.length) reportProviderRuntimeReady("cursor");
+}
+
+function recordCursorModelDiscoveryFailure(error: unknown): CursorModelDiscoveryError {
+ const discoveryError = toCursorModelDiscoveryError(error);
+ if (discoveryError.kind === "auth") {
+ reportProviderRuntimeAuthFailure("cursor", CURSOR_AGENT_AUTH_BLOCKER);
+ }
+ return discoveryError;
+}
+
+function rememberCursorModelDiscoveryFailure(keyHash: string, error: unknown): CursorModelDiscoveryError {
+ const discoveryError = recordCursorModelDiscoveryFailure(error);
+ sdkLastFailure = {
+ at: Date.now(),
+ keyHash,
+ kind: discoveryError.kind,
+ message: discoveryError.message,
+ };
+ return discoveryError;
+}
+
function normalizeSdkModelRows(models: SDKModel[]): CursorCliModelRow[] {
const rows: CursorCliModelRow[] = [];
const seen = new Set();
@@ -73,25 +180,205 @@ function normalizeSdkModelRows(models: SDKModel[]): CursorCliModelRow[] {
return rows;
}
-export async function listCursorModelsFromSdk(apiKey?: string | null): Promise {
+function normalizeCursorModelRows(models: unknown[]): CursorCliModelRow[] {
+ const rows: CursorCliModelRow[] = [];
+ const seen = new Set();
+ for (const model of models) {
+ if (typeof model === "string") {
+ const id = model.trim();
+ if (id && !seen.has(id)) {
+ seen.add(id);
+ rows.push({ id });
+ }
+ continue;
+ }
+ if (!model || typeof model !== "object") continue;
+ const record = model as Record;
+ const id = typeof record.id === "string"
+ ? record.id.trim()
+ : typeof record.model === "string"
+ ? record.model.trim()
+ : "";
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ const displayName = typeof record.displayName === "string"
+ ? record.displayName.trim()
+ : typeof record.name === "string"
+ ? record.name.trim()
+ : "";
+ rows.push(displayName ? { id, displayName } : { id });
+ }
+ return rows;
+}
+
+function getCachedCursorSdkModels(apiKey?: string | null): CursorCliModelRow[] | null {
const now = Date.now();
const normalizedApiKey = apiKey?.trim() || undefined;
const keyHash = hashKeyForCache(normalizedApiKey);
if (sdkCached && sdkCached.keyHash === keyHash && now - sdkCached.at < TTL_MS && sdkCached.models.length) {
return sdkCached.models;
}
+ return null;
+}
+
+function getRecentCursorSdkFailure(apiKey?: string | null): typeof sdkLastFailure {
+ const normalizedApiKey = apiKey?.trim() || undefined;
+ const keyHash = hashKeyForCache(normalizedApiKey);
+ if (!sdkLastFailure || sdkLastFailure.keyHash !== keyHash) return null;
+ if (Date.now() - sdkLastFailure.at > TTL_MS) return null;
+ return sdkLastFailure;
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number): Promise {
+ let timeoutHandle: ReturnType | null = null;
try {
- const { Cursor } = await import("@cursor/sdk");
- const rows = normalizeSdkModelRows(await Cursor.models.list({ apiKey: normalizedApiKey }));
- if (rows.length) sdkCached = { at: now, keyHash, models: rows };
- return rows;
- } catch {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(() => reject(new Error("Cursor model discovery timed out.")), timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timeoutHandle) clearTimeout(timeoutHandle);
+ }
+}
+
+async function fetchCursorModelsFromSdk(
+ apiKey: string | undefined,
+ keyHash: string,
+ generation: number,
+ timeoutMs: number,
+): Promise {
+ let sdkSucceeded = false;
+ const rows = await withTimeout((async () => {
+ let sdkError: unknown = null;
+ try {
+ const { Cursor } = await import("@cursor/sdk");
+ const sdkRows = normalizeSdkModelRows(await Cursor.models.list({ apiKey }));
+ if (sdkRows.length) {
+ sdkSucceeded = true;
+ return sdkRows;
+ }
+ } catch (error) {
+ sdkError = error;
+ }
+
+ try {
+ const apiRows = await fetchCursorModelsFromOfficialApi(apiKey);
+ if (apiRows.length) return apiRows;
+ } catch (apiError) {
+ const apiFailure = toCursorModelDiscoveryError(apiError);
+ if (apiFailure.kind === "auth") throw apiFailure;
+ if (sdkError && classifyCursorModelDiscoveryError(sdkError) === "auth") {
+ throw toCursorModelDiscoveryError(sdkError);
+ }
+ throw apiFailure;
+ }
+
+ if (sdkError) throw toCursorModelDiscoveryError(sdkError);
+ return [];
+ })(), timeoutMs);
+ if (rows.length && generation === sdkCacheGeneration) {
+ sdkCached = { at: Date.now(), keyHash, models: rows };
+ }
+ if (sdkSucceeded && rows.length && sdkLastFailure?.keyHash === keyHash) {
+ sdkLastFailure = null;
+ }
+ if (sdkSucceeded) {
+ recordCursorModelDiscoverySuccess(rows);
+ }
+ return rows;
+}
+
+async function fetchCursorModelsFromOfficialApi(apiKey: string | undefined): Promise {
+ const token = apiKey?.trim();
+ if (!token) return [];
+ const response = await fetch(CURSOR_MODELS_API_URL, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json",
+ },
+ });
+ if (!response.ok) {
+ throw new CursorModelDiscoveryError(
+ response.status === 401 || response.status === 403 ? "auth" : "unavailable",
+ `Cursor model API returned HTTP ${response.status}.`,
+ );
+ }
+ const payload = await response.json() as { models?: unknown };
+ return Array.isArray(payload.models) ? normalizeCursorModelRows(payload.models) : [];
+}
+
+function warmCursorModelsFromSdk(apiKey?: string | null): void {
+ const normalizedApiKey = apiKey?.trim() || undefined;
+ const keyHash = hashKeyForCache(normalizedApiKey);
+ if (sdkWarmInFlight?.keyHash === keyHash) return;
+
+ const generation = sdkCacheGeneration;
+ const promise = fetchCursorModelsFromSdk(
+ normalizedApiKey,
+ keyHash,
+ generation,
+ SDK_MODEL_LIST_TIMEOUT_MS,
+ ).catch((error) => {
+ rememberCursorModelDiscoveryFailure(keyHash, error);
+ return [];
+ });
+ sdkWarmInFlight = { keyHash, promise };
+ promise.finally(() => {
+ if (sdkWarmInFlight?.promise === promise) sdkWarmInFlight = null;
+ });
+}
+
+export async function probeCursorSdkModelDiscovery(
+ apiKey?: string | null,
+ options?: { timeoutMs?: number; allowCached?: boolean },
+): Promise {
+ const cachedRows = options?.allowCached === true ? getCachedCursorSdkModels(apiKey) : null;
+ if (cachedRows) {
+ return {
+ rows: cachedRows,
+ failureKind: null,
+ errorMessage: null,
+ fromCache: true,
+ };
+ }
+
+ const normalizedApiKey = apiKey?.trim() || undefined;
+ const keyHash = hashKeyForCache(normalizedApiKey);
+ const generation = sdkCacheGeneration;
+ try {
+ const rows = await fetchCursorModelsFromSdk(
+ normalizedApiKey,
+ keyHash,
+ generation,
+ Math.max(1, options?.timeoutMs ?? SDK_MODEL_LIST_TIMEOUT_MS),
+ );
+ return {
+ rows,
+ failureKind: null,
+ errorMessage: null,
+ };
+ } catch (error) {
+ const discoveryError = rememberCursorModelDiscoveryFailure(keyHash, error);
// Best-effort: a transient SDK error or invalid key should not crash
// model resolution — fallback IDs cover the common case.
- return [];
+ return {
+ rows: [],
+ failureKind: discoveryError.kind,
+ errorMessage: discoveryError.message,
+ };
}
}
+export async function listCursorModelsFromSdk(
+ apiKey?: string | null,
+ options?: { timeoutMs?: number; allowCached?: boolean },
+): Promise {
+ return (await probeCursorSdkModelDiscovery(apiKey, options)).rows;
+}
+
function getCachedCursorModels(): CursorCliModelRow[] | null {
const now = Date.now();
if (cached && now - cached.at < TTL_MS && cached.models.length) {
@@ -100,6 +387,18 @@ function getCachedCursorModels(): CursorCliModelRow[] | null {
return null;
}
+function cursorRowsToDescriptors(rows: CursorCliModelRow[]): ModelDescriptor[] {
+ const seen = new Set();
+ const descriptors: ModelDescriptor[] = [];
+ for (const row of rows) {
+ const id = String(row.id ?? "").trim();
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ descriptors.push(createDynamicCursorCliModelDescriptor(id, row.displayName));
+ }
+ return sortCursorCliDescriptorsForPicker(descriptors);
+}
+
/**
* Best-effort: run `agent models` (and JSON variants) and parse stdout.
*/
@@ -174,28 +473,27 @@ export async function discoverCursorCliModelDescriptors(
const rows = options?.mode === "cached-or-fallback"
? getCachedCursorModels() ?? []
: await listCursorModelsFromCli(agentPath);
- const useRows: CursorCliModelRow[] = rows.length ? rows : FALLBACK_SDK_IDS.map((id) => ({ id }));
- const seen = new Set();
- const descriptors: ModelDescriptor[] = [];
- for (const row of useRows) {
- const id = String(row.id ?? "").trim();
- if (!id || seen.has(id)) continue;
- seen.add(id);
- descriptors.push(createDynamicCursorCliModelDescriptor(id, row.displayName));
- }
- return sortCursorCliDescriptorsForPicker(descriptors);
+ const useRows: CursorCliModelRow[] = rows.length ? rows : fallbackCursorSdkRows();
+ return cursorRowsToDescriptors(useRows);
}
-export async function discoverCursorSdkModelDescriptors(apiKey?: string | null): Promise {
- const rows = await listCursorModelsFromSdk(apiKey);
- const useRows: CursorCliModelRow[] = rows.length ? rows : FALLBACK_SDK_IDS.map((id) => ({ id }));
- const seen = new Set();
- const descriptors: ModelDescriptor[] = [];
- for (const row of useRows) {
- const id = String(row.id ?? "").trim();
- if (!id || seen.has(id)) continue;
- seen.add(id);
- descriptors.push(createDynamicCursorCliModelDescriptor(id, row.displayName));
+export async function discoverCursorSdkModelDescriptors(
+ apiKey?: string | null,
+ options?: { mode?: CursorCliModelDiscoveryMode; timeoutMs?: number },
+): Promise {
+ const result = options?.mode === "probe"
+ ? await probeCursorSdkModelDiscovery(apiKey, { timeoutMs: options?.timeoutMs })
+ : null;
+ const rows = result?.rows ?? getCachedCursorSdkModels(apiKey) ?? [];
+ const recentFailure = getRecentCursorSdkFailure(apiKey);
+ const knownAuthFailure = result?.failureKind === "auth" || recentFailure?.kind === "auth";
+ if (!rows.length && options?.mode !== "probe") {
+ warmCursorModelsFromSdk(apiKey);
}
- return sortCursorCliDescriptorsForPicker(descriptors);
+ const useRows: CursorCliModelRow[] = rows.length
+ ? rows
+ : options?.mode === "cached-only" || knownAuthFailure
+ ? []
+ : fallbackCursorSdkRows();
+ return cursorRowsToDescriptors(useRows);
}
diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts
new file mode 100644
index 000000000..d9681420c
--- /dev/null
+++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts
@@ -0,0 +1,232 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ __buildCursorSdkHookCommandForTests,
+ cursorSdkHookScriptPath,
+ cursorSdkHookWindowsCommandPath,
+ cursorSdkHooksJsonPath,
+ ensureCursorSdkUserHook,
+ writeCursorSdkHookWindowsCommandScript,
+} from "./cursorSdkHooks";
+
+function tempHome(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-home-"));
+}
+
+function readJson(filePath: string): any {
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+describe("Cursor SDK hook installation", () => {
+ it("merges ADE's preToolUse hook into the real Cursor hooks file idempotently", () => {
+ const home = tempHome();
+ try {
+ const hooksPath = cursorSdkHooksJsonPath(home);
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
+ fs.writeFileSync(hooksPath, JSON.stringify({
+ version: 2,
+ hooks: {
+ preToolUse: [{ command: "node existing-hook.cjs", failClosed: false }],
+ postToolUse: [{ command: "node post-hook.cjs" }],
+ },
+ }, null, 2));
+
+ const first = ensureCursorSdkUserHook({
+ userHomeDir: home,
+ nodePath: "/node path/bin/node",
+ });
+ expect(first.changed).toBe(true);
+ expect(first.command).toContain("\"/node path/bin/node\"");
+ expect(first.command).toContain("\"");
+
+ const config = readJson(hooksPath);
+ expect(config.version).toBe(2);
+ expect(config.hooks.postToolUse).toEqual([{ command: "node post-hook.cjs" }]);
+ expect(config.hooks.preToolUse).toHaveLength(2);
+ expect(config.hooks.preToolUse[0].command).toContain("ade-tool-gate.cjs");
+ expect(config.hooks.preToolUse[0].failClosed).toBe(true);
+ expect(config.hooks.preToolUse[1]).toEqual({ command: "node existing-hook.cjs", failClosed: false });
+ expect(fs.existsSync(cursorSdkHookScriptPath(home))).toBe(true);
+
+ const second = ensureCursorSdkUserHook({
+ userHomeDir: home,
+ nodePath: "/node path/bin/node",
+ });
+ expect(second.changed).toBe(false);
+ expect(readJson(hooksPath).hooks.preToolUse).toHaveLength(2);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("allows non-ADE Cursor hook invocations to pass through", () => {
+ const home = tempHome();
+ try {
+ ensureCursorSdkUserHook({ userHomeDir: home });
+ const stdout = execFileSync(process.execPath, [cursorSdkHookScriptPath(home)], {
+ input: "{}",
+ env: {
+ PATH: process.env.PATH ?? "",
+ HOME: home,
+ USERPROFILE: home,
+ },
+ encoding: "utf8",
+ });
+ expect(JSON.parse(stdout)).toEqual({ permission: "allow" });
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("fails closed when an ADE Cursor hook cannot reach the policy socket", () => {
+ const home = tempHome();
+ try {
+ ensureCursorSdkUserHook({ userHomeDir: home });
+ const stdout = execFileSync(process.execPath, [cursorSdkHookScriptPath(home)], {
+ input: "{}",
+ env: {
+ PATH: process.env.PATH ?? "",
+ HOME: home,
+ USERPROFILE: home,
+ ADE_CURSOR_SDK_SOCKET: path.join(home, "missing.sock"),
+ ADE_CURSOR_SDK_SESSION_ID: "session-1",
+ ADE_CURSOR_SDK_LANE_ROOT: "/tmp/lane",
+ },
+ encoding: "utf8",
+ });
+ const decision = JSON.parse(stdout);
+ expect(decision.permission).toBe("deny");
+ expect(decision.user_message).toContain("ENOENT");
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("fails closed when a hook is launched with an explicit socket argument", () => {
+ const home = tempHome();
+ try {
+ ensureCursorSdkUserHook({ userHomeDir: home });
+ const stdout = execFileSync(process.execPath, [
+ cursorSdkHookScriptPath(home),
+ "--socket",
+ path.join(home, "missing.sock"),
+ ], {
+ input: "{}",
+ env: {
+ PATH: process.env.PATH ?? "",
+ HOME: home,
+ USERPROFILE: home,
+ },
+ encoding: "utf8",
+ });
+ const decision = JSON.parse(stdout);
+ expect(decision.permission).toBe("deny");
+ expect(decision.user_message).toContain("ENOENT");
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("fails closed when ADE accepts the hook connection but does not answer", async () => {
+ if (process.platform === "win32") return;
+ const home = tempHome();
+ const socketPath = path.join(home, "silent.sock");
+ const server = net.createServer((socket) => {
+ socket.resume();
+ });
+ try {
+ ensureCursorSdkUserHook({ userHomeDir: home });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(socketPath, resolve);
+ });
+ const stdout = execFileSync(process.execPath, [cursorSdkHookScriptPath(home)], {
+ input: "{}",
+ env: {
+ PATH: process.env.PATH ?? "",
+ HOME: home,
+ USERPROFILE: home,
+ ADE_CURSOR_SDK_SOCKET: socketPath,
+ ADE_CURSOR_SDK_SESSION_ID: "session-1",
+ ADE_CURSOR_SDK_LANE_ROOT: "/tmp/lane",
+ ADE_CURSOR_SDK_RESPONSE_TIMEOUT_MS: "20",
+ },
+ encoding: "utf8",
+ });
+ const decision = JSON.parse(stdout);
+ expect(decision.permission).toBe("deny");
+ expect(decision.user_message).toContain("Timed out waiting");
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("does not overwrite malformed user hooks.json", () => {
+ const home = tempHome();
+ try {
+ const hooksPath = cursorSdkHooksJsonPath(home);
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
+ fs.writeFileSync(hooksPath, "{ nope");
+ expect(() => ensureCursorSdkUserHook({ userHomeDir: home })).toThrow(/not valid JSON/);
+ expect(fs.readFileSync(hooksPath, "utf8")).toBe("{ nope");
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects hook command paths with control characters", () => {
+ const home = tempHome();
+ try {
+ expect(() => ensureCursorSdkUserHook({
+ userHomeDir: home,
+ nodePath: `/node\npath/bin/node`,
+ })).toThrow(/control characters/);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it("uses a cmd wrapper for packaged Electron hooks on Windows", () => {
+ const originalPlatform = process.platform;
+ const originalElectron = process.versions.electron;
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
+ Object.defineProperty(process.versions, "electron", { value: "37.0.0", configurable: true });
+ try {
+ const command = __buildCursorSdkHookCommandForTests(
+ undefined,
+ String.raw`C:\Users\Ada Lovelace\.cursor\hooks\ade-tool-gate.cjs`,
+ String.raw`C:\Users\Ada Lovelace\.cursor\hooks\ade-tool-gate.cmd`,
+ );
+ expect(command).toBe(`cmd /d /c "C:\\Users\\Ada Lovelace\\.cursor\\hooks\\ade-tool-gate.cmd"`);
+ } finally {
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
+ if (originalElectron === undefined) {
+ Reflect.deleteProperty(process.versions, "electron");
+ } else {
+ Object.defineProperty(process.versions, "electron", { value: originalElectron, configurable: true });
+ }
+ }
+ });
+
+ it("writes the Windows Electron command wrapper with escaped batch percent signs", () => {
+ const home = tempHome();
+ try {
+ const commandPath = cursorSdkHookWindowsCommandPath(home);
+ writeCursorSdkHookWindowsCommandScript({
+ commandPath,
+ electronPath: String.raw`C:\Users\Ada%20\AppData\Local\ADE.exe`,
+ scriptPath: String.raw`C:\Users\Ada%20\.cursor\hooks\ade-tool-gate.cjs`,
+ });
+ expect(fs.readFileSync(commandPath, "utf8")).toContain(
+ String.raw`"C:\Users\Ada%%20\AppData\Local\ADE.exe" "C:\Users\Ada%%20\.cursor\hooks\ade-tool-gate.cjs"`,
+ );
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts
new file mode 100644
index 000000000..2f6880f97
--- /dev/null
+++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts
@@ -0,0 +1,300 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const ADE_HOOK_SCRIPT_NAME = "ade-tool-gate.cjs";
+const ADE_HOOK_WINDOWS_COMMAND_NAME = "ade-tool-gate.cmd";
+
+type CursorHooksConfig = {
+ version?: unknown;
+ hooks?: unknown;
+ [key: string]: unknown;
+};
+
+function ensureDir(dir: string): void {
+ fs.mkdirSync(dir, { recursive: true });
+}
+
+function writeJson(filePath: string, value: unknown): void {
+ ensureDir(path.dirname(filePath));
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
+}
+
+function shellQuote(value: string): string {
+ if (/[\0\r\n]/.test(value)) {
+ throw new Error("Cursor hook command paths cannot contain control characters.");
+ }
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
+}
+
+function windowsCmdQuote(value: string): string {
+ if (/[\0\r\n"%]/.test(value)) {
+ throw new Error("Cursor hook command paths cannot contain control characters or Windows cmd expansion characters.");
+ }
+ return `"${value}"`;
+}
+
+function windowsBatchQuote(value: string): string {
+ if (/[\0\r\n"]/.test(value)) {
+ throw new Error("Cursor hook command paths cannot contain control characters or double quotes.");
+ }
+ return `"${value.replace(/%/g, "%%")}"`;
+}
+
+function commandPathQuote(value: string): string {
+ return process.platform === "win32" ? windowsCmdQuote(value) : shellQuote(value);
+}
+
+function buildHookCommand(
+ nodePath: string | undefined,
+ scriptPath: string,
+ windowsCommandPath?: string,
+): string {
+ const explicitNode = nodePath?.trim();
+ if (explicitNode) return `${commandPathQuote(explicitNode)} ${commandPathQuote(scriptPath)}`;
+ if (process.versions.electron) {
+ if (process.platform === "win32") {
+ if (!windowsCommandPath) {
+ throw new Error("Cursor hook command script is required for packaged Electron on Windows.");
+ }
+ return `cmd /d /c ${windowsCmdQuote(windowsCommandPath)}`;
+ }
+ return `ELECTRON_RUN_AS_NODE=1 ${shellQuote(process.execPath)} ${shellQuote(scriptPath)}`;
+ }
+ return `${commandPathQuote(process.execPath)} ${commandPathQuote(scriptPath)}`;
+}
+
+function readObject(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function readHooksFile(filePath: string): CursorHooksConfig {
+ if (!fs.existsSync(filePath)) return { version: 1, hooks: {} };
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ } catch (error) {
+ throw new Error(
+ `Cannot install ADE Cursor hook because ${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ const config = readObject(parsed);
+ if (!config) {
+ throw new Error(`Cannot install ADE Cursor hook because ${filePath} must contain a JSON object.`);
+ }
+ if (config.hooks !== undefined && !readObject(config.hooks)) {
+ throw new Error(`Cannot install ADE Cursor hook because ${filePath}.hooks must be a JSON object.`);
+ }
+ const hooks = readObject(config.hooks) ?? {};
+ const preToolUse = hooks.preToolUse;
+ if (preToolUse !== undefined && !Array.isArray(preToolUse)) {
+ throw new Error(`Cannot install ADE Cursor hook because ${filePath}.hooks.preToolUse must be an array.`);
+ }
+ return config;
+}
+
+function isAdeHookEntry(value: unknown): boolean {
+ const entry = readObject(value);
+ const command = typeof entry?.command === "string" ? entry.command : "";
+ return command.includes(ADE_HOOK_SCRIPT_NAME);
+}
+
+export function cursorSdkHookScriptPath(userHomeDir: string): string {
+ return path.join(userHomeDir, ".cursor", "hooks", ADE_HOOK_SCRIPT_NAME);
+}
+
+export function cursorSdkHookWindowsCommandPath(userHomeDir: string): string {
+ return path.join(userHomeDir, ".cursor", "hooks", ADE_HOOK_WINDOWS_COMMAND_NAME);
+}
+
+export function cursorSdkHooksJsonPath(userHomeDir: string): string {
+ return path.join(userHomeDir, ".cursor", "hooks.json");
+}
+
+export function writeCursorSdkHookBridgeScript(scriptPath: string): void {
+ ensureDir(path.dirname(scriptPath));
+ const source = `#!/usr/bin/env node
+const net = require("node:net");
+
+function readStdin() {
+ return new Promise((resolve, reject) => {
+ let text = "";
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (chunk) => { text += chunk; });
+ process.stdin.on("end", () => resolve(text));
+ process.stdin.on("error", reject);
+ });
+}
+
+function parseArg(name) {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : null;
+}
+
+function writeDecision(decision) {
+ process.stdout.write(JSON.stringify(decision));
+}
+
+function allow() {
+ writeDecision({ permission: "allow" });
+}
+
+function deny(reason) {
+ writeDecision({
+ permission: "deny",
+ user_message: reason,
+ agent_message: reason,
+ });
+}
+
+function connectWithTimeout(socketPath) {
+ return new Promise((resolve, reject) => {
+ const client = net.createConnection(socketPath);
+ const timeout = setTimeout(() => {
+ client.destroy();
+ reject(new Error("Timed out connecting to ADE Cursor policy gate."));
+ }, 2000);
+ client.once("connect", () => {
+ clearTimeout(timeout);
+ resolve(client);
+ });
+ client.once("error", (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ });
+}
+
+async function main() {
+ const socketPath = parseArg("--socket") || process.env.ADE_CURSOR_SDK_SOCKET || "";
+ const adeSession = process.env.ADE_CURSOR_SDK_SESSION_ID || process.env.ADE_CURSOR_SDK_LANE_ROOT;
+ if (!socketPath) {
+ if (adeSession) deny("ADE Cursor policy gate is unavailable.");
+ else allow();
+ return;
+ }
+
+ const rawText = await readStdin();
+ let payload = {};
+ try {
+ payload = rawText.trim() ? JSON.parse(rawText) : {};
+ } catch (error) {
+ payload = { parseError: error && error.message ? error.message : String(error), rawText };
+ }
+
+ const client = await connectWithTimeout(socketPath);
+ client.write(JSON.stringify({
+ payload,
+ sessionId: process.env.ADE_CURSOR_SDK_SESSION_ID || null,
+ laneRoot: process.env.ADE_CURSOR_SDK_LANE_ROOT || null,
+ }) + "\\n");
+ const responseTimeoutMs = Number(process.env.ADE_CURSOR_SDK_RESPONSE_TIMEOUT_MS) || 5000;
+ const decision = await new Promise((resolve, reject) => {
+ let responseText = "";
+ let settled = false;
+ const timeout = setTimeout(() => {
+ settled = true;
+ client.destroy();
+ reject(new Error("Timed out waiting for ADE Cursor policy decision."));
+ }, responseTimeoutMs);
+ function settle(fn, value) {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ fn(value);
+ }
+ client.setEncoding("utf8");
+ client.on("data", (chunk) => {
+ if (settled) return;
+ responseText += chunk;
+ const newline = responseText.indexOf("\\n");
+ if (newline >= 0) {
+ const line = responseText.slice(0, newline);
+ try {
+ settle(resolve, JSON.parse(line));
+ } catch (error) {
+ settle(reject, new Error("ADE could not parse the Cursor hook decision."));
+ } finally {
+ client.end();
+ }
+ }
+ });
+ client.once("error", (error) => settle(reject, error));
+ client.once("close", () => {
+ if (!responseText.includes("\\n")) settle(reject, new Error("ADE Cursor policy gate closed without a decision."));
+ });
+ });
+ writeDecision(decision);
+}
+
+main().catch((error) => {
+ const socketPath = parseArg("--socket") || process.env.ADE_CURSOR_SDK_SOCKET || "";
+ const adeSession = process.env.ADE_CURSOR_SDK_SESSION_ID || process.env.ADE_CURSOR_SDK_LANE_ROOT;
+ if (!socketPath && !adeSession) {
+ allow();
+ return;
+ }
+ deny(error && error.message ? error.message : String(error));
+});
+`;
+ fs.writeFileSync(scriptPath, source, { mode: 0o755 });
+}
+
+export function writeCursorSdkHookWindowsCommandScript(args: {
+ commandPath: string;
+ electronPath: string;
+ scriptPath: string;
+}): void {
+ ensureDir(path.dirname(args.commandPath));
+ const source = [
+ "@echo off",
+ "set ELECTRON_RUN_AS_NODE=1",
+ `${windowsBatchQuote(args.electronPath)} ${windowsBatchQuote(args.scriptPath)}`,
+ "exit /b %ERRORLEVEL%",
+ "",
+ ].join("\r\n");
+ fs.writeFileSync(args.commandPath, source, { mode: 0o755 });
+}
+
+export const __buildCursorSdkHookCommandForTests = buildHookCommand;
+
+export function ensureCursorSdkUserHook(args: {
+ userHomeDir: string;
+ nodePath?: string;
+}): { hooksPath: string; scriptPath: string; command: string; changed: boolean } {
+ const scriptPath = cursorSdkHookScriptPath(args.userHomeDir);
+ const windowsCommandPath = cursorSdkHookWindowsCommandPath(args.userHomeDir);
+ const hooksPath = cursorSdkHooksJsonPath(args.userHomeDir);
+ writeCursorSdkHookBridgeScript(scriptPath);
+ if (!args.nodePath?.trim() && process.versions.electron && process.platform === "win32") {
+ writeCursorSdkHookWindowsCommandScript({
+ commandPath: windowsCommandPath,
+ electronPath: process.execPath,
+ scriptPath,
+ });
+ }
+
+ const config = readHooksFile(hooksPath);
+ const hooks = readObject(config.hooks) ?? {};
+ const existingPreToolUse = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
+ const command = buildHookCommand(args.nodePath, scriptPath, windowsCommandPath);
+ const adeEntry = { command, failClosed: true };
+ const nextPreToolUse = [
+ adeEntry,
+ ...existingPreToolUse.filter((entry) => !isAdeHookEntry(entry)),
+ ];
+ const nextConfig: CursorHooksConfig = {
+ ...config,
+ version: typeof config.version === "number" ? config.version : 1,
+ hooks: {
+ ...hooks,
+ preToolUse: nextPreToolUse,
+ },
+ };
+ const changed = JSON.stringify(config) !== JSON.stringify(nextConfig);
+ if (changed || !fs.existsSync(hooksPath)) {
+ writeJson(hooksPath, nextConfig);
+ }
+ return { hooksPath, scriptPath, command, changed };
+}
diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts
index 7d6915be9..5268a4d77 100644
--- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts
+++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts
@@ -1,6 +1,10 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
import { describe, expect, it } from "vitest";
import {
allowCursorHook,
+ cursorProjectSlugForPath,
denyCursorHook,
evaluateCursorSdkHook,
resolveCursorSdkPolicy,
@@ -108,6 +112,148 @@ describe("Cursor SDK policy", () => {
expect(evaluateCursorSdkHook({ request: traversal, policy, laneRoot })).toBe("deny");
});
+ it("denies shell path escapes even in full-auto mode", () => {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const laneRoot = "/tmp/ade-lane";
+
+ for (const command of [
+ "cat /etc/passwd",
+ "git -C /tmp status",
+ "npm --prefix ../other test",
+ "npm --prefix=/tmp/outside test",
+ "npm --prefix=C:\\Users\\admin\\outside test",
+ "git -C C:\\Users\\admin\\outside status",
+ "AWS_SHARED_CREDENTIALS_FILE=/Users/admin/.aws/credentials aws sts get-caller-identity",
+ "cd /outside && ls",
+ "echo ok > /tmp/ade-outside.txt",
+ "cat ~/.aws/credentials",
+ "cat $HOME/.aws/credentials",
+ ]) {
+ const request = summarizeCursorHook({
+ toolName: "shell",
+ toolInput: { command },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({
+ request,
+ policy,
+ laneRoot,
+ userHomeDir: "/Users/admin",
+ })).toBe("deny");
+ }
+ });
+
+ it("denies shell path escapes when hook payload input is a raw string", () => {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const laneRoot = "/tmp/ade-lane";
+ const request = summarizeCursorHook({
+ toolName: "shell",
+ toolInput: "cd /etc && cat /etc/passwd",
+ }, laneRoot);
+
+ expect(evaluateCursorSdkHook({
+ request,
+ policy,
+ laneRoot,
+ })).toBe("deny");
+ expect(request.reason).toContain("/etc");
+ });
+
+ it("denies shell cwd escapes even when the command text is otherwise safe", () => {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const laneRoot = "/tmp/ade-lane";
+ const request = summarizeCursorHook({
+ toolName: "shell",
+ toolInput: { command: "npm test", cwd: "/tmp/outside-lane" },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({ request, policy, laneRoot })).toBe("deny");
+ });
+
+ it("allows Cursor SDK transcript and terminal reads for the active lane only", () => {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const laneRoot = "/Users/admin/Projects/Versic/.ade/worktrees/private-sharing-5d14c47a";
+ const userHomeDir = "/Users/admin";
+ const slug = cursorProjectSlugForPath(laneRoot);
+ expect(slug).toBe("Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a");
+
+ const transcript = summarizeCursorHook({
+ toolName: "read",
+ toolInput: {
+ path: path.join(userHomeDir, ".cursor", "projects", slug, "agent-transcripts", "run.jsonl"),
+ },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({ request: transcript, policy, laneRoot, userHomeDir })).toBe("allow");
+
+ const terminalGlob = summarizeCursorHook({
+ toolName: "glob",
+ toolInput: {
+ pattern: "*.json",
+ targetDirectory: path.join(userHomeDir, ".cursor", "projects", slug, "terminals"),
+ },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({ request: terminalGlob, policy, laneRoot, userHomeDir })).toBe("allow");
+
+ const writeTranscript = summarizeCursorHook({
+ toolName: "write",
+ toolInput: {
+ path: path.join(userHomeDir, ".cursor", "projects", slug, "agent-transcripts", "run.jsonl"),
+ },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({ request: writeTranscript, policy, laneRoot, userHomeDir })).toBe("deny");
+ });
+
+ it("denies Cursor support reads when the active project support root is symlinked outside Cursor projects", () => {
+ if (process.platform === "win32") return;
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-support-"));
+ const home = path.join(root, "home");
+ const laneRoot = path.join(root, "repo", ".ade", "worktrees", "lane");
+ const outside = path.join(root, "outside");
+ const slug = cursorProjectSlugForPath(laneRoot);
+ fs.mkdirSync(path.join(home, ".cursor", "projects"), { recursive: true });
+ fs.mkdirSync(laneRoot, { recursive: true });
+ fs.mkdirSync(outside, { recursive: true });
+ fs.symlinkSync(outside, path.join(home, ".cursor", "projects", slug), "dir");
+
+ try {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const request = summarizeCursorHook({
+ toolName: "read",
+ toolInput: {
+ path: path.join(home, ".cursor", "projects", slug, "agent-transcripts", "run.jsonl"),
+ },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({
+ request,
+ policy,
+ laneRoot,
+ userHomeDir: home,
+ })).toBe("deny");
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it("denies symlink escapes through paths that appear to be inside the lane", () => {
+ if (process.platform === "win32") return;
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-policy-"));
+ const laneRoot = path.join(root, "lane");
+ const outside = path.join(root, "outside");
+ fs.mkdirSync(laneRoot, { recursive: true });
+ fs.mkdirSync(outside, { recursive: true });
+ fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
+ fs.symlinkSync(outside, path.join(laneRoot, "linked-outside"), "dir");
+
+ try {
+ const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
+ const request = summarizeCursorHook({
+ toolName: "read",
+ toolInput: { path: "linked-outside/secret.txt" },
+ }, laneRoot);
+ expect(evaluateCursorSdkHook({ request, policy, laneRoot })).toBe("deny");
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it("allows shells in full-auto without prompting", () => {
const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" });
const laneRoot = "/tmp/ade-lane";
diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts
index b1ad1e203..c193bbca6 100644
--- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts
+++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import type { AgentChatSession } from "../../../shared/types";
import type {
@@ -178,38 +179,222 @@ export function buildHookSummary(toolName: string, toolInput: unknown): string {
return toolName;
}
-function collectPotentialPaths(value: unknown, out: string[] = []): string[] {
- if (typeof value === "string") return out;
+function normalizedObjectKey(key: string): string {
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
+
+function isPathLikeKey(key: string): boolean {
+ const normalized = normalizedObjectKey(key);
+ return normalized === "path"
+ || normalized === "paths"
+ || normalized === "filepath"
+ || normalized === "filepaths"
+ || normalized === "file"
+ || normalized === "files"
+ || normalized === "filename"
+ || normalized === "filenames"
+ || normalized === "target"
+ || normalized === "targetpath"
+ || normalized === "targetpaths"
+ || normalized === "targetdirectory"
+ || normalized === "targetdirectories"
+ || normalized === "directory"
+ || normalized === "directories"
+ || normalized === "dir"
+ || normalized === "dirs"
+ || normalized === "cwd"
+ || normalized === "workingdirectory"
+ || normalized === "root"
+ || normalized === "roots"
+ || normalized.endsWith("path")
+ || normalized.endsWith("paths");
+}
+
+function isShellCommandKey(key: string): boolean {
+ const normalized = normalizedObjectKey(key);
+ return normalized === "command" || normalized === "cmd" || normalized === "shellcommand";
+}
+
+function shellWords(command: string): string[] {
+ const words: string[] = [];
+ const pattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|`([^`]*)`|([^\s;&|()<>]+)/g;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(command)) !== null) {
+ const word = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
+ const trimmed = word.trim();
+ if (trimmed.length > 0) words.push(trimmed);
+ }
+ return words;
+}
+
+function trimShellToken(token: string): string {
+ return token.trim().replace(/^[=:,]+|[,:]+$/g, "");
+}
+
+function looksLikePathToken(token: string): boolean {
+ const cleaned = trimShellToken(token);
+ if (!cleaned || cleaned.startsWith("-") || cleaned.includes("://")) return false;
+ if (/^[A-Za-z]:[\\/]/.test(cleaned)) return true;
+ if (
+ cleaned === ".."
+ || cleaned.startsWith("../")
+ || cleaned.startsWith("./")
+ || cleaned.startsWith("/")
+ || cleaned.startsWith("~/")
+ || cleaned === "~"
+ || cleaned.startsWith("$HOME/")
+ || cleaned.startsWith("${HOME}/")
+ ) return true;
+ if (!cleaned.includes("/")) return false;
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(cleaned)) return false;
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) return false;
+ return true;
+}
+
+function isWindowsAbsolutePath(candidate: string): boolean {
+ return /^[A-Za-z]:[\\/]/.test(candidate.trim());
+}
+
+function collectShellCommandPaths(command: string, out: string[]): void {
+ for (const word of shellWords(command)) {
+ const token = trimShellToken(word);
+ const flagValue = /^--?[A-Za-z0-9][A-Za-z0-9_-]*=(.+)$/.exec(token)?.[1];
+ if (flagValue && looksLikePathToken(flagValue)) {
+ out.push(flagValue);
+ continue;
+ }
+ const assignmentValue = /^[A-Za-z_][A-Za-z0-9_]*=(.+)$/.exec(token)?.[1];
+ if (assignmentValue && looksLikePathToken(assignmentValue)) {
+ out.push(assignmentValue);
+ continue;
+ }
+ if (looksLikePathToken(token)) out.push(token);
+ }
+}
+
+function collectPotentialPaths(value: unknown, out: string[] = [], pathContext = false): string[] {
+ if (typeof value === "string") {
+ const text = value.trim();
+ if (pathContext && text.length > 0) out.push(text);
+ return out;
+ }
if (!value || typeof value !== "object") return out;
if (Array.isArray(value)) {
- for (const item of value) collectPotentialPaths(item, out);
+ for (const item of value) collectPotentialPaths(item, out, pathContext);
return out;
}
for (const [key, raw] of Object.entries(value as Record)) {
- const normalized = key.toLowerCase();
- if (
- typeof raw === "string"
- && raw.trim().length > 0
- && (normalized === "path"
- || normalized.endsWith("path")
- || normalized === "file"
- || normalized === "filename")
- ) {
+ const keyIsPathLike = isPathLikeKey(key);
+ if (typeof raw === "string" && raw.trim().length > 0 && keyIsPathLike) {
out.push(raw.trim());
+ } else if (typeof raw === "string" && isShellCommandKey(key)) {
+ collectShellCommandPaths(raw, out);
} else {
- collectPotentialPaths(raw, out);
+ collectPotentialPaths(raw, out, pathContext || keyIsPathLike);
}
}
return out;
}
+function nearestExistingPath(filePath: string): string | null {
+ let current = path.resolve(filePath);
+ for (let depth = 0; depth < 128; depth += 1) {
+ if (fs.existsSync(current)) return current;
+ const parent = path.dirname(current);
+ if (parent === current) return null;
+ current = parent;
+ }
+ return null;
+}
+
+function realPathWithNearestExistingAncestor(filePath: string): string {
+ const resolved = path.resolve(filePath);
+ const existing = nearestExistingPath(resolved);
+ if (!existing) return resolved;
+ let realExisting: string;
+ try {
+ realExisting = fs.realpathSync.native(existing);
+ } catch {
+ realExisting = path.resolve(existing);
+ }
+ const remainder = path.relative(existing, resolved);
+ return remainder ? path.resolve(realExisting, remainder) : realExisting;
+}
+
+function isWithinPath(root: string, candidate: string): boolean {
+ const relative = path.relative(root, candidate);
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+function resolveCandidatePath(candidate: string, cwd: string, userHomeDir?: string | null): string {
+ if (candidate === "~" && userHomeDir?.trim()) return path.resolve(userHomeDir);
+ if (candidate.startsWith("~/") && userHomeDir?.trim()) {
+ return path.resolve(userHomeDir, candidate.slice(2));
+ }
+ if (candidate.startsWith("$HOME/") && userHomeDir?.trim()) {
+ return path.resolve(userHomeDir, candidate.slice("$HOME/".length));
+ }
+ if (candidate.startsWith("${HOME}/") && userHomeDir?.trim()) {
+ return path.resolve(userHomeDir, candidate.slice("${HOME}/".length));
+ }
+ return path.resolve(path.isAbsolute(candidate) ? candidate : cwd, path.isAbsolute(candidate) ? "" : candidate);
+}
+
+export function cursorProjectSlugForPath(projectPath: string): string {
+ return path.resolve(projectPath)
+ .split(/[\\/]+/)
+ .filter(Boolean)
+ .map((component) => component.replace(/^\.+/, "").replace(/[^A-Za-z0-9_-]+/g, ""))
+ .filter(Boolean)
+ .join("-");
+}
+
+function cursorSupportReadRoots(laneRoot: string, userHomeDir?: string | null): string[] {
+ const home = userHomeDir?.trim();
+ if (!home) return [];
+ const projectRoot = path.join(home, ".cursor", "projects", cursorProjectSlugForPath(laneRoot));
+ return [
+ path.join(projectRoot, "terminals"),
+ path.join(projectRoot, "agent-transcripts"),
+ ];
+}
+
+function cursorSupportProjectRoot(laneRoot: string, userHomeDir: string): string {
+ return path.join(userHomeDir, ".cursor", "projects", cursorProjectSlugForPath(laneRoot));
+}
+
+function isAllowedCursorSupportRead(args: {
+ candidatePath: string;
+ laneRoot: string;
+ userHomeDir?: string | null;
+ risk: CursorSdkHookRequest["risk"];
+}): boolean {
+ if (args.risk !== "read") return false;
+ const home = args.userHomeDir?.trim();
+ if (!home) return false;
+ const projectsRootReal = realPathWithNearestExistingAncestor(path.join(home, ".cursor", "projects"));
+ const projectRootReal = realPathWithNearestExistingAncestor(cursorSupportProjectRoot(args.laneRoot, home));
+ if (!isWithinPath(projectsRootReal, projectRootReal)) return false;
+ const candidateReal = realPathWithNearestExistingAncestor(args.candidatePath);
+ for (const supportRoot of cursorSupportReadRoots(args.laneRoot, home)) {
+ const rootReal = realPathWithNearestExistingAncestor(supportRoot);
+ if (isWithinPath(rootReal, candidateReal)) return true;
+ }
+ return false;
+}
+
function pathGuardReason(args: {
laneRoot: string;
cwd: string;
value: unknown;
+ risk: CursorSdkHookRequest["risk"];
+ userHomeDir?: string | null;
}): string | null {
const laneRoot = path.resolve(args.laneRoot);
+ const laneRootReal = realPathWithNearestExistingAncestor(laneRoot);
const cwd = path.resolve(args.cwd || laneRoot);
+ const secretsRoot = path.join(laneRoot, ".ade", "secrets");
+ const secretsRootReal = realPathWithNearestExistingAncestor(secretsRoot);
const candidates = collectPotentialPaths(args.value);
// If the tool input is itself a non-empty string path (e.g. a Bash command's
// `cd /etc`-style argument), include it as a candidate so the lane/protected
@@ -219,16 +404,40 @@ function pathGuardReason(args: {
&& typeof args.value === "string"
&& args.value.trim().length > 0
) {
- candidates.push(args.value.trim());
+ collectShellCommandPaths(args.value, candidates);
}
for (const candidate of candidates) {
- const resolved = path.resolve(path.isAbsolute(candidate) ? candidate : cwd, path.isAbsolute(candidate) ? "" : candidate);
+ if (process.platform !== "win32" && isWindowsAbsolutePath(candidate)) {
+ return `Path is outside the active lane: ${candidate}`;
+ }
+ const resolved = resolveCandidatePath(candidate, cwd, args.userHomeDir);
+ const realResolved = realPathWithNearestExistingAncestor(resolved);
const relative = path.relative(laneRoot, resolved);
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ const realRelative = path.relative(laneRootReal, realResolved);
+ if (
+ relative.startsWith("..")
+ || path.isAbsolute(relative)
+ || realRelative.startsWith("..")
+ || path.isAbsolute(realRelative)
+ ) {
+ if (isAllowedCursorSupportRead({
+ candidatePath: resolved,
+ laneRoot,
+ userHomeDir: args.userHomeDir,
+ risk: args.risk,
+ })) {
+ continue;
+ }
return `Path is outside the active lane: ${candidate}`;
}
const normalizedRel = relative.split(path.sep).join("/");
- if (normalizedRel === ".ade/secrets" || normalizedRel.startsWith(".ade/secrets/")) {
+ const realSecretsRelative = path.relative(secretsRootReal, realResolved);
+ const isSecretReal = realSecretsRelative === "" || (!realSecretsRelative.startsWith("..") && !path.isAbsolute(realSecretsRelative));
+ if (
+ normalizedRel === ".ade/secrets"
+ || normalizedRel.startsWith(".ade/secrets/")
+ || isSecretReal
+ ) {
return `Path is protected by ADE: ${candidate}`;
}
}
@@ -240,12 +449,15 @@ export function evaluateCursorSdkHook(args: {
policy: CursorSdkPermissionPolicy;
laneRoot: string;
sessionAllowedTools?: Set;
+ userHomeDir?: string | null;
}): "allow" | "deny" | "ask" {
const guardReason = args.policy.hardGuards
? pathGuardReason({
laneRoot: args.laneRoot,
cwd: args.request.cwd,
value: args.request.toolInput ?? args.request.raw,
+ risk: args.request.risk,
+ userHomeDir: args.userHomeDir,
})
: null;
if (guardReason) {
diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts
new file mode 100644
index 000000000..2113a722a
--- /dev/null
+++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts
@@ -0,0 +1,64 @@
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ buildCursorSdkPaths,
+ buildCursorSdkWorkerEnv,
+ resolveCursorSdkUserHome,
+} from "./cursorSdkPool";
+
+describe("Cursor SDK pool paths", () => {
+ it("uses the real user home while keeping ADE runtime state under the project cache", () => {
+ const projectRoot = path.join(os.tmpdir(), "ade-project");
+ const userHomeDir = path.join(os.tmpdir(), "real-home");
+ const paths = buildCursorSdkPaths({
+ projectRoot,
+ poolKey: "lane:/repo:session",
+ userHomeDir,
+ });
+
+ expect(paths.userHomeDir).toBe(userHomeDir);
+ expect(paths.cacheRoot).toContain(path.join(projectRoot, ".ade", "cache", "cursor-sdk"));
+ expect(paths.stateRoot).toBe(path.join(paths.cacheRoot, "state"));
+ if (process.platform === "win32") {
+ expect(paths.socketPath).toContain("\\\\.\\pipe\\ade-cursor-sdk-");
+ } else {
+ expect(paths.socketPath).toContain(`ade-cursor-sdk-${process.getuid?.() ?? ""}`);
+ expect(path.basename(paths.socketPath)).toBe("hook.sock");
+ }
+ });
+
+ it("builds a worker environment with real HOME parity and ADE socket metadata", () => {
+ const env = buildCursorSdkWorkerEnv({
+ baseEnv: {
+ HOME: "/synthetic",
+ USERPROFILE: "/synthetic-profile",
+ PATH: "/bin",
+ CURSOR_API_KEY: "cursor-secret",
+ CURSOR_AUTH_TOKEN: "cursor-token",
+ },
+ userHomeDir: "/Users/admin",
+ stateRoot: "/repo/.ade/cache/cursor-sdk/hash/state",
+ socketPath: "/tmp/ade-cursor-sdk/socket.sock",
+ workspacePath: "/repo/.ade/worktrees/lane",
+ sessionId: "session-1",
+ });
+
+ expect(env.HOME).toBe("/Users/admin");
+ expect(env.USERPROFILE).toBe("/Users/admin");
+ expect(env.CURSOR_API_KEY).toBeUndefined();
+ expect(env.CURSOR_AUTH_TOKEN).toBeUndefined();
+ expect(env.ADE_CURSOR_SDK_SOCKET).toBe("/tmp/ade-cursor-sdk/socket.sock");
+ expect(env.ADE_CURSOR_SDK_LANE_ROOT).toBe("/repo/.ade/worktrees/lane");
+ expect(env.ADE_CURSOR_SDK_SESSION_ID).toBe("session-1");
+ expect(env.ADE_CURSOR_SDK_STATE_ROOT).toBe("/repo/.ade/cache/cursor-sdk/hash/state");
+ });
+
+ it("prefers HOME on POSIX and USERPROFILE on Windows when resolving the Cursor user home", () => {
+ const resolved = resolveCursorSdkUserHome({
+ HOME: "/posix-home",
+ USERPROFILE: "C:\\Users\\admin",
+ });
+ expect(resolved).toBe(process.platform === "win32" ? "C:\\Users\\admin" : "/posix-home");
+ });
+});
diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts
index 0575eff03..d52c5ccee 100644
--- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts
+++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts
@@ -19,6 +19,7 @@ import type {
type PendingRpc = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
+ type: CursorSdkWorkerRequest["type"];
};
export type CursorSdkRuntimeMeta = {
@@ -65,7 +66,15 @@ export type CursorSdkPooled = {
};
let cursorSdkGenCounter = 0;
-const pools = new Map();
+const pools = new Map();
const pendingInits = new Map>();
function hashKey(value: string): string {
@@ -84,11 +93,12 @@ function resolveWorkerPath(): string {
}
function socketPathFor(poolKey: string): string {
- const name = `ade-cursor-sdk-${hashKey(poolKey)}.sock`;
+ const name = hashKey(poolKey);
if (process.platform === "win32") {
- return `\\\\.\\pipe\\${name}`;
+ return `\\\\.\\pipe\\ade-cursor-sdk-${name}`;
}
- return path.join(os.tmpdir(), name);
+ const userPart = typeof process.getuid === "function" ? String(process.getuid()) : hashKey(os.homedir());
+ return path.join(os.tmpdir(), `ade-cursor-sdk-${userPart}`, name, "hook.sock");
}
function sanitizeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -98,19 +108,80 @@ function sanitizeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env;
}
+function ensurePrivateDirectory(dir: string): void {
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ let fd: number | null = null;
+ try {
+ // Older Node/platform pairs can omit these open flags; fstat below still
+ // verifies the directory shape when the constants are unavailable.
+ fd = fs.openSync(
+ dir,
+ fs.constants.O_RDONLY
+ | (fs.constants.O_DIRECTORY ?? 0)
+ | (fs.constants.O_NOFOLLOW ?? 0),
+ );
+ const stat = fs.fstatSync(fd);
+ if (!stat.isDirectory()) {
+ throw new Error(`Cursor SDK socket directory is not a private directory: ${dir}`);
+ }
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
+ throw new Error(`Cursor SDK socket directory is not owned by the current user: ${dir}`);
+ }
+ fs.fchmodSync(fd, 0o700);
+ } finally {
+ if (fd !== null) fs.closeSync(fd);
+ }
+}
+
+function ensurePrivateSocketPath(socketPath: string): void {
+ if (process.platform === "win32") return;
+ const rootDir = path.dirname(path.dirname(socketPath));
+ const socketDir = path.dirname(socketPath);
+ ensurePrivateDirectory(rootDir);
+ ensurePrivateDirectory(socketDir);
+}
+
+export function resolveCursorSdkUserHome(env: NodeJS.ProcessEnv = process.env): string {
+ const preferred = process.platform === "win32"
+ ? env.USERPROFILE?.trim() || env.HOME?.trim()
+ : env.HOME?.trim() || env.USERPROFILE?.trim();
+ return preferred || os.homedir();
+}
+
export function buildCursorSdkPaths(args: {
projectRoot: string;
poolKey: string;
-}): { homeDir: string; stateRoot: string; socketPath: string } {
+ userHomeDir?: string;
+}): { userHomeDir: string; cacheRoot: string; stateRoot: string; socketPath: string } {
const keyHash = hashKey(args.poolKey);
const cacheRoot = path.join(args.projectRoot, ".ade", "cache", "cursor-sdk", keyHash);
return {
- homeDir: path.join(cacheRoot, "home"),
+ userHomeDir: args.userHomeDir?.trim() || resolveCursorSdkUserHome(),
+ cacheRoot,
stateRoot: path.join(cacheRoot, "state"),
socketPath: socketPathFor(args.poolKey),
};
}
+export function buildCursorSdkWorkerEnv(args: {
+ baseEnv?: NodeJS.ProcessEnv;
+ userHomeDir: string;
+ stateRoot: string;
+ socketPath: string;
+ workspacePath: string;
+ sessionId: string;
+}): NodeJS.ProcessEnv {
+ return {
+ ...sanitizeEnv(args.baseEnv ?? process.env),
+ HOME: args.userHomeDir,
+ USERPROFILE: args.userHomeDir,
+ ADE_CURSOR_SDK_SOCKET: args.socketPath,
+ ADE_CURSOR_SDK_LANE_ROOT: args.workspacePath,
+ ADE_CURSOR_SDK_SESSION_ID: args.sessionId,
+ ADE_CURSOR_SDK_STATE_ROOT: args.stateRoot,
+ };
+}
+
export async function acquireCursorSdkConnection(args: {
poolKey: string;
projectRoot: string;
@@ -121,6 +192,7 @@ export async function acquireCursorSdkConnection(args: {
agentName?: string | null;
sessionId: string;
policy: CursorSdkPermissionPolicy;
+ cleanupStateRoot?: boolean;
logger?: Logger;
}): Promise<{ pooled: CursorSdkPooled; generation: number }> {
const existing = pools.get(args.poolKey);
@@ -128,7 +200,10 @@ export async function acquireCursorSdkConnection(args: {
existing.ref += 1;
return { pooled: existing.pooled, generation: existing.generation };
}
- if (existing) pools.delete(args.poolKey);
+ if (existing) {
+ pools.delete(args.poolKey);
+ cleanupCursorSdkRuntimePaths(existing);
+ }
let initOwner = false;
let init = pendingInits.get(args.poolKey);
@@ -154,16 +229,18 @@ export async function acquireCursorSdkConnection(args: {
async function createCursorSdkConnection(args: Parameters[0]): Promise {
const workerPath = resolveWorkerPath();
const paths = buildCursorSdkPaths({ projectRoot: args.projectRoot, poolKey: args.poolKey });
- fs.mkdirSync(paths.homeDir, { recursive: true });
fs.mkdirSync(paths.stateRoot, { recursive: true });
+ ensurePrivateSocketPath(paths.socketPath);
const child = fork(workerPath, [], {
cwd: args.workspacePath,
- env: {
- ...sanitizeEnv(process.env),
- HOME: paths.homeDir,
- USERPROFILE: paths.homeDir,
- },
+ env: buildCursorSdkWorkerEnv({
+ userHomeDir: paths.userHomeDir,
+ stateRoot: paths.stateRoot,
+ socketPath: paths.socketPath,
+ workspacePath: args.workspacePath,
+ sessionId: args.sessionId,
+ }),
stdio: ["ignore", "pipe", "pipe", "ipc"],
execArgv: [],
});
@@ -197,6 +274,7 @@ async function createCursorSdkConnection(args: Parameters resolve(value as T),
reject,
+ type,
});
child.send?.({ type, requestId, payload } as CursorSdkWorkerRequest);
});
@@ -226,7 +304,7 @@ async function createCursorSdkConnection(args: Parameters();
+ targets.add(entry.cacheRoot ?? entry.stateRoot);
+ if (process.platform !== "win32" && entry.socketPath) {
+ targets.add(path.dirname(entry.socketPath));
+ }
+ for (const target of targets) {
+ try {
+ fs.rmSync(target, { recursive: true, force: true });
+ } catch {
+ // Best effort: stale one-shot SDK state should never break request cleanup.
+ }
+ }
+}
+
export function releaseCursorSdkConnection(poolKey: string, generation?: number): void {
const entry = pools.get(poolKey);
if (!entry) return;
@@ -370,6 +488,7 @@ export function releaseCursorSdkConnection(poolKey: string, generation?: number)
if (entry.ref <= 0) {
entry.pooled.dispose();
pools.delete(poolKey);
+ cleanupCursorSdkRuntimePaths(entry);
}
}
@@ -390,6 +509,7 @@ export async function runCursorSdkCatalogRequest(
modelSdkId: "default",
apiKey: args.apiKey,
sessionId: "catalog",
+ cleanupStateRoot: true,
policy: {
chatMode: "agent",
approvalPolicy: "never",
@@ -439,6 +559,7 @@ export async function runCursorSdkCloudRequest(
modelSdkId: "default",
apiKey: args.apiKey,
sessionId: "cloud-oneshot",
+ cleanupStateRoot: true,
policy: {
chatMode: "agent",
approvalPolicy: "never",
diff --git a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts
index b2da4d96b..7dba64aa2 100644
--- a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts
+++ b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts
@@ -35,7 +35,7 @@ export type CursorSdkHookRequest = {
export type CursorSdkWorkerInit = {
sessionId: string;
laneRoot: string;
- homeDir: string;
+ userHomeDir: string;
stateRoot: string;
socketPath: string;
modelSdkId: string;
@@ -123,6 +123,7 @@ export type CursorSdkCloudArtifactDownloadResult = {
export type CursorSdkCloudRunStartedResult = {
agentId: string;
runId: string;
+ agentName?: string | null;
modelSdkId?: string | null;
status?: string;
};
diff --git a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts
index ec49b17e1..c77a4faea 100644
--- a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts
+++ b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts
@@ -25,6 +25,7 @@ import {
evaluateCursorSdkHook,
summarizeCursorHook,
} from "./cursorSdkPolicy";
+import { ensureCursorSdkUserHook } from "./cursorSdkHooks";
type CursorSdkModule = typeof CursorSdkModuleTypes;
type SdkAgent = Awaited>;
@@ -45,7 +46,40 @@ function post(message: CursorSdkWorkerResponse): void {
}
function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
+ if (!(error instanceof Error)) return String(error);
+ const message = error.message.trim();
+ const detailEntries: string[] = [];
+ const record = error as Error & {
+ code?: unknown;
+ status?: unknown;
+ operation?: unknown;
+ endpoint?: unknown;
+ requestId?: unknown;
+ toJSON?: () => unknown;
+ };
+ let jsonRecord: Record | null = null;
+ if (typeof record.toJSON === "function") {
+ try {
+ const json = record.toJSON();
+ if (json && typeof json === "object" && !Array.isArray(json)) {
+ jsonRecord = json as Record;
+ }
+ } catch {
+ jsonRecord = null;
+ }
+ }
+ for (const key of ["code", "status", "operation", "endpoint", "requestId"] as const) {
+ const value = record[key] ?? jsonRecord?.[key];
+ if (value !== undefined && value !== null && String(value).trim().length) {
+ detailEntries.push(`${key}=${String(value)}`);
+ }
+ }
+ const primary = message && message !== "Error"
+ ? message
+ : error.name && error.name !== "Error"
+ ? error.name
+ : "Unknown Cursor SDK error";
+ return detailEntries.length ? `${primary} (${detailEntries.join(", ")})` : primary;
}
async function getSdk(): Promise {
@@ -55,136 +89,6 @@ async function getSdk(): Promise {
return sdkModule;
}
-function ensureDir(dir: string): void {
- fs.mkdirSync(dir, { recursive: true });
-}
-
-function writeJson(filePath: string, value: unknown): void {
- ensureDir(path.dirname(filePath));
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
-}
-
-function cursorCliConfig(policy: CursorSdkPermissionPolicy): Record {
- if (policy.approvalPolicy === "never") {
- return {
- version: 1,
- approvalMode: "unrestricted",
- permissions: {
- allow: ["Shell(**)", "Read(**)", "Write(**)", "Mcp(**)"],
- deny: [],
- },
- sandbox: {
- mode: "disabled",
- networkAccess: "allow_all",
- },
- };
- }
- return {
- version: 1,
- approvalMode: "allowlist",
- permissions: {
- allow: [],
- deny: [],
- },
- sandbox: {
- mode: "disabled",
- networkAccess: "user_config_with_defaults",
- },
- };
-}
-
-function writeHookBridgeScript(scriptPath: string): void {
- ensureDir(path.dirname(scriptPath));
- const source = `#!/usr/bin/env node
-const net = require("node:net");
-
-function readStdin() {
- return new Promise((resolve, reject) => {
- let text = "";
- process.stdin.setEncoding("utf8");
- process.stdin.on("data", (chunk) => { text += chunk; });
- process.stdin.on("end", () => resolve(text));
- process.stdin.on("error", reject);
- });
-}
-
-function parseArg(name) {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : null;
-}
-
-async function main() {
- const socketPath = parseArg("--socket");
- if (!socketPath) throw new Error("Missing --socket");
- const rawText = await readStdin();
- let payload = {};
- try {
- payload = rawText.trim() ? JSON.parse(rawText) : {};
- } catch (error) {
- payload = { parseError: error && error.message ? error.message : String(error), rawText };
- }
- const client = net.createConnection(socketPath);
- await new Promise((resolve, reject) => {
- client.once("connect", resolve);
- client.once("error", reject);
- });
- client.write(JSON.stringify({ payload }) + "\\n");
- let responseText = "";
- client.setEncoding("utf8");
- client.on("data", (chunk) => {
- responseText += chunk;
- const newline = responseText.indexOf("\\n");
- if (newline >= 0) {
- const line = responseText.slice(0, newline);
- try {
- const response = JSON.parse(line);
- process.stdout.write(JSON.stringify(response));
- client.end();
- } catch (error) {
- process.stdout.write(JSON.stringify({
- permission: "deny",
- user_message: "ADE could not parse the Cursor hook decision.",
- agent_message: "ADE could not parse the Cursor hook decision."
- }));
- client.end();
- }
- }
- });
- await new Promise((resolve) => client.once("close", resolve));
-}
-
-main().catch((error) => {
- process.stdout.write(JSON.stringify({
- permission: "deny",
- user_message: "ADE Cursor hook bridge failed.",
- agent_message: error && error.message ? error.message : String(error)
- }));
- process.exitCode = 1;
-});
-`;
- fs.writeFileSync(scriptPath, source, { mode: 0o755 });
-}
-
-function writeCursorHome(init: CursorSdkWorkerInit): void {
- const cursorDir = path.join(init.homeDir, ".cursor");
- const hooksDir = path.join(cursorDir, "hooks");
- ensureDir(hooksDir);
- const hookScript = path.join(hooksDir, "ade-tool-gate.cjs");
- writeHookBridgeScript(hookScript);
- writeJson(path.join(cursorDir, "cli-config.json"), cursorCliConfig(init.policy));
- writeJson(path.join(cursorDir, "hooks.json"), {
- version: 1,
- hooks: {
- preToolUse: [
- {
- command: `"${process.execPath}" "${hookScript}" --socket "${init.socketPath}"`,
- failClosed: true,
- },
- ],
- },
- });
-}
-
function removeSocketIfNeeded(socketPath: string): void {
if (process.platform === "win32") return;
try {
@@ -244,6 +148,7 @@ async function handleHookSocketLine(init: CursorSdkWorkerInit, socket: net.Socke
request,
policy: init.policy,
laneRoot: init.laneRoot,
+ userHomeDir: init.userHomeDir,
});
if (localDecision === "allow") {
socket.end(`${JSON.stringify(allowCursorHook())}\n`);
@@ -267,18 +172,25 @@ function requestHookDecision(request: CursorSdkHookRequest): Promise {
initState = init;
- process.env.HOME = init.homeDir;
- process.env.USERPROFILE = init.homeDir;
+ process.env.HOME = init.userHomeDir;
+ process.env.USERPROFILE = init.userHomeDir;
+ process.env.ADE_CURSOR_SDK_SOCKET = init.socketPath;
+ process.env.ADE_CURSOR_SDK_LANE_ROOT = init.laneRoot;
+ process.env.ADE_CURSOR_SDK_SESSION_ID = init.sessionId;
+ process.env.ADE_CURSOR_SDK_STATE_ROOT = init.stateRoot;
if (init.apiKey?.trim()) {
process.env.CURSOR_API_KEY = init.apiKey.trim();
} else {
delete process.env.CURSOR_API_KEY;
}
- ensureDir(init.homeDir);
- ensureDir(init.stateRoot);
- writeCursorHome(init);
+ fs.mkdirSync(init.userHomeDir, { recursive: true });
+ fs.mkdirSync(init.stateRoot, { recursive: true });
+ const hook = ensureCursorSdkUserHook({ userHomeDir: init.userHomeDir });
await startHookServer(init);
const { Agent } = await getSdk();
+ // Keep these fields aligned with Cursor's TypeScript SDK docs:
+ // local.cwd selects the workspace, platform.stateRoot isolates durable ADE
+ // state, sandboxOptions is terminal policy, and hooks gate tool execution.
const agentOptions: AgentOptions = {
apiKey: init.apiKey?.trim() || undefined,
model: { id: init.modelSdkId },
@@ -296,6 +208,18 @@ async function initWorker(init: CursorSdkWorkerInit): Promise<{ agentId: string;
agent = init.agentId?.trim()
? await Agent.resume(init.agentId.trim(), agentOptions)
: await Agent.create(agentOptions);
+ post({
+ type: "log",
+ level: "debug",
+ message: "Cursor SDK local runtime initialized.",
+ detail: {
+ laneRoot: init.laneRoot,
+ userHomeDir: init.userHomeDir,
+ stateRoot: init.stateRoot,
+ hookPath: hook.hooksPath,
+ hookChanged: hook.changed,
+ },
+ });
post({ type: "ready", agentId: agent.agentId, modelSdkId: init.modelSdkId, transport: "sdk" });
return { agentId: agent.agentId, modelSdkId: init.modelSdkId };
}
@@ -336,7 +260,7 @@ async function cancelRun(): Promise {
async function updatePolicy(policy: CursorSdkPermissionPolicy): Promise {
if (!initState) throw new Error("Cursor SDK worker is not initialized.");
initState.policy = policy;
- writeCursorHome(initState);
+ ensureCursorSdkUserHook({ userHomeDir: initState.userHomeDir });
}
async function dispose(): Promise {
@@ -563,23 +487,41 @@ async function handleCloudRequest(req: CursorSdkWorkerRequest): Promise
const cloudAgent = await Agent.create(buildCloudCreateOptions(req.payload));
const sendOpts = req.payload.modelSdkId?.trim() ? { model: { id: req.payload.modelSdkId.trim() } } : undefined;
const run = await cloudAgent.send(req.payload.promptText, sendOpts);
- return streamCloudRun({
+ const result = await streamCloudRun({
requestId: req.requestId,
agentId: cloudAgent.agentId,
run,
modelSdkId: req.payload.modelSdkId,
});
+ try {
+ const info = await Agent.get(cloudAgent.agentId, { apiKey: req.payload.apiKey?.trim() || undefined });
+ return {
+ ...(result && typeof result === "object" ? result as Record : {}),
+ agentName: typeof info.name === "string" ? info.name : null,
+ };
+ } catch {
+ return result;
+ }
}
if (req.type === "cloud.followup") {
const cloudAgent = await Agent.resume(req.payload.agentId, buildCloudResumeOptions(req.payload));
const sendOpts = req.payload.modelSdkId?.trim() ? { model: { id: req.payload.modelSdkId.trim() } } : undefined;
const run = await cloudAgent.send(req.payload.promptText, sendOpts);
- return streamCloudRun({
+ const result = await streamCloudRun({
requestId: req.requestId,
agentId: cloudAgent.agentId,
run,
modelSdkId: req.payload.modelSdkId,
});
+ try {
+ const info = await Agent.get(cloudAgent.agentId, { apiKey: req.payload.apiKey?.trim() || undefined });
+ return {
+ ...(result && typeof result === "object" ? result as Record : {}),
+ agentName: typeof info.name === "string" ? info.name : null,
+ };
+ } catch {
+ return result;
+ }
}
if (req.type === "cloud.run.cancel") {
const entry = cloudRuns.get(req.payload.runId);
@@ -735,4 +677,3 @@ post({
level: "debug",
message: `Cursor SDK worker booted in ${process.cwd()} (${os.platform()})`,
});
-
diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts
index ba4ecc6c7..aef4e00a4 100644
--- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts
+++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts
@@ -1,11 +1,15 @@
import fs from "node:fs";
+import net from "node:net";
import os from "node:os";
+import path from "node:path";
import { EventEmitter } from "node:events";
import type { ChildProcess, spawn as nodeSpawn } from "node:child_process";
import { describe, it, expect, vi, afterEach } from "vitest";
import {
+ __testResetIosurfaceCapabilityCache,
__testSetIosSimulatorProcessHooks,
createIosSimulatorService,
+ detectIosurfaceIndigoCapability,
IosSimulatorOwnedBySessionError,
iosurfaceInputScreenFromSnapshot,
normalizeIosSimulatorPointForIndigo,
@@ -14,6 +18,7 @@ import {
shouldOpenSimulatorAppForLaunch,
} from "./iosSimulatorService";
import { IOS_SIMULATOR_OWNED_BY_OTHER_SESSION_CODE } from "../../../shared/types/iosSimulator";
+import type { IosSimulatorEventPayload } from "../../../shared/types";
import type { Logger } from "../logging/logger";
const noopLogger: Logger = {
@@ -49,7 +54,86 @@ const simulatorDevicesJson = JSON.stringify({
});
afterEach(() => {
+ __testResetIosurfaceCapabilityCache();
vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe("iosSimulatorService IOSurface helper capability", () => {
+ it("allows packaged helper sources and builds helpers outside the signed app bundle", async () => {
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ios-helper-packaged-"));
+ const tempHome = path.join(tempRoot, "home");
+ const developerDir = path.join(tempRoot, "Xcode.app", "Contents", "Developer");
+ const simulatorKitDir = path.join(
+ developerDir,
+ "Platforms",
+ "iPhoneSimulator.platform",
+ "Developer",
+ "Library",
+ "PrivateFrameworks",
+ "SimulatorKit.framework",
+ );
+ const helperRoot = path.join(tempRoot, "ADE.app", "Contents", "Resources", "native", "ios-sim-helpers");
+ const previousHelperRoot = process.env.ADE_IOS_SIM_HELPER_ROOT;
+ const previousHelperBuildRoot = process.env.ADE_IOS_SIM_HELPER_BUILD_ROOT;
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const homeSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome);
+ let capturedBuildRoot: string | undefined;
+
+ fs.mkdirSync(simulatorKitDir, { recursive: true });
+ fs.mkdirSync(helperRoot, { recursive: true });
+ fs.writeFileSync(path.join(helperRoot, "build.sh"), "#!/usr/bin/env bash\n");
+ fs.writeFileSync(path.join(helperRoot, "sim-capture.swift"), "print(\"capture\")\n");
+ fs.writeFileSync(path.join(helperRoot, "sim-input.m"), "int main(void) { return 0; }\n");
+ process.env.ADE_IOS_SIM_HELPER_ROOT = helperRoot;
+ delete process.env.ADE_IOS_SIM_HELPER_BUILD_ROOT;
+
+ const runMock = vi.fn(async (command: string, commandArgs: string[], options?: { env?: NodeJS.ProcessEnv }) => {
+ if (command === "/usr/bin/xcode-select") return { stdout: `${developerDir}\n`, stderr: "" };
+ if (command === "xcodebuild") return { stdout: "Xcode 26.3\nBuild version 17C52\n", stderr: "" };
+ if (command === "bash" && commandArgs[0] === path.join(helperRoot, "build.sh")) {
+ capturedBuildRoot = options?.env?.ADE_IOS_SIM_HELPER_BUILD_ROOT;
+ const buildDir = path.join(capturedBuildRoot ?? helperRoot, "xcode-26.3-test");
+ return {
+ stdout: JSON.stringify({
+ xcodeVersion: "26.3",
+ sourceHash: "test-hash",
+ buildDir,
+ capture: path.join(buildDir, "sim-capture"),
+ input: path.join(buildDir, "sim-input"),
+ }),
+ stderr: "",
+ };
+ }
+ throw new Error(`unexpected command: ${command} ${commandArgs.join(" ")}`);
+ });
+ const restoreHooks = __testSetIosSimulatorProcessHooks({
+ run: runMock,
+ commandExists: () => true,
+ });
+
+ try {
+ const capability = await detectIosurfaceIndigoCapability();
+ expect(capability.available).toBe(true);
+ expect(capturedBuildRoot).toBe(path.join(tempHome, "Library", "Caches", "ADE", "ios-sim-helpers"));
+ expect(capturedBuildRoot).not.toContain(".app");
+ } finally {
+ restoreHooks();
+ platformSpy.mockRestore();
+ homeSpy.mockRestore();
+ if (previousHelperRoot == null) {
+ delete process.env.ADE_IOS_SIM_HELPER_ROOT;
+ } else {
+ process.env.ADE_IOS_SIM_HELPER_ROOT = previousHelperRoot;
+ }
+ if (previousHelperBuildRoot == null) {
+ delete process.env.ADE_IOS_SIM_HELPER_BUILD_ROOT;
+ } else {
+ process.env.ADE_IOS_SIM_HELPER_BUILD_ROOT = previousHelperBuildRoot;
+ }
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
});
describe("iosSimulatorService cross-platform safety", () => {
@@ -268,6 +352,89 @@ describe("iosSimulatorService Simulator.app launch visibility", () => {
}
});
+ it("falls back from a frame-less idb MJPEG stream to drawable simulator screenshots", async () => {
+ vi.useFakeTimers();
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const events: IosSimulatorEventPayload[] = [];
+ const companionServers: net.Server[] = [];
+ const runMock = vi.fn(async (command: string, commandArgs: string[]) => {
+ if (command === "ps") return { stdout: "", stderr: "" };
+ if (command === "xcrun" && commandArgs.join(" ") === "simctl list devices available --json") {
+ return { stdout: simulatorDevicesJson, stderr: "" };
+ }
+ if (command === "xcrun" && commandArgs[1] === "io" && commandArgs[3] === "screenshot") {
+ const outputPath = commandArgs.at(-1);
+ if (outputPath) fs.writeFileSync(outputPath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
+ return { stdout: "", stderr: "" };
+ }
+ if (command === "/usr/bin/xcode-select") return { stdout: "", stderr: "" };
+ return { stdout: "", stderr: "" };
+ });
+ const spawnMock = vi.fn<[string, string[], unknown?], ChildProcess>((command, commandArgs) => {
+ const child = mockChildProcess();
+ Object.defineProperty(child, "exitCode", { configurable: true, value: null });
+ Object.defineProperty(child, "signalCode", { configurable: true, value: null });
+ if (command === "idb_companion") {
+ const portArg = commandArgs[commandArgs.indexOf("--grpc-port") + 1];
+ const port = Number(portArg);
+ const server = net.createServer((socket) => socket.end());
+ server.listen(port, "127.0.0.1");
+ companionServers.push(server);
+ child.kill = vi.fn(() => {
+ server.close();
+ return true;
+ }) as unknown as ChildProcess["kill"];
+ }
+ return child;
+ });
+ const restoreHooks = __testSetIosSimulatorProcessHooks({
+ run: runMock,
+ spawn: spawnMock as unknown as typeof nodeSpawn,
+ commandExists: (command) => command === "xcrun" || command === "idb" || command === "idb_companion",
+ });
+ const service = createIosSimulatorService({
+ projectRoot: os.tmpdir(),
+ logger: noopLogger,
+ onEvent: (payload) => {
+ events.push(payload);
+ },
+ });
+
+ try {
+ const initialStatus = await service.startStream({ deviceUdid: "device-1", backend: "idb-mjpeg", fps: 30 });
+ expect(initialStatus.backend).toBe("idb-mjpeg");
+ expect(initialStatus.streamUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/ios-simulator\/stream\.mjpg$/);
+
+ await vi.advanceTimersByTimeAsync(5_100);
+ await vi.waitFor(() => {
+ expect(events.some((event) => (
+ event.type === "stream-error"
+ && event.status.backend === "idb-mjpeg"
+ && event.status.degradationReason?.includes("idb MJPEG produced no frames")
+ ))).toBe(true);
+ expect(events.some((event) => (
+ event.type === "stream-started"
+ && event.status.backend === "simctl-screenshot-poll"
+ && typeof event.status.streamUrl === "string"
+ ))).toBe(true);
+ });
+ await vi.waitFor(() => {
+ expect(service.getStreamStatus().frameCount).toBeGreaterThan(0);
+ });
+
+ const finalStatus = service.getStreamStatus();
+ expect(finalStatus.backend).toBe("simctl-screenshot-poll");
+ expect(finalStatus.running).toBe(true);
+ expect(finalStatus.streamUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/ios-simulator\/stream\.mjpg$/);
+ expect(finalStatus.frameCount).toBeGreaterThan(0);
+ } finally {
+ service.dispose();
+ for (const server of companionServers) server.close();
+ restoreHooks();
+ platformSpy.mockRestore();
+ }
+ });
+
it("documents launch and stream backend defaults with pure helpers", () => {
expect(shouldOpenSimulatorAppForLaunch(undefined)).toBe(false);
expect(shouldOpenSimulatorAppForLaunch(true)).toBe(false);
diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts
index fa5b9c736..0b5a223e2 100644
--- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts
+++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts
@@ -72,6 +72,7 @@ const IOSURFACE_TAP_HOLD_MS = 45;
const IOSURFACE_FAILURE_WINDOW_MS = 60_000;
const IOSURFACE_MAX_FAILURES_PER_WINDOW = 2;
const IOSURFACE_SUPPORTED_XCODE_MAJORS = new Set([17, 26]);
+const IOS_SIM_HELPER_SOURCE_FILES = ["build.sh", "sim-capture.swift", "sim-input.m"] as const;
const INSTALL_HINT_XCODE = "Install Xcode from the App Store, then run xcode-select --install.";
const INSTALL_HINT_XCODE_CLI = "Run xcode-select --install to install the Xcode command line tools.";
const INSTALL_HINT_IOSURFACE_INDIGO = "Install a supported full Xcode (17.x or 26.x) and select it with xcode-select so ADE can build the private IOSurface/Indigo helpers.";
@@ -298,6 +299,10 @@ export function __testSetIosSimulatorProcessHooks(hooks: {
};
}
+export function __testResetIosurfaceCapabilityCache(): void {
+ iosurfaceCapabilityCache = null;
+}
+
export function shouldOpenSimulatorAppForLaunch(keepSimulatorInBackground?: boolean | null): boolean {
return keepSimulatorInBackground === false;
}
@@ -379,23 +384,80 @@ function latencyPercentiles(samples: number[]): LatencyPercentiles {
}
function iosSimHelperRoot(): string | null {
+ const envRoot = process.env.ADE_IOS_SIM_HELPER_ROOT?.trim();
+ const processWithElectron = process as NodeJS.Process & { defaultApp?: boolean };
+ const packagedApp = Boolean(
+ process.resourcesPath
+ && findContainingAppBundle(process.resourcesPath)
+ && !processWithElectron.defaultApp,
+ );
const candidates = [
- path.join(process.cwd(), "native", "ios-sim-helpers"),
- path.join(process.cwd(), "apps", "desktop", "native", "ios-sim-helpers"),
- path.resolve(__dirname, "../../../../native/ios-sim-helpers"),
- ];
- return candidates.find((candidate) => fs.existsSync(path.join(candidate, "build.sh"))) ?? null;
+ envRoot || null,
+ process.resourcesPath ? path.join(process.resourcesPath, "native", "ios-sim-helpers") : null,
+ process.resourcesPath ? path.join(process.resourcesPath, "app.asar.unpacked", "native", "ios-sim-helpers") : null,
+ packagedApp ? null : path.join(process.cwd(), "native", "ios-sim-helpers"),
+ packagedApp ? null : path.join(process.cwd(), "apps", "desktop", "native", "ios-sim-helpers"),
+ packagedApp ? null : path.resolve(__dirname, "../../native/ios-sim-helpers"),
+ packagedApp ? null : path.resolve(__dirname, "../../../../native/ios-sim-helpers"),
+ ].filter(Boolean) as string[];
+ return candidates.find(hasIosSimHelperSources) ?? null;
+}
+
+function hasIosSimHelperSources(candidate: string): boolean {
+ return IOS_SIM_HELPER_SOURCE_FILES.every((fileName) => fs.existsSync(path.join(candidate, fileName)));
}
function hashHelperSources(helperRoot: string): string {
const hash = createHash("sha256");
- for (const fileName of ["sim-capture.swift", "sim-input.m", "build.sh"]) {
+ for (const fileName of IOS_SIM_HELPER_SOURCE_FILES) {
hash.update(fileName);
hash.update(fs.readFileSync(path.join(helperRoot, fileName)));
}
return hash.digest("hex");
}
+function findContainingAppBundle(targetPath: string): string | null {
+ const resolved = path.resolve(targetPath);
+ const parts = resolved.split(path.sep);
+ let current = resolved.startsWith(path.sep) ? path.sep : "";
+ for (const part of parts) {
+ if (!part) continue;
+ current = current === path.sep ? path.join(current, part) : path.join(current, part);
+ if (part.endsWith(".app")) return current;
+ }
+ return null;
+}
+
+function electronUserDataPath(): string | null {
+ try {
+ const electron = require("electron") as { app?: { getPath?: (name: string) => string } };
+ const userData = electron.app?.getPath?.("userData");
+ return typeof userData === "string" && userData.trim().length > 0 ? userData : null;
+ } catch {
+ return null;
+ }
+}
+
+function fallbackIosSimHelperCacheRoot(): string {
+ const home = os.homedir();
+ if (process.platform === "darwin" && home) {
+ return path.join(home, "Library", "Caches", "ADE");
+ }
+ return path.join(os.tmpdir(), "ADE");
+}
+
+function iosSimHelperBuildRoot(helperRoot: string): string | null {
+ const explicitRoot = process.env.ADE_IOS_SIM_HELPER_BUILD_ROOT?.trim();
+ if (explicitRoot) return explicitRoot;
+ const resourcesRoot = process.resourcesPath ? path.resolve(process.resourcesPath) : null;
+ const resolvedHelperRoot = path.resolve(helperRoot);
+ const helperIsInsidePackagedApp =
+ Boolean(findContainingAppBundle(resolvedHelperRoot)) ||
+ Boolean(resourcesRoot && (resolvedHelperRoot === resourcesRoot || resolvedHelperRoot.startsWith(`${resourcesRoot}${path.sep}`)));
+ if (!helperIsInsidePackagedApp) return null;
+ return path.join(electronUserDataPath() ?? fallbackIosSimHelperCacheRoot(), "ios-sim-helpers");
+}
+
let iosurfaceCapabilityCache: {
developerDir: string | null;
value: Promise;
@@ -428,32 +490,20 @@ function simulatorKitFrameworkPath(developerDir: string): string | null {
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
-function isPackagedElectronRuntime(): boolean {
- if (process.env.ADE_IOS_SURFACE_ALLOW_PACKAGED === "1") return false;
- try {
- const electron = require("electron") as { app?: { isPackaged?: boolean } };
- return Boolean(electron.app?.isPackaged);
- } catch {
- return false;
- }
-}
-
function unsupportedIosurfaceEnvironment(): IosurfaceIndigoCapability | null {
if (process.platform !== "darwin") {
return { available: false, reason: "IOSurface simulator streaming is only available on macOS." };
}
- if (isPackagedElectronRuntime()) {
- return {
- available: false,
- reason: "IOSurface simulator streaming is disabled in packaged ADE builds until helper signing and notarization are cleared. Using the idb/window/simctl fallback chain.",
- };
- }
return null;
}
-async function buildIosurfaceIndigoHelpers(helperRoot: string): Promise {
+async function buildIosurfaceIndigoHelpers(helperRoot: string, buildRoot: string | null): Promise {
+ const env = buildRoot
+ ? { ...process.env, ADE_IOS_SIM_HELPER_BUILD_ROOT: buildRoot }
+ : undefined;
const { stdout } = await run("bash", [path.join(helperRoot, "build.sh"), "--print-json", "--smoke"], {
timeoutMs: 120_000,
+ env,
});
const parsed = JSON.parse(stdout.trim()) as Partial;
const { xcodeVersion, sourceHash, buildDir, capture, input } = parsed;
@@ -507,7 +557,7 @@ export async function detectIosurfaceIndigoCapability(): Promise {
+ const nextBackend = cachedCommandExists("ffmpeg") ? "idb-h264-ffmpeg-mjpeg" : "simctl-screenshot-poll";
+ const fallbackReason = `Using ${nextBackend} fallback because ${detail}`;
+ const failedStatus: IosSimulatorStreamStatus = {
+ ...streamStatus,
+ running: false,
+ fallbackReason,
+ degradationReason: detail,
+ lastError: detail,
+ error: { code: "idb-mjpeg-no-frames", exitCode: null, signal: null },
+ };
+ emit({ type: "stream-error", status: failedStatus });
await stopStream();
- emit({ type: "stream-error", status: { ...streamStatus, lastError: detail } });
- streamRequestContext = { ...streamRequestContext, degradationReason: detail };
+ streamRequestContext = { ...streamRequestContext, fallbackReason, degradationReason: detail };
if (cachedCommandExists("ffmpeg")) {
await startIdbH264FfmpegStream(device, fps);
} else {
@@ -4008,9 +4068,18 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) {
ffmpegStderr: transcoderStderr.trim() || null,
});
void (async () => {
+ const fallbackReason = `Using simctl-screenshot-poll fallback because ${detail}`;
+ const failedStatus: IosSimulatorStreamStatus = {
+ ...streamStatus,
+ running: false,
+ fallbackReason,
+ degradationReason: detail,
+ lastError: detail,
+ error: { code: "idb-h264-no-frames", exitCode: null, signal: null },
+ };
+ emit({ type: "stream-error", status: failedStatus });
await stopStream();
- emit({ type: "stream-error", status: { ...streamStatus, lastError: detail } });
- streamRequestContext = { ...streamRequestContext, degradationReason: detail };
+ streamRequestContext = { ...streamRequestContext, fallbackReason, degradationReason: detail };
await startSimctlPreview(device, Math.min(8, fps));
})().catch((error) => {
const status = setStreamStopped(error instanceof Error ? error.message : String(error));
diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts
index e0df3b15a..828fba9ac 100644
--- a/apps/desktop/src/main/services/ipc/registerIpc.ts
+++ b/apps/desktop/src/main/services/ipc/registerIpc.ts
@@ -3225,13 +3225,35 @@ export function registerIpc({
});
ipcMain.handle(IPC.aiStoreApiKey, async (_event, arg: { provider: string; key: string }): Promise => {
+ const ctx = getCtx();
const { storeApiKey } = await import("../ai/apiKeyStore");
storeApiKey(arg.provider, arg.key);
+ try {
+ // The key store mutation already succeeded; invalidation is a freshness
+ // step so settings save/delete should not fail if a runtime cache is gone.
+ ctx.aiIntegrationService.invalidateProviderReadinessCaches();
+ } catch (error) {
+ ctx.logger.warn("ai.api_key_cache_invalidation_failed", {
+ provider: arg.provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
});
ipcMain.handle(IPC.aiDeleteApiKey, async (_event, arg: { provider: string }): Promise => {
+ const ctx = getCtx();
const { deleteApiKey } = await import("../ai/apiKeyStore");
deleteApiKey(arg.provider);
+ try {
+ // The key store mutation already succeeded; invalidation is a freshness
+ // step so settings save/delete should not fail if a runtime cache is gone.
+ ctx.aiIntegrationService.invalidateProviderReadinessCaches();
+ } catch (error) {
+ ctx.logger.warn("ai.api_key_cache_invalidation_failed", {
+ provider: arg.provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
});
ipcMain.handle(IPC.aiListApiKeys, async (): Promise => {
@@ -5458,7 +5480,7 @@ export function registerIpc({
});
}
}
- return {
+ return ctx.ptyService.enrichSessions([session])[0] ?? {
...session,
runtimeState: ctx.ptyService.getRuntimeState(session.id, session.status)
};
diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts
index 52b3ccaef..01a2b5f27 100644
--- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts
+++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts
@@ -130,6 +130,22 @@ describe("openCodeRuntime", () => {
expect(mockState.sharedLease.close).toHaveBeenCalledWith("handle_close");
});
+ it("omits the session title when ADE wants OpenCode to auto-name", async () => {
+ await startOpenCodeSession({
+ directory: "/repo",
+ title: null,
+ leaseKind: "shared",
+ projectConfig: { ai: {} },
+ ownerKind: "chat",
+ ownerId: "chat-1",
+ ownerKey: "chat:chat-1",
+ });
+
+ expect(mockState.createSession).toHaveBeenCalledWith(expect.objectContaining({
+ body: {},
+ }));
+ });
+
it("applies no scoped tool selection to one-shot prompts", async () => {
const result = await runOpenCodeTextPrompt({
directory: "/repo",
diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts
index ceb972205..0abb91283 100644
--- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts
+++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts
@@ -46,6 +46,7 @@ export type OpenCodeSessionHandle = {
};
lease: OpenCodeServerLease;
sessionId: string;
+ initialTitle: string | null;
directory: string;
toolSelection: Record | null;
close(reason?: OpenCodeServerShutdownReason): Promise;
@@ -75,7 +76,7 @@ type BuildOpenCodeConfigArgs = {
type StartOpenCodeSessionArgs = BuildOpenCodeConfigArgs & {
directory: string;
- title: string;
+ title?: string | null;
sessionId?: string;
ownerKind?: OpenCodeServerOwnerKind;
ownerId?: string | null;
@@ -86,7 +87,7 @@ type StartOpenCodeSessionArgs = BuildOpenCodeConfigArgs & {
type RunOpenCodePromptArgs = BuildOpenCodeConfigArgs & {
directory: string;
- title: string;
+ title?: string | null;
modelDescriptor: ModelDescriptor;
prompt: string;
system?: string;
@@ -374,6 +375,7 @@ function createOpenCodeSessionHandle(args: {
client: OpencodeClient;
lease: OpenCodeServerLease;
sessionId: string;
+ initialTitle?: string | null;
directory: string;
toolSelection: Record | null;
}): OpenCodeSessionHandle {
@@ -387,6 +389,7 @@ function createOpenCodeSessionHandle(args: {
},
lease: args.lease,
sessionId: args.sessionId,
+ initialTitle: trimToUndefined(args.initialTitle) ?? null,
directory: args.directory,
toolSelection: args.toolSelection,
async close(reason = "handle_close") {
@@ -437,7 +440,7 @@ async function startOpenCodeSessionInternal(
if (resolvedSessionId) {
try {
- await client.session.get({
+ const existing = await client.session.get({
path: { id: resolvedSessionId },
query: { directory: args.directory },
});
@@ -445,6 +448,7 @@ async function startOpenCodeSessionInternal(
client,
lease,
sessionId: resolvedSessionId,
+ initialTitle: existing.data?.title,
directory: args.directory,
toolSelection: null,
});
@@ -455,7 +459,7 @@ async function startOpenCodeSessionInternal(
const created = await client.session.create({
query: { directory: args.directory },
- body: { title: args.title },
+ body: trimToUndefined(args.title) ? { title: trimToUndefined(args.title) } : {},
});
if (!created.data) {
@@ -467,6 +471,7 @@ async function startOpenCodeSessionInternal(
client,
lease,
sessionId: created.data.id,
+ initialTitle: created.data.title,
directory: args.directory,
toolSelection: null,
});
diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
index 52153e87c..68e0a442d 100644
--- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
@@ -2148,13 +2148,16 @@ describe("aiOrchestratorService", () => {
expect(targetCompleted!.state).toBe("completed");
expect(targetCompleted!.completedAt).toBeTruthy();
} finally {
- fixture.aiOrchestratorService.dispose();
+ fixture.dispose();
}
});
it("pushes terminal sub-agent completion summaries to the parent attempt thread", async () => {
- const fixture = await createFixture();
+ const injectMessageSpy = vi.spyOn(CoordinatorAgent.prototype, "injectMessage").mockImplementation(() => {});
+ const injectEventSpy = vi.spyOn(CoordinatorAgent.prototype, "injectEvent").mockImplementation(() => {});
+ let fixture: Awaited> | null = null;
try {
+ fixture = await createFixture();
const mission = fixture.missionService.create({
prompt: "Validate sub-agent completion rollups.",
laneId: fixture.laneId
@@ -2254,7 +2257,9 @@ describe("aiOrchestratorService", () => {
);
expect(rollups).toHaveLength(1);
} finally {
- fixture.dispose();
+ fixture?.dispose();
+ injectEventSpy.mockRestore();
+ injectMessageSpy.mockRestore();
}
});
diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts
index ab983f02a..efb52c5f0 100644
--- a/apps/desktop/src/main/services/pty/ptyService.test.ts
+++ b/apps/desktop/src/main/services/pty/ptyService.test.ts
@@ -1205,6 +1205,49 @@ describe("ptyService", () => {
expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests");
});
+ it("ignores provider slash commands when choosing the first CLI title seed", async () => {
+ const { service, sessionService } = createHarness();
+ const { ptyId } = await service.create({
+ laneId: "lane-1",
+ title: "Codex CLI",
+ cols: 80,
+ rows: 24,
+ toolType: "codex",
+ });
+
+ const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId;
+ expect(createdSessionId).toBeTruthy();
+
+ service.write({ ptyId, data: "/model\r" });
+
+ expect(sessionService.get(createdSessionId)?.title).toBe("Codex CLI");
+ expect(sessionService.get(createdSessionId)?.goal ?? null).toBeNull();
+
+ service.write({ ptyId, data: "Fix the flaky login tests\r" });
+
+ expect(sessionService.get(createdSessionId)?.title).toBe("Fix the flaky login tests");
+ expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests");
+ });
+
+ it("treats legacy slash-command CLI titles as placeholders", async () => {
+ const { service, sessionService } = createHarness();
+ const { ptyId } = await service.create({
+ laneId: "lane-1",
+ title: "/model",
+ cols: 80,
+ rows: 24,
+ toolType: "codex",
+ });
+
+ const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId;
+ expect(createdSessionId).toBeTruthy();
+
+ service.write({ ptyId, data: "Fix the flaky login tests\r" });
+
+ expect(sessionService.get(createdSessionId)?.title).toBe("Fix the flaky login tests");
+ expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests");
+ });
+
it("sets a compact fallback title from the first CLI prompt while AI naming is pending", async () => {
const { service, sessionService } = createHarness();
const { ptyId } = await service.create({
@@ -1253,6 +1296,29 @@ describe("ptyService", () => {
expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled();
});
+ it("rejects low-signal AI titles for raw CLI sessions", async () => {
+ const aiIntegrationService = {
+ getMode: vi.fn(() => "subscription"),
+ summarizeTerminal: vi.fn(async () => ({ text: "/model" })),
+ };
+ const { service, sessionService } = createHarness({ aiIntegrationService });
+ const { ptyId } = await service.create({
+ laneId: "lane-1",
+ title: "Codex CLI",
+ cols: 80,
+ rows: 24,
+ toolType: "codex",
+ });
+
+ const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId;
+ expect(createdSessionId).toBeTruthy();
+
+ service.write({ ptyId, data: "Fix the flaky login tests\r" });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(sessionService.get(createdSessionId)?.title).toBe("Fix the flaky login tests");
+ });
+
it("backfills a missing tracked CLI resume target from the flushed transcript tail on exit", async () => {
mocks.extractResumeCommandFromOutput.mockReturnValue("codex resume thread-backfilled" as any);
const { service, mockPty, sessionService } = createHarness();
@@ -1415,6 +1481,36 @@ describe("ptyService", () => {
expect(enriched[0]).toMatchObject({ id: sessionId, runtimeState: "running", extra: "data" });
});
+ it("overlays live PTY attachment when a persisted row drifted to ended", async () => {
+ const { service, sessionService } = createHarness();
+ const { ptyId, sessionId } = await service.create({
+ laneId: "lane-1",
+ title: "Codex CLI",
+ cols: 80,
+ rows: 24,
+ toolType: "codex",
+ });
+ sessionService.end({
+ sessionId,
+ endedAt: "2026-04-09T12:30:00.000Z",
+ exitCode: 0,
+ status: "completed",
+ });
+
+ const stale = sessionService.get(sessionId);
+ expect(stale).toMatchObject({ status: "completed", ptyId: null });
+
+ const enriched = service.enrichSessions([stale] as any);
+ expect(enriched[0]).toMatchObject({
+ id: sessionId,
+ ptyId,
+ status: "running",
+ endedAt: null,
+ exitCode: null,
+ runtimeState: "running",
+ });
+ });
+
it("falls back to status-derived state for unknown sessions", () => {
const { service } = createHarness();
const rows = [{ id: "unknown", status: "completed" as const }];
diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts
index 95c85c2c4..46c8d663c 100644
--- a/apps/desktop/src/main/services/pty/ptyService.ts
+++ b/apps/desktop/src/main/services/pty/ptyService.ts
@@ -30,6 +30,7 @@ import type {
TerminalSessionSummary,
TerminalToolType
} from "../../../shared/types";
+import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands";
import { stripAnsi } from "../../utils/ansiStrip";
import { summarizeTerminalSession } from "../../utils/sessionSummary";
import { derivePreviewFromChunk } from "../../utils/terminalPreview";
@@ -124,11 +125,34 @@ function deterministicCliTitleFromSeed(seed: string): string {
function isCliPlaceholderTitle(title: string | null | undefined, toolType: TerminalToolType | null | undefined): boolean {
const normalized = String(title ?? "").trim().toLowerCase();
if (!normalized.length) return true;
+ if (isProviderSlashCommandInput(normalized)) return true;
if (toolType === "codex") return normalized === "codex" || normalized === "codex cli" || normalized === "codex session";
if (toolType === "claude") return normalized === "claude" || normalized === "claude cli" || normalized === "claude session" || normalized === "claude code";
return false;
}
+function sanitizeGeneratedCliTitle(raw: string): string {
+ const title = stripAnsi(raw)
+ .replace(/\p{Extended_Pictographic}/gu, "")
+ .replace(/^["'`]+|["'`]+$/g, "")
+ .replace(/\s+/g, " ")
+ .replace(/[^\p{L}\p{N})\]]+$/gu, "")
+ .trim()
+ .slice(0, 80)
+ .trim();
+ if (!title.length) return "";
+ if (isProviderSlashCommandInput(title)) return "";
+ const collapsed = title.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
+ const rejected = new Set([
+ "model", "models", "status", "help", "clear", "compact", "resume",
+ "chat", "session", "claude", "claude code", "codex", "codex cli",
+ "untitled", "untitled chat", "new chat", "new session",
+ "completed", "done", "finished", "success",
+ ]);
+ if (/^(new session|new chat|untitled chat|untitled)\b/u.test(collapsed)) return "";
+ return rejected.has(collapsed) ? "" : title;
+}
+
function isSessionManuallyNamed(
sessionService: ReturnType,
sessionId: string,
@@ -385,6 +409,7 @@ export function createPtyService({
entry.cliUserTitleLineBuffer = entry.cliUserTitleLineBuffer.slice(idx + 1);
const seed = sanitizeCliUserTitleSeed(segment);
if (seed.length < CLI_USER_TITLE_SEED_MIN_LEN) continue;
+ if (isProviderSlashCommandInput(seed)) continue;
entry.cliUserTitleCommitted = true;
if (entry.aiTitleTimer) {
@@ -433,7 +458,7 @@ export function createPtyService({
})
.then((result) => {
if (entry.disposed) return;
- const title = result.text.trim().replace(/\s+/g, " ").slice(0, 80);
+ const title = sanitizeGeneratedCliTitle(result.text);
if (!title) return;
if (isSessionManuallyNamed(sessionService, entry.sessionId)) {
logger.info("pty.cli_user_title_skipped_user_renamed", { sessionId: entry.sessionId });
@@ -650,7 +675,7 @@ export function createPtyService({
timeoutMs: PTY_AI_TITLE_TIMEOUT_MS,
...(titleModelId ? { model: titleModelId } : {}),
});
- const finalTitle = titleResult.text.trim().replace(/\s+/g, " ").slice(0, 80);
+ const finalTitle = sanitizeGeneratedCliTitle(titleResult.text);
if (finalTitle) {
// Re-check in case user renamed during AI call
if (isSessionManuallyNamed(sessionService, sessionId)) {
@@ -1375,6 +1400,7 @@ export function createPtyService({
?? summary.chatSessionId
?? null;
const activeTerminalId = chatSessionId ? activeTerminalByChatSession.get(chatSessionId) ?? null : null;
+ const fallbackStatus = live ? "running" : summary.status;
return {
terminalId: summary.id,
ptyId: live?.[0] ?? summary.ptyId ?? null,
@@ -1382,12 +1408,12 @@ export function createPtyService({
laneId: summary.laneId,
laneName: summary.laneName,
title: summary.title,
- status: summary.status,
- runtimeState: computeRuntimeState(summary.id, summary.status),
+ status: fallbackStatus,
+ runtimeState: computeRuntimeState(summary.id, fallbackStatus),
active: Boolean(activeTerminalId && activeTerminalId === summary.id),
startedAt: summary.startedAt,
- endedAt: summary.endedAt,
- exitCode: summary.exitCode,
+ endedAt: live ? null : summary.endedAt,
+ exitCode: live ? null : summary.exitCode,
pid: live?.[1].pty.pid ?? null,
};
};
@@ -1879,7 +1905,7 @@ export function createPtyService({
...(titleModelId ? { model: titleModelId } : {}),
})
.then((result) => {
- const title = result.text.trim().replace(/\s+/g, " ").slice(0, 80);
+ const title = sanitizeGeneratedCliTitle(result.text);
if (title) {
// Re-check in case user renamed during AI call
if (isSessionManuallyNamed(sessionService, sessionId)) {
@@ -2104,14 +2130,25 @@ export function createPtyService({
},
enrichSessions(rows: T[]): T[] {
- return rows.map((row) => ({
- ...row,
- runtimeState: this.getRuntimeState(row.id, row.status),
- chatSessionId: row.chatSessionId
- ?? terminalChatSessions.get(row.id)
- ?? liveEntryBySessionId(row.id)?.[1].chatSessionId
- ?? null,
- }));
+ return rows.map((row) => {
+ const live = liveEntryBySessionId(row.id);
+ const fallbackStatus = live ? "running" : row.status;
+ return {
+ ...row,
+ ...(live
+ ? {
+ ptyId: live[0],
+ status: "running" as const,
+ endedAt: null,
+ exitCode: null,
+ }
+ : {}),
+ runtimeState: computeRuntimeState(row.id, fallbackStatus),
+ chatSessionId: live
+ ? terminalChatSessions.get(row.id) ?? live[1].chatSessionId ?? row.chatSessionId ?? null
+ : terminalChatSessions.get(row.id) ?? row.chatSessionId ?? null,
+ };
+ });
},
dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): void {
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
index 0ec068fb8..a8121a216 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts
@@ -75,6 +75,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [
"git.rebaseContinue",
"git.rebaseAbort",
"chat.models",
+ "chat.modelCatalog",
"chat.listSessions",
"chat.create",
"chat.getSummary",
@@ -418,6 +419,7 @@ function createMockAgentChatService() {
updateSession: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
getAvailableModels: vi.fn().mockResolvedValue([{ id: "model-1", modelId: "m1" }]),
+ getModelCatalog: vi.fn().mockResolvedValue({ groups: [], fetchedAt: "2026-01-01T00:00:00.000Z" }),
ensureIdentitySession: vi.fn().mockResolvedValue({
id: "chat-identity-1",
laneId: "lane-1",
@@ -1541,6 +1543,12 @@ describe("createSyncRemoteCommandService", () => {
expect(agentChatService.getAvailableModels).toHaveBeenCalledWith({ provider: "codex" });
});
+ it("chat.modelCatalog returns the canonical model catalog", async () => {
+ const result = await service.execute(makePayload("chat.modelCatalog", {}));
+ expect(agentChatService.getModelCatalog).toHaveBeenCalled();
+ expect(result).toEqual({ groups: [], fetchedAt: "2026-01-01T00:00:00.000Z" });
+ });
+
it("chat commands throw when agentChatService is not available", async () => {
const svcNoChat = createSyncRemoteCommandService({
laneService,
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
index e9ff15530..dac36ede9 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
@@ -1739,6 +1739,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
});
register("chat.models", { viewerAllowed: true }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload)));
+ register("chat.modelCatalog", { viewerAllowed: true }, async () =>
+ requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog());
register("cto.getRoster", { viewerAllowed: true }, async () => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts
index a33756404..b533aa70a 100644
--- a/apps/desktop/src/preload/preload.test.ts
+++ b/apps/desktop/src/preload/preload.test.ts
@@ -124,4 +124,54 @@ describe("preload OAuth bridge", () => {
unsubscribe();
expect(removeListener).toHaveBeenCalledWith(IPC.reviewEvent, listener);
});
+
+ it("clears the AI status bridge cache after API key verification", async () => {
+ const status = {
+ mode: "guest",
+ availableProviders: { claude: false, codex: false, cursor: false, droid: false },
+ models: { claude: [], codex: [], cursor: [], droid: [] },
+ features: [],
+ };
+ const invoke = vi.fn(async (channel: string) => {
+ if (channel === IPC.aiGetStatus) return status;
+ if (channel === IPC.aiVerifyApiKey) {
+ return {
+ provider: "cursor",
+ ok: true,
+ message: "Verified",
+ verifiedAt: "2026-03-17T19:00:00.000Z",
+ };
+ }
+ return undefined;
+ });
+ const on = vi.fn();
+ const removeListener = vi.fn();
+ const exposeInMainWorld = vi.fn((name: string, value: unknown) => {
+ (globalThis as any).__bridgeName = name;
+ (globalThis as any).__adeBridge = value;
+ });
+
+ vi.doMock("electron", () => ({
+ contextBridge: { exposeInMainWorld },
+ ipcRenderer: { invoke, on, removeListener },
+ webFrame: {
+ getZoomLevel: vi.fn(() => 0),
+ setZoomLevel: vi.fn(),
+ getZoomFactor: vi.fn(() => 1),
+ },
+ }));
+
+ await import("./preload");
+
+ const bridge = (globalThis as any).__adeBridge;
+ await bridge.ai.getStatus();
+ await bridge.ai.getStatus();
+ expect(invoke.mock.calls.filter(([channel]) => channel === IPC.aiGetStatus)).toHaveLength(1);
+
+ await bridge.ai.verifyApiKey("cursor");
+ await bridge.ai.getStatus();
+
+ expect(invoke).toHaveBeenCalledWith(IPC.aiVerifyApiKey, { provider: "cursor" });
+ expect(invoke.mock.calls.filter(([channel]) => channel === IPC.aiGetStatus)).toHaveLength(2);
+ });
});
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index fc05dcf14..c751d7d94 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1136,7 +1136,7 @@ contextBridge.exposeInMainWorld("ade", {
verifyApiKey: async (
provider: string,
): Promise =>
- ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider }),
+ clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider })),
updateConfig: async (config: Partial): Promise =>
clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiUpdateConfig, config)),
cursorCloudListRepositories: async (): Promise =>
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
index ce74fe26e..bc65bf5b0 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
@@ -2397,6 +2397,24 @@ export function AgentChatPane({
});
const refreshAvailableModels = useCallback(async () => {
+ const orderModelIds = (ids: Iterable): string[] => {
+ const available = new Set(ids);
+ const ordered = MODEL_REGISTRY
+ .filter((model) => !model.deprecated && available.has(model.id))
+ .map((model) => model.id);
+ const extra = [...available].filter((modelId) => !ordered.includes(modelId));
+ extra.sort((left, right) => {
+ const leftLabel = getModelById(left)?.displayName ?? left;
+ const rightLabel = getModelById(right)?.displayName ?? right;
+ return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" });
+ });
+ return [...ordered, ...extra];
+ };
+ const isCursorModelId = (id: string): boolean => (
+ id.startsWith("cursor/")
+ || getModelById(id)?.family === "cursor"
+ );
+
const selectedModelProvider = modelId.trim()
? resolveChatRuntimeProvider(getModelById(modelId))
: null;
@@ -2419,8 +2437,36 @@ export function AgentChatPane({
droid: status.providerConnections?.droid ?? null,
});
const available = deriveConfiguredModelIds(status, { includeDroid: true });
- setAvailableModelIds(available);
- return available;
+ const orderedAvailable = orderModelIds(available);
+ setAvailableModelIds(orderedAvailable);
+ const cursorReady = status.availableProviders?.cursor === true
+ || status.providerConnections?.cursor?.runtimeAvailable === true;
+ if (!cursorReady) return orderedAvailable;
+
+ let cursorModels: Awaited>;
+ try {
+ cursorModels = await getAgentChatModelsCached({
+ projectRoot,
+ provider: "cursor",
+ activateRuntime: true,
+ });
+ } catch {
+ return orderedAvailable;
+ }
+ if (!cursorModels.length) {
+ const withoutCursor = orderedAvailable.filter((id) => !isCursorModelId(id));
+ setAvailableModelIds(withoutCursor);
+ return withoutCursor;
+ }
+
+ const merged = new Set(available);
+ for (const model of cursorModels) {
+ const resolved = resolveCliRegistryModelId("cursor", model.id);
+ if (resolved) merged.add(resolved);
+ }
+ const withCursor = orderModelIds(merged);
+ setAvailableModelIds(withCursor);
+ return withCursor;
} catch {
setAiStatus(null);
setProviderConnections(null);
@@ -2431,7 +2477,7 @@ export function AgentChatPane({
const [codexModels, claudeModels, cursorModels, droidModels, openCodeModels] = await Promise.all([
getAgentChatModelsCached({ projectRoot, provider: "codex" }).catch(() => []),
getAgentChatModelsCached({ projectRoot, provider: "claude" }).catch(() => []),
- getAgentChatModelsCached({ projectRoot, provider: "cursor" }).catch(() => []),
+ getAgentChatModelsCached({ projectRoot, provider: "cursor", activateRuntime: true }).catch(() => []),
getAgentChatModelsCached({ projectRoot, provider: "droid" }).catch(() => []),
getAgentChatModelsCached({
projectRoot,
@@ -2466,16 +2512,7 @@ export function AgentChatPane({
}
}
- const ordered = MODEL_REGISTRY
- .filter((model) => !model.deprecated && available.has(model.id))
- .map((model) => model.id);
- const extra = [...available].filter((modelId) => !ordered.includes(modelId));
- extra.sort((left, right) => {
- const leftLabel = getModelById(left)?.displayName ?? left;
- const rightLabel = getModelById(right)?.displayName ?? right;
- return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" });
- });
- const allAvailable = [...ordered, ...extra];
+ const allAvailable = orderModelIds(available);
setAvailableModelIds(allAvailable);
return allAvailable;
} catch {
diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx
index 5f75feb4a..a538b4d5a 100644
--- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx
@@ -335,6 +335,55 @@ describe("ChatIosSimulatorPanel", () => {
expect(api.startStream).toHaveBeenLastCalledWith({ deviceUdid: device.udid, backend: "auto" });
});
+ it("does not stop a service-managed fallback while it is switching backends", async () => {
+ const { api, emit } = installIosSimulatorApi();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(api.startStream).toHaveBeenCalledTimes(1), { timeout: 3_000 });
+ const stopCallsBeforeFallback = api.stopStream.mock.calls.length;
+
+ act(() => {
+ emit({
+ type: "stream-error",
+ status: streamStatus({
+ running: false,
+ backend: "idb-mjpeg",
+ streamUrl: null,
+ lastError: "idb MJPEG produced no frames; falling back to simulator screenshots.",
+ fallbackReason: "Using simctl-screenshot-poll fallback because idb MJPEG produced no frames.",
+ degradationReason: "idb MJPEG produced no frames; falling back to simulator screenshots.",
+ }),
+ });
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 700));
+
+ expect(api.stopStream).toHaveBeenCalledTimes(stopCallsBeforeFallback);
+ expect(api.startStream).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ emit({
+ type: "stream-started",
+ status: streamStatus({
+ backend: "simctl-screenshot-poll",
+ streamUrl: "http://127.0.0.1:5678/ios-simulator/stream.mjpg",
+ fallbackReason: "Using simctl-screenshot-poll fallback because idb MJPEG produced no frames.",
+ degradationReason: "idb MJPEG produced no frames; falling back to simulator screenshots.",
+ }),
+ });
+ });
+
+ await waitFor(() => expect(api.startStream).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText(/Live stream failed/)).toBeNull();
+ });
+
it("forces the idb fallback after repeated native stream failures", async () => {
const { api, emit } = installIosSimulatorApi({ autoBackend: "iosurface-indigo" });
diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx
index 1094c1606..d196351c9 100644
--- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx
+++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx
@@ -1218,6 +1218,12 @@ export function ChatIosSimulatorPanel({
}
}, [projectRoot, selectedElement?.sourceFile, selectedElement?.sourceLine]);
+ const cancelDeviceBackedStreamRestart = useCallback(() => {
+ if (liveRestartTimerRef.current == null) return;
+ window.clearTimeout(liveRestartTimerRef.current);
+ liveRestartTimerRef.current = null;
+ }, []);
+
const stopRendererLiveVisual = useCallback((options: { preserveVisual?: boolean } = {}) => {
const preserveVisual = options.preserveVisual === true;
if (liveRestartTimerRef.current != null) {
@@ -1357,6 +1363,23 @@ export function ChatIosSimulatorPanel({
return;
}
if (!nextStatus.streamUrl) {
+ setStreamStatus(nextStatus);
+ if (nextStatus.degradationReason || nextStatus.fallbackReason || nextStatus.lastError) {
+ const detail = nextStatus.degradationReason ?? nextStatus.fallbackReason ?? nextStatus.lastError ?? LIVE_RECONNECT_MESSAGE;
+ setLiveVisual((current) => current?.kind === "mjpeg"
+ ? { ...current, status: "reconnecting", url: null, error: detail }
+ : {
+ kind: "mjpeg",
+ status: "reconnecting",
+ url: null,
+ width: null,
+ height: null,
+ error: detail,
+ });
+ setMessage(detail);
+ void refreshStatus().catch(() => {});
+ return;
+ }
throw new Error(nextStatus.lastError ?? "Live stream did not provide a drawable URL.");
}
setStreamStatus(nextStatus);
@@ -1369,7 +1392,7 @@ export function ChatIosSimulatorPanel({
height: current?.kind === "mjpeg" ? current.height : null,
error: null,
}));
- }, [startWindowCaptureVisual]);
+ }, [refreshStatus, startWindowCaptureVisual]);
useEffect(() => {
const video = videoRef.current;
@@ -1655,6 +1678,14 @@ export function ChatIosSimulatorPanel({
return;
}
if (event.type === "stream-started" || event.type === "stream-status" || event.type === "stream-stopped" || event.type === "stream-error") {
+ const fallbackMessage = event.status.degradationReason ?? event.status.fallbackReason ?? null;
+ const serviceManagedFallback = Boolean(fallbackMessage);
+ const recoveredStream =
+ (event.type === "stream-started" || event.type === "stream-status")
+ && Boolean(event.status.streamUrl && event.status.lastFrameAt);
+ if (serviceManagedFallback || recoveredStream || (event.type === "stream-started" && event.status.streamUrl)) {
+ cancelDeviceBackedStreamRestart();
+ }
if (event.status.backend) lastResolvedStreamBackendRef.current = event.status.backend;
setStreamStatus(event.status);
if ((event.type === "stream-started" || event.type === "stream-status") && event.status.streamUrl) {
@@ -1682,8 +1713,8 @@ export function ChatIosSimulatorPanel({
};
});
}
- if (event.type === "stream-started" && (event.status.degradationReason || event.status.fallbackReason)) {
- setMessage(event.status.degradationReason ?? event.status.fallbackReason ?? null);
+ if (event.type === "stream-started" && fallbackMessage) {
+ setMessage(fallbackMessage);
}
if ((event.type === "stream-stopped" || event.type === "stream-error") && !event.status.streamUrl) {
setLiveVisual((current) => current?.kind === "mjpeg"
@@ -1691,11 +1722,14 @@ export function ChatIosSimulatorPanel({
...current,
status: event.type === "stream-error" ? "reconnecting" : current.status,
url: null,
- error: event.status.lastError ?? current.error,
+ error: fallbackMessage ?? event.status.lastError ?? current.error,
}
: current);
}
- if (event.type === "stream-error" && event.status.lastError) setMessage(event.status.lastError);
+ if (event.type === "stream-error") {
+ if (fallbackMessage) setMessage(fallbackMessage);
+ else if (event.status.lastError) setMessage(event.status.lastError);
+ }
return;
}
if (event.type === "session-released") {
@@ -1714,7 +1748,7 @@ export function ChatIosSimulatorPanel({
return () => {
unsubscribe();
};
- }, [onAddContext, refreshStatus, sessionId]);
+ }, [cancelDeviceBackedStreamRestart, onAddContext, refreshStatus, sessionId]);
useEffect(() => {
if (
@@ -1722,11 +1756,21 @@ export function ChatIosSimulatorPanel({
|| liveVisualKind !== "mjpeg"
|| !streamStatus?.lastError
|| streamStatus.running
+ || streamStatus.degradationReason
+ || streamStatus.fallbackReason
) {
return;
}
scheduleDeviceBackedStreamRestart("Live simulator stream stopped.");
- }, [liveVisualKind, mode, scheduleDeviceBackedStreamRestart, streamStatus?.lastError, streamStatus?.running]);
+ }, [
+ liveVisualKind,
+ mode,
+ scheduleDeviceBackedStreamRestart,
+ streamStatus?.degradationReason,
+ streamStatus?.fallbackReason,
+ streamStatus?.lastError,
+ streamStatus?.running,
+ ]);
useEffect(() => {
if (
diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx
index 7b668683c..d724716a5 100644
--- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx
+++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx
@@ -6,6 +6,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProvidersSection } from "./ProvidersSection";
import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types";
+vi.mock("@lobehub/icons", () => {
+ const brand = () => {
+ const Component = () => null;
+ Object.assign(Component, {
+ Avatar: () => null,
+ Color: () => null,
+ Combine: () => null,
+ Text: () => null,
+ colorPrimary: "#888",
+ title: "stub",
+ });
+ return Component;
+ };
+ return {
+ Anthropic: brand(),
+ Claude: brand(),
+ Codex: brand(),
+ Cursor: brand(),
+ Gemini: brand(),
+ Google: brand(),
+ Grok: brand(),
+ Groq: brand(),
+ Kimi: brand(),
+ OpenAI: brand(),
+ OpenCode: brand(),
+ OpenRouter: brand(),
+ XAI: brand(),
+ };
+});
+
function buildStatus(
claudeRuntimeAvailable: boolean,
localModels: string[] = [],
@@ -302,4 +332,32 @@ describe("ProvidersSection", () => {
expect(await screen.findByText("Cursor connection verified.")).toBeTruthy();
expect(screen.getAllByText("Connected").length).toBeGreaterThan(0);
});
+
+ it("forces a provider status refresh after verifying a stored Cursor API key", async () => {
+ const getStatusMock = window.ade.ai.getStatus as ReturnType;
+ getStatusMock.mockReset();
+ getStatusMock.mockResolvedValue(buildStatus(true, []));
+ const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType;
+ listApiKeysMock.mockReset();
+ listApiKeysMock.mockResolvedValue(["cursor"]);
+
+ render();
+
+ await waitFor(() => {
+ expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1);
+ expect(window.ade.ai.listApiKeys).toHaveBeenCalledTimes(1);
+ });
+
+ await act(async () => {
+ screen.getByLabelText("Verify Cursor API key").click();
+ });
+
+ await waitFor(() => {
+ expect(window.ade.ai.verifyApiKey).toHaveBeenCalledWith("cursor");
+ expect(window.ade.ai.getStatus).toHaveBeenCalledWith({
+ force: true,
+ refreshOpenCodeInventory: true,
+ });
+ });
+ });
});
diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
index 9bc6b60c2..99fababd7 100644
--- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
+++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
@@ -380,7 +380,13 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh
if (status?.apiKeyStore?.legacyPlaintextDetected) {
return "Legacy plaintext API keys were detected in .ade/secrets/api-keys.json. ADE now uses encrypted safeStorage, and plaintext keys are no longer loaded. Re-enter any keys you still need.";
}
+ if (status?.apiKeyStore?.macosKeychainError) {
+ return status.apiKeyStore.macosKeychainError;
+ }
if (status?.apiKeyStore?.decryptionFailed) {
+ if (status.apiKeyStore.macosKeychainAvailable) {
+ return "An older encrypted API key file could not be decrypted from this app identity. New keys are stored in macOS Keychain; re-enter any missing keys.";
+ }
return "Encrypted API keys exist but could not be decrypted on this machine. Re-enter the affected keys to continue using them.";
}
if (status?.apiKeyStore?.secureStorageAvailable === false) {
@@ -446,12 +452,18 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh
setError(null);
setNotice(null);
setVerifyingProvider(provider);
+ setVerificationByProvider((prev) => {
+ const next = { ...prev };
+ delete next[provider];
+ return next;
+ });
try {
+ invalidateAiDiscoveryCache();
const result = await window.ade.ai.verifyApiKey(provider);
+ invalidateAiDiscoveryCache();
setVerificationByProvider((prev) => ({ ...prev, [provider]: result }));
setNotice(result.ok ? `${provider} connection verified.` : `${provider} verification failed.`);
- setVerifyingProvider(null);
- await refreshStatus();
+ await refreshStatus({ force: true, refreshOpenCodeInventory: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -466,15 +478,20 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh
setError(null);
setNotice(null);
setVerifyingProvider("cursor");
+ setVerificationByProvider((prev) => {
+ const next = { ...prev };
+ delete next.cursor;
+ return next;
+ });
try {
await window.ade.ai.storeApiKey("cursor", trimmed);
invalidateAiDiscoveryCache();
setStoredProviders((prev) => Array.from(new Set([...prev, "cursor"])));
cancelEditing();
const result = await window.ade.ai.verifyApiKey("cursor");
+ invalidateAiDiscoveryCache();
setVerificationByProvider((prev) => ({ ...prev, cursor: result }));
setNotice(result.ok ? "Cursor connection verified." : "Cursor verification failed.");
- setVerifyingProvider(null);
await refreshStatus({ force: true, refreshOpenCodeInventory: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
diff --git a/apps/desktop/src/renderer/components/shared/providerModelSelectorGrouping.ts b/apps/desktop/src/renderer/components/shared/providerModelSelectorGrouping.ts
index 396656666..814547041 100644
--- a/apps/desktop/src/renderer/components/shared/providerModelSelectorGrouping.ts
+++ b/apps/desktop/src/renderer/components/shared/providerModelSelectorGrouping.ts
@@ -1,404 +1 @@
-import {
- CURSOR_CLI_LINE_ORDER,
- DROID_CLI_LINE_ORDER,
- MODEL_REGISTRY,
- cursorCliLineGroupFromSdkId,
- cursorCliLineGroupLabel,
- droidCliLineGroupFromModelId,
- droidCliLineGroupLabel,
- type CursorCliLineGroup,
- type DroidCliLineGroup,
- type ModelDescriptor,
-} from "../../../shared/modelRegistry";
-
-/** Top-level provider groups — the five first-class ADE runtime providers. */
-export type ProviderGroupKey = "claude" | "codex" | "cursor" | "droid" | "opencode";
-
-/** Category within the OpenCode provider group. */
-export type ProviderCategory = "cloud-api" | "local" | "router";
-
-export const PROVIDER_CATEGORY_MAP: Record = {
- anthropic: "cloud-api",
- openai: "cloud-api",
- google: "cloud-api",
- deepseek: "cloud-api",
- mistral: "cloud-api",
- xai: "cloud-api",
- groq: "cloud-api",
- together: "cloud-api",
- opencode: "cloud-api",
- openrouter: "router",
- ollama: "local",
- lmstudio: "local",
-};
-
-export const PROVIDER_CATEGORY_LABELS: Record = {
- "cloud-api": "Cloud",
- "local": "Local",
- "router": "Router",
-};
-
-export function getProviderCategory(family: string): ProviderCategory {
- return PROVIDER_CATEGORY_MAP[family] ?? "cloud-api";
-}
-
-export function sortOpenCodeProvidersByCategory(providers: ModelProviderBlock[]): {
- cloud: ModelProviderBlock[];
- local: ModelProviderBlock[];
- router: ModelProviderBlock[];
-} {
- const cloud: ModelProviderBlock[] = [];
- const local: ModelProviderBlock[] = [];
- const router: ModelProviderBlock[] = [];
- for (const p of providers) {
- const cat = getProviderCategory(p.key);
- if (cat === "local") local.push(p);
- else if (cat === "router") router.push(p);
- else cloud.push(p);
- }
- return { cloud, local, router };
-}
-
-export type ModelSubsection = {
- key: string;
- /** Human-readable subsection title (e.g. Cursor model family). Empty when a single default bucket. */
- label: string;
- models: ModelDescriptor[];
-};
-
-export type ModelProviderBlock = {
- key: string;
- label: string;
- badgeColor: string;
- subsections: ModelSubsection[];
- modelCount: number;
-};
-
-export type ModelProviderGroupBlock = {
- key: ProviderGroupKey;
- label: string;
- providers: ModelProviderBlock[];
-};
-
-const PROVIDER_LABELS: Record = {
- opencode: "OpenCode (Free)",
- anthropic: "Anthropic",
- openai: "OpenAI",
- cursor: "Cursor",
- factory: "Factory Droid",
- google: "Google",
- deepseek: "DeepSeek",
- mistral: "Mistral",
- xai: "xAI",
- openrouter: "OpenRouter",
- ollama: "Ollama",
- lmstudio: "LM Studio",
- groq: "Groq",
- together: "Together",
- meta: "Meta",
-};
-
-/** Brand colors for provider chips, row accents, and picker chrome. */
-export const PROVIDER_BADGE_COLORS: Record = {
- opencode: "#2563EB",
- anthropic: "#D97706",
- openai: "#10A37F",
- cursor: "#A78BFA",
- factory: "#6B7280",
- google: "#F59E0B",
- deepseek: "#3B82F6",
- mistral: "#F97316",
- xai: "#DC2626",
- openrouter: "#6B7280",
- ollama: "#71717A",
- lmstudio: "#64748B",
- groq: "#06B6D4",
- together: "#22C55E",
- meta: "#3B82F6",
-};
-
-/** Provider ordering within a group section. */
-export const PROVIDER_ORDER: string[] = [
- "opencode",
- "anthropic",
- "openai",
- "google",
- "deepseek",
- "mistral",
- "xai",
- "groq",
- "together",
- "openrouter",
- "ollama",
- "lmstudio",
- "cursor",
- "factory",
-];
-
-const PROVIDER_GROUP_ORDER: Record = {
- claude: 10,
- codex: 20,
- cursor: 30,
- droid: 35,
- opencode: 40,
-};
-
-/** Brand colors for the five top-level provider groups. */
-export const PROVIDER_GROUP_COLORS: Record = {
- claude: "#D97706",
- codex: "#10A37F",
- cursor: "#A78BFA",
- droid: "#6B7280",
- opencode: "#2563EB",
-};
-
-const CURSOR_SECTION_PREFIX = "__cursor_line__:";
-const DROID_SECTION_PREFIX = "__droid_line__:";
-const OPENCODE_PROVIDER_PREFIX = "__ocprov__:";
-
-export function providerLabel(family: string): string {
- return PROVIDER_LABELS[family] ?? family;
-}
-
-export function providerBadgeColor(provider: string, models: ModelDescriptor[]): string {
- return PROVIDER_BADGE_COLORS[provider] ?? models[0]?.color ?? "#A78BFA";
-}
-
-/** Classify a model into one of the five top-level provider groups. */
-export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey {
- if (model.family === "cursor") return "cursor";
- if (model.isCliWrapped) {
- if (model.family === "anthropic" || model.cliCommand === "claude") return "claude";
- if (model.family === "openai" || model.cliCommand === "codex") return "codex";
- if (model.family === "factory" || model.cliCommand === "droid") return "droid";
- }
- return "opencode";
-}
-
-export function providerGroupLabel(group: ProviderGroupKey): string {
- switch (group) {
- case "claude":
- return "Claude";
- case "codex":
- return "Codex";
- case "cursor":
- return "Cursor";
- case "droid":
- return "Droid";
- case "opencode":
- return "OpenCode";
- }
-}
-
-export function subsectionKeyForModel(model: ModelDescriptor, group: ProviderGroupKey): string {
- if (model.family === "cursor" && group === "cursor") {
- return `${CURSOR_SECTION_PREFIX}${cursorCliLineGroupFromSdkId(model.providerModelId)}`;
- }
- if (model.family === "factory" && group === "droid") {
- return `${DROID_SECTION_PREFIX}${droidCliLineGroupFromModelId(model.providerModelId)}`;
- }
- if (group === "opencode" && model.openCodeProviderId) {
- return `${OPENCODE_PROVIDER_PREFIX}${model.openCodeProviderId}`;
- }
- return "__default__";
-}
-
-export function subsectionLabel(family: string, key: string): string {
- if (key === "__default__") return "";
- if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) {
- const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length);
- return providerLabel(pid);
- }
- if (family === "cursor" && key.startsWith(CURSOR_SECTION_PREFIX)) {
- const group = key.slice(CURSOR_SECTION_PREFIX.length) as CursorCliLineGroup;
- return cursorCliLineGroupLabel(group);
- }
- if (family === "factory" && key.startsWith(DROID_SECTION_PREFIX)) {
- const group = key.slice(DROID_SECTION_PREFIX.length) as DroidCliLineGroup;
- return droidCliLineGroupLabel(group);
- }
- return "";
-}
-
-export function subsectionSortOrder(family: string, key: string): number {
- if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) {
- const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length);
- const index = PROVIDER_ORDER.indexOf(pid);
- return index === -1 ? PROVIDER_ORDER.length + 10 : index;
- }
- if (family === "cursor" && key.startsWith(CURSOR_SECTION_PREFIX)) {
- const group = key.slice(CURSOR_SECTION_PREFIX.length) as CursorCliLineGroup;
- const index = CURSOR_CLI_LINE_ORDER.indexOf(group);
- return index === -1 ? CURSOR_CLI_LINE_ORDER.length + 50 : index;
- }
- if (family === "factory" && key.startsWith(DROID_SECTION_PREFIX)) {
- const group = key.slice(DROID_SECTION_PREFIX.length) as DroidCliLineGroup;
- const index = DROID_CLI_LINE_ORDER.indexOf(group);
- return index === -1 ? DROID_CLI_LINE_ORDER.length + 50 : index;
- }
- return 0;
-}
-
-export function matchesQuery(model: ModelDescriptor, query: string): boolean {
- const normalized = query.trim().toLowerCase();
- if (!normalized.length) return true;
- return [
- model.displayName,
- model.id,
- model.shortId,
- model.providerModelId,
- model.openCodeProviderId ?? "",
- ...(model.aliases ?? []),
- ]
- .join(" ")
- .toLowerCase()
- .includes(normalized);
-}
-
-function sortModels(models: ModelDescriptor[], modelOrder: Map): ModelDescriptor[] {
- return [...models].sort((a, b) => {
- const oa = modelOrder.get(a.id);
- const ob = modelOrder.get(b.id);
- if (oa != null && ob != null && oa !== ob) return oa - ob;
- return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: "base" });
- });
-}
-
-function compareProviderKeys(a: string, b: string): number {
- const ia = PROVIDER_ORDER.indexOf(a);
- const ib = PROVIDER_ORDER.indexOf(b);
- return (ia === -1 ? Number.MAX_SAFE_INTEGER : ia) - (ib === -1 ? Number.MAX_SAFE_INTEGER : ib);
-}
-
-/** Fallback provider list when dynamic OpenCode provider data is unavailable. */
-export const OPENCODE_FALLBACK_PROVIDERS: string[] = [
- "opencode",
- "anthropic",
- "openai",
- "google",
- "deepseek",
- "mistral",
- "xai",
- "groq",
- "together",
- "openrouter",
- "ollama",
- "lmstudio",
-];
-
-/** Group models: provider group → provider family → subsection (Cursor lines, else single block). */
-export function buildProviderGroupBlocks(
- models: ModelDescriptor[],
- modelOrder: Map,
- opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>,
-): ModelProviderGroupBlock[] {
- const byGroup = new Map>>();
-
- for (const model of models) {
- const group = classifyProviderGroup(model);
- const family = model.family;
- const subKey = subsectionKeyForModel(model, group);
-
- let famMap = byGroup.get(group);
- if (!famMap) {
- famMap = new Map();
- byGroup.set(group, famMap);
- }
- let subMap = famMap.get(family);
- if (!subMap) {
- subMap = new Map();
- famMap.set(family, subMap);
- }
- const list = subMap.get(subKey) ?? [];
- list.push(model);
- subMap.set(subKey, list);
- }
-
- // Always ensure the opencode group exists so the OPENCODE tab is never disabled
- if (!byGroup.has("opencode")) {
- byGroup.set("opencode", new Map());
- }
-
- const groupKeys = [...byGroup.keys()].sort(
- (a, b) => PROVIDER_GROUP_ORDER[a] - PROVIDER_GROUP_ORDER[b],
- );
-
- const result: ModelProviderGroupBlock[] = [];
- for (const groupKey of groupKeys) {
- const famMap = byGroup.get(groupKey);
- if (!famMap) continue;
-
- const familyKeys = [...famMap.keys()].sort(compareProviderKeys);
- const providers: ModelProviderBlock[] = [];
-
- for (const family of familyKeys) {
- const subMap = famMap.get(family)!;
- const rawSubsections: ModelSubsection[] = [...subMap.entries()]
- .map(([key, ms]) => {
- return {
- key,
- label: subsectionLabel(family, key),
- models: sortModels(ms, modelOrder),
- };
- })
- .sort((a, b) => subsectionSortOrder(family, a.key) - subsectionSortOrder(family, b.key));
-
- // Merge all empty-label subsections into a single "Models" bucket to avoid
- // duplicate "Models (N)" tabs in the UI.
- const labeled: ModelSubsection[] = [];
- const unlabeledModels: ModelDescriptor[] = [];
- for (const sub of rawSubsections) {
- if (sub.label.trim() === "") {
- unlabeledModels.push(...sub.models);
- } else {
- labeled.push(sub);
- }
- }
- const subsections: ModelSubsection[] = unlabeledModels.length > 0
- ? [{ key: "__default__", label: "", models: sortModels(unlabeledModels, modelOrder) }, ...labeled]
- : labeled;
-
- const modelCount = subsections.reduce((acc, sub) => acc + sub.models.length, 0);
- providers.push({
- key: family,
- label: providerLabel(family),
- badgeColor: providerBadgeColor(family, subsections.flatMap((s) => s.models)),
- subsections,
- modelCount,
- });
- }
-
- // For the opencode group, inject empty provider blocks for providers that have no models yet.
- // Uses the dynamic list from OpenCode's provider.list() when available, falls back to a curated list.
- if (groupKey === "opencode") {
- const existingFamilies = new Set(providers.map((p) => p.key));
- const potentialProviders = opencodeProviders?.length
- ? opencodeProviders.map((p) => ({ id: p.id, name: p.name }))
- : OPENCODE_FALLBACK_PROVIDERS.map((id) => ({ id, name: providerLabel(id) }));
- for (const { id, name } of potentialProviders) {
- if (!existingFamilies.has(id)) {
- providers.push({
- key: id,
- label: PROVIDER_LABELS[id] ?? name,
- badgeColor: PROVIDER_BADGE_COLORS[id] ?? "#6B7280",
- subsections: [],
- modelCount: 0,
- });
- }
- }
- providers.sort((a, b) => compareProviderKeys(a.key, b.key));
- }
-
- result.push({
- key: groupKey,
- label: providerGroupLabel(groupKey),
- providers,
- });
- }
-
- return result;
-}
-
-export function createModelOrderMap(): Map {
- return new Map(MODEL_REGISTRY.map((model, index) => [model.id, index]));
-}
+export * from "../../../shared/modelCatalog";
diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx
index ba3102650..76effdb04 100644
--- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx
+++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx
@@ -118,10 +118,9 @@ export const SessionCard = React.memo(function SessionCard({
/>
{primaryText}
diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
index 530e6c271..b464dd035 100644
--- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
+++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
@@ -110,6 +110,28 @@ describe("SessionListPane", () => {
expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy();
});
+ it("bolds only the session name in sidebar cards", () => {
+ const session = makeSession({
+ id: "session-style",
+ laneId: "lane-known",
+ laneName: "Known Lane",
+ title: "Style target session",
+ lastOutputPreview: "Ran the latest command",
+ });
+ renderPane({
+ runningFiltered: [session],
+ sessionsGroupedByLane: new Map([[session.laneId, [session]]]),
+ });
+
+ const title = screen.getByText("Style target session");
+ const row = title.closest("button");
+ expect(row).toBeTruthy();
+
+ expect(title.className).toContain("font-semibold");
+ expect(within(row!).getByText("Codex").className).not.toContain("font-semibold");
+ expect(within(row!).getByText("Ran the latest command").className).not.toContain("font-semibold");
+ });
+
it("marks old running CLI and shell sessions", () => {
const staleSession = makeSession({
id: "session-stale-shell",
diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts
index da363e829..273c942f7 100644
--- a/apps/desktop/src/renderer/lib/sessions.ts
+++ b/apps/desktop/src/renderer/lib/sessions.ts
@@ -1,6 +1,7 @@
/** Shared session/terminal utilities for the renderer. */
import type { AgentChatProvider, AgentChatSession, TerminalSessionSummary, TerminalToolType } from "../../shared/types";
+import { isProviderSlashCommandInput } from "../../shared/chatSlashCommands";
/** Returns true if the tool type represents an AI chat session. */
export function isChatToolType(toolType: string | null | undefined): boolean {
@@ -171,6 +172,7 @@ export function isLowSignalSessionLabel(raw: string | null | undefined): boolean
.toLowerCase();
if (!collapsed.length) return true;
+ if (isProviderSlashCommandInput(normalized)) return true;
if (/\b(error|exception|apicall|traceback|stack\s*trace)\b/i.test(collapsed)) return true;
if (/^(session closed|chat completed)\b/u.test(collapsed)) return true;
diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts
new file mode 100644
index 000000000..fb0664773
--- /dev/null
+++ b/apps/desktop/src/shared/modelCatalog.ts
@@ -0,0 +1,374 @@
+import {
+ CURSOR_CLI_LINE_ORDER,
+ DROID_CLI_LINE_ORDER,
+ MODEL_REGISTRY,
+ cursorCliLineGroupFromSdkId,
+ cursorCliLineGroupLabel,
+ droidCliLineGroupFromModelId,
+ droidCliLineGroupLabel,
+ type CursorCliLineGroup,
+ type DroidCliLineGroup,
+ type ModelDescriptor,
+ type ModelProviderGroup,
+} from "./modelRegistry";
+
+export type ProviderGroupKey = ModelProviderGroup;
+
+export type ProviderCategory = "cloud-api" | "local" | "router";
+
+export const PROVIDER_CATEGORY_MAP: Record = {
+ anthropic: "cloud-api",
+ openai: "cloud-api",
+ google: "cloud-api",
+ deepseek: "cloud-api",
+ mistral: "cloud-api",
+ xai: "cloud-api",
+ groq: "cloud-api",
+ together: "cloud-api",
+ opencode: "cloud-api",
+ openrouter: "router",
+ ollama: "local",
+ lmstudio: "local",
+};
+
+export const PROVIDER_CATEGORY_LABELS: Record = {
+ "cloud-api": "Cloud",
+ local: "Local",
+ router: "Router",
+};
+
+export function getProviderCategory(family: string): ProviderCategory {
+ return PROVIDER_CATEGORY_MAP[family] ?? "cloud-api";
+}
+
+export type ModelSubsection = {
+ key: string;
+ label: string;
+ models: ModelDescriptor[];
+};
+
+export type ModelProviderBlock = {
+ key: string;
+ label: string;
+ badgeColor: string;
+ subsections: ModelSubsection[];
+ modelCount: number;
+};
+
+export type ModelProviderGroupBlock = {
+ key: ProviderGroupKey;
+ label: string;
+ providers: ModelProviderBlock[];
+};
+
+const PROVIDER_LABELS: Record = {
+ opencode: "OpenCode (Free)",
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ cursor: "Cursor",
+ factory: "Factory Droid",
+ google: "Google",
+ deepseek: "DeepSeek",
+ mistral: "Mistral",
+ xai: "xAI",
+ openrouter: "OpenRouter",
+ ollama: "Ollama",
+ lmstudio: "LM Studio",
+ groq: "Groq",
+ together: "Together",
+ meta: "Meta",
+};
+
+export const PROVIDER_BADGE_COLORS: Record = {
+ opencode: "#2563EB",
+ anthropic: "#D97706",
+ openai: "#10A37F",
+ cursor: "#A78BFA",
+ factory: "#6B7280",
+ google: "#F59E0B",
+ deepseek: "#3B82F6",
+ mistral: "#F97316",
+ xai: "#DC2626",
+ openrouter: "#6B7280",
+ ollama: "#71717A",
+ lmstudio: "#64748B",
+ groq: "#06B6D4",
+ together: "#22C55E",
+ meta: "#3B82F6",
+};
+
+export const PROVIDER_ORDER: string[] = [
+ "opencode",
+ "anthropic",
+ "openai",
+ "google",
+ "deepseek",
+ "mistral",
+ "xai",
+ "groq",
+ "together",
+ "openrouter",
+ "ollama",
+ "lmstudio",
+ "cursor",
+ "factory",
+];
+
+const PROVIDER_GROUP_ORDER: Record = {
+ claude: 10,
+ codex: 20,
+ cursor: 30,
+ droid: 35,
+ opencode: 40,
+};
+
+export const PROVIDER_GROUP_COLORS: Record = {
+ claude: "#D97706",
+ codex: "#10A37F",
+ cursor: "#A78BFA",
+ droid: "#6B7280",
+ opencode: "#2563EB",
+};
+
+const CURSOR_SECTION_PREFIX = "__cursor_line__:";
+const DROID_SECTION_PREFIX = "__droid_line__:";
+const OPENCODE_PROVIDER_PREFIX = "__ocprov__:";
+
+export function providerLabel(family: string): string {
+ return PROVIDER_LABELS[family] ?? family;
+}
+
+export function providerBadgeColor(provider: string, models: ModelDescriptor[]): string {
+ return PROVIDER_BADGE_COLORS[provider] ?? models[0]?.color ?? "#A78BFA";
+}
+
+export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey {
+ if (model.family === "cursor") return "cursor";
+ if (model.isCliWrapped) {
+ if (model.family === "anthropic" || model.cliCommand === "claude") return "claude";
+ if (model.family === "openai" || model.cliCommand === "codex") return "codex";
+ if (model.family === "factory" || model.cliCommand === "droid") return "droid";
+ }
+ return "opencode";
+}
+
+export function providerGroupLabel(group: ProviderGroupKey): string {
+ switch (group) {
+ case "claude":
+ return "Claude";
+ case "codex":
+ return "Codex";
+ case "cursor":
+ return "Cursor";
+ case "droid":
+ return "Droid";
+ case "opencode":
+ return "OpenCode";
+ }
+}
+
+export function subsectionKeyForModel(model: ModelDescriptor, group: ProviderGroupKey): string {
+ if (model.family === "cursor" && group === "cursor") {
+ return `${CURSOR_SECTION_PREFIX}${cursorCliLineGroupFromSdkId(model.providerModelId)}`;
+ }
+ if (model.family === "factory" && group === "droid") {
+ return `${DROID_SECTION_PREFIX}${droidCliLineGroupFromModelId(model.providerModelId)}`;
+ }
+ if (group === "opencode" && model.openCodeProviderId) {
+ return `${OPENCODE_PROVIDER_PREFIX}${model.openCodeProviderId}`;
+ }
+ return "__default__";
+}
+
+export function subsectionLabel(family: string, key: string): string {
+ if (key === "__default__") return "";
+ if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) {
+ const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length);
+ return providerLabel(pid);
+ }
+ if (family === "cursor" && key.startsWith(CURSOR_SECTION_PREFIX)) {
+ const group = key.slice(CURSOR_SECTION_PREFIX.length) as CursorCliLineGroup;
+ return cursorCliLineGroupLabel(group);
+ }
+ if (family === "factory" && key.startsWith(DROID_SECTION_PREFIX)) {
+ const group = key.slice(DROID_SECTION_PREFIX.length) as DroidCliLineGroup;
+ return droidCliLineGroupLabel(group);
+ }
+ return "";
+}
+
+export function subsectionSortOrder(family: string, key: string): number {
+ if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) {
+ const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length);
+ const index = PROVIDER_ORDER.indexOf(pid);
+ return index === -1 ? PROVIDER_ORDER.length + 10 : index;
+ }
+ if (family === "cursor" && key.startsWith(CURSOR_SECTION_PREFIX)) {
+ const group = key.slice(CURSOR_SECTION_PREFIX.length) as CursorCliLineGroup;
+ const index = CURSOR_CLI_LINE_ORDER.indexOf(group);
+ return index === -1 ? CURSOR_CLI_LINE_ORDER.length + 50 : index;
+ }
+ if (family === "factory" && key.startsWith(DROID_SECTION_PREFIX)) {
+ const group = key.slice(DROID_SECTION_PREFIX.length) as DroidCliLineGroup;
+ const index = DROID_CLI_LINE_ORDER.indexOf(group);
+ return index === -1 ? DROID_CLI_LINE_ORDER.length + 50 : index;
+ }
+ return 0;
+}
+
+export function matchesQuery(model: ModelDescriptor, query: string): boolean {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized.length) return true;
+ return [
+ model.displayName,
+ model.id,
+ model.shortId,
+ model.providerModelId,
+ model.openCodeProviderId ?? "",
+ ...(model.aliases ?? []),
+ ]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalized);
+}
+
+function sortModels(models: ModelDescriptor[], modelOrder: Map): ModelDescriptor[] {
+ return [...models].sort((a, b) => {
+ const oa = modelOrder.get(a.id);
+ const ob = modelOrder.get(b.id);
+ if (oa != null && ob != null && oa !== ob) return oa - ob;
+ return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: "base" });
+ });
+}
+
+function compareProviderKeys(a: string, b: string): number {
+ const ia = PROVIDER_ORDER.indexOf(a);
+ const ib = PROVIDER_ORDER.indexOf(b);
+ return (ia === -1 ? Number.MAX_SAFE_INTEGER : ia) - (ib === -1 ? Number.MAX_SAFE_INTEGER : ib);
+}
+
+export const OPENCODE_FALLBACK_PROVIDERS: string[] = [
+ "opencode",
+ "anthropic",
+ "openai",
+ "google",
+ "deepseek",
+ "mistral",
+ "xai",
+ "groq",
+ "together",
+ "openrouter",
+ "ollama",
+ "lmstudio",
+];
+
+export function sortOpenCodeProvidersByCategory(providers: ModelProviderBlock[]): {
+ cloud: ModelProviderBlock[];
+ local: ModelProviderBlock[];
+ router: ModelProviderBlock[];
+} {
+ const cloud: ModelProviderBlock[] = [];
+ const local: ModelProviderBlock[] = [];
+ const router: ModelProviderBlock[] = [];
+ for (const p of providers) {
+ const cat = getProviderCategory(p.key);
+ if (cat === "local") local.push(p);
+ else if (cat === "router") router.push(p);
+ else cloud.push(p);
+ }
+ return { cloud, local, router };
+}
+
+export function buildProviderGroupBlocks(
+ models: ModelDescriptor[],
+ modelOrder: Map,
+ opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>,
+): ModelProviderGroupBlock[] {
+ const byGroup = new Map>>();
+
+ for (const model of models) {
+ const group = classifyProviderGroup(model);
+ const family = model.family;
+ const subKey = subsectionKeyForModel(model, group);
+ let famMap = byGroup.get(group);
+ if (!famMap) {
+ famMap = new Map();
+ byGroup.set(group, famMap);
+ }
+ let subMap = famMap.get(family);
+ if (!subMap) {
+ subMap = new Map();
+ famMap.set(family, subMap);
+ }
+ const list = subMap.get(subKey) ?? [];
+ list.push(model);
+ subMap.set(subKey, list);
+ }
+
+ if (!byGroup.has("opencode")) {
+ byGroup.set("opencode", new Map());
+ }
+
+ const result: ModelProviderGroupBlock[] = [];
+ const groupKeys = [...byGroup.keys()].sort((a, b) => PROVIDER_GROUP_ORDER[a] - PROVIDER_GROUP_ORDER[b]);
+ for (const groupKey of groupKeys) {
+ const famMap = byGroup.get(groupKey);
+ if (!famMap) continue;
+ const providers: ModelProviderBlock[] = [];
+ for (const family of [...famMap.keys()].sort(compareProviderKeys)) {
+ const subMap = famMap.get(family)!;
+ const rawSubsections: ModelSubsection[] = [...subMap.entries()]
+ .map(([key, ms]) => ({
+ key,
+ label: subsectionLabel(family, key),
+ models: sortModels(ms, modelOrder),
+ }))
+ .sort((a, b) => subsectionSortOrder(family, a.key) - subsectionSortOrder(family, b.key));
+
+ const labeled: ModelSubsection[] = [];
+ const unlabeledModels: ModelDescriptor[] = [];
+ for (const sub of rawSubsections) {
+ if (sub.label.trim() === "") unlabeledModels.push(...sub.models);
+ else labeled.push(sub);
+ }
+ const subsections: ModelSubsection[] = unlabeledModels.length > 0
+ ? [{ key: "__default__", label: "", models: sortModels(unlabeledModels, modelOrder) }, ...labeled]
+ : labeled;
+ const modelCount = subsections.reduce((acc, sub) => acc + sub.models.length, 0);
+ providers.push({
+ key: family,
+ label: providerLabel(family),
+ badgeColor: providerBadgeColor(family, subsections.flatMap((s) => s.models)),
+ subsections,
+ modelCount,
+ });
+ }
+
+ if (groupKey === "opencode") {
+ const existingFamilies = new Set(providers.map((p) => p.key));
+ const potentialProviders = opencodeProviders?.length
+ ? opencodeProviders.map((p) => ({ id: p.id, name: p.name }))
+ : OPENCODE_FALLBACK_PROVIDERS.map((id) => ({ id, name: providerLabel(id) }));
+ for (const { id, name } of potentialProviders) {
+ if (!existingFamilies.has(id)) {
+ providers.push({
+ key: id,
+ label: PROVIDER_LABELS[id] ?? name,
+ badgeColor: PROVIDER_BADGE_COLORS[id] ?? "#6B7280",
+ subsections: [],
+ modelCount: 0,
+ });
+ existingFamilies.add(id);
+ }
+ }
+ providers.sort((a, b) => compareProviderKeys(a.key, b.key));
+ }
+
+ result.push({ key: groupKey, label: providerGroupLabel(groupKey), providers });
+ }
+ return result;
+}
+
+export function createModelOrderMap(): Map {
+ return new Map(MODEL_REGISTRY.map((model, index) => [model.id, index]));
+}
diff --git a/apps/desktop/src/shared/modelProfiles.test.ts b/apps/desktop/src/shared/modelProfiles.test.ts
index 959776c08..1819b1538 100644
--- a/apps/desktop/src/shared/modelProfiles.test.ts
+++ b/apps/desktop/src/shared/modelProfiles.test.ts
@@ -333,8 +333,8 @@ describe("thinkingLevelToReasoningEffort", () => {
expect(thinkingLevelToReasoningEffort("minimal")).toBe("low");
});
- it("maps 'max' to 'high'", () => {
- expect(thinkingLevelToReasoningEffort("max")).toBe("high");
+ it("maps 'max' to 'max'", () => {
+ expect(thinkingLevelToReasoningEffort("max")).toBe("max");
});
it("returns 'low' for null", () => {
diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts
index 31829c95e..9d21761ac 100644
--- a/apps/desktop/src/shared/modelProfiles.ts
+++ b/apps/desktop/src/shared/modelProfiles.ts
@@ -287,7 +287,6 @@ export function modelConfigToServiceModel(config: ModelConfig): string {
/** Convert ThinkingLevel to reasoning effort string for AI service */
export function thinkingLevelToReasoningEffort(level?: ThinkingLevel | null): string {
if (!level || level === "none" || level === "minimal") return "low";
- if (level === "max") return "high";
return level;
}
diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts
index a7d25e1c2..c1fdbbd8f 100644
--- a/apps/desktop/src/shared/modelRegistry.test.ts
+++ b/apps/desktop/src/shared/modelRegistry.test.ts
@@ -85,6 +85,8 @@ describe("modelRegistry", () => {
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-5.3-codex",
+ "openai/gpt-5.3-codex-spark",
+ "openai/gpt-5.2",
]);
// API-key OpenAI models are now discovered dynamically through OpenCode,
@@ -109,6 +111,32 @@ describe("modelRegistry", () => {
});
});
+ it("exposes GPT-5.3-Codex-Spark as a Codex CLI model", () => {
+ expect(getModelById("openai/gpt-5.3-codex-spark")).toMatchObject({
+ displayName: "GPT-5.3-Codex-Spark",
+ providerRoute: "codex-cli",
+ providerModelId: "gpt-5.3-codex-spark",
+ cliCommand: "codex",
+ isCliWrapped: true,
+ family: "openai",
+ contextWindow: 128_000,
+ capabilities: expect.objectContaining({ vision: false, reasoning: true }),
+ });
+ expect(resolveModelAlias("spark")?.id).toBe("openai/gpt-5.3-codex-spark");
+ });
+
+ it("exposes GPT-5.2 as a Codex CLI model", () => {
+ expect(getModelById("openai/gpt-5.2")).toMatchObject({
+ displayName: "GPT-5.2",
+ providerRoute: "codex-cli",
+ providerModelId: "gpt-5.2",
+ cliCommand: "codex",
+ isCliWrapped: true,
+ family: "openai",
+ });
+ expect(resolveModelAlias("gpt-5.2-codex")?.id).toBe("openai/gpt-5.2");
+ });
+
it("marks CLI-wrapped models as CLI subscription in the shared model source helper", () => {
expect(describeModelSource(getModelById("openai/gpt-5.5")!)).toBe("CLI subscription");
});
@@ -179,11 +207,12 @@ describe("modelRegistry", () => {
family: "anthropic",
providerRoute: "claude-cli",
providerModelId: "claude-opus-4-7",
- contextWindow: 1_000_000,
+ contextWindow: 200_000,
maxOutputTokens: 128_000,
inputPricePer1M: 5,
outputPricePer1M: 25,
});
+ expect(opus?.reasoningTiers).toEqual(["low", "medium", "high", "xhigh", "max"]);
});
it("exposes the 1M Opus 4.7 variant with the xhigh reasoning tier and legacy aliases", () => {
diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts
index 3e62edde3..e1b46b3f2 100644
--- a/apps/desktop/src/shared/modelRegistry.ts
+++ b/apps/desktop/src/shared/modelRegistry.ts
@@ -107,7 +107,8 @@ const LOCAL_PROVIDER_ENDPOINTS: Record = {
export const MODEL_REGISTRY: ModelDescriptor[] = [
// ---- Anthropic (CLI-wrapped via claude) ----
- // Claude chat surfaces in ADE use the native low/medium/high effort ladder.
+ // Claude chat surfaces use the native Agent SDK effort ladder. Keep these
+ // tiers aligned with the launch validation path.
{
id: "anthropic/claude-opus-4-7",
shortId: "opus",
@@ -115,10 +116,10 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [
displayName: "Claude Opus 4.7",
family: "anthropic",
authTypes: ["cli-subscription"],
- contextWindow: 1_000_000,
+ contextWindow: 200_000,
maxOutputTokens: 128_000,
capabilities: ALL_CAPS,
- reasoningTiers: ["low", "medium", "high", "max"],
+ reasoningTiers: ["low", "medium", "high", "xhigh", "max"],
color: "#D97706",
providerRoute: "claude-cli",
providerModelId: "claude-opus-4-7",
@@ -255,6 +256,7 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [
{
id: "openai/gpt-5.3-codex",
shortId: "gpt-5.3-codex",
+ aliases: ["gpt-5.3"],
displayName: "GPT-5.3-Codex",
family: "openai",
authTypes: ["cli-subscription"],
@@ -271,6 +273,42 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [
outputPricePer1M: 6,
costTier: "high",
},
+ {
+ id: "openai/gpt-5.3-codex-spark",
+ shortId: "gpt-5.3-codex-spark",
+ aliases: ["gpt-5.3-spark", "codex-spark", "spark"],
+ displayName: "GPT-5.3-Codex-Spark",
+ family: "openai",
+ authTypes: ["cli-subscription"],
+ contextWindow: 128_000,
+ maxOutputTokens: 128_000,
+ capabilities: { tools: true, vision: false, reasoning: true, streaming: true },
+ reasoningTiers: ["low", "medium", "high", "xhigh"],
+ color: "#22C55E",
+ providerRoute: "codex-cli",
+ providerModelId: "gpt-5.3-codex-spark",
+ cliCommand: "codex",
+ isCliWrapped: true,
+ costTier: "low",
+ },
+ {
+ id: "openai/gpt-5.2",
+ shortId: "gpt-5.2",
+ aliases: ["openai/gpt-5.2-codex", "gpt-5.2-codex"],
+ displayName: "GPT-5.2",
+ family: "openai",
+ authTypes: ["cli-subscription"],
+ contextWindow: 400_000,
+ maxOutputTokens: 128_000,
+ capabilities: ALL_CAPS,
+ reasoningTiers: ["low", "medium", "high", "xhigh"],
+ color: "#059669",
+ providerRoute: "codex-cli",
+ providerModelId: "gpt-5.2",
+ cliCommand: "codex",
+ isCliWrapped: true,
+ costTier: "medium",
+ },
// ---- Cursor SDK models: discovered at runtime via @cursor/sdk (see cursorModelsDiscovery + getResolvedAvailableModels) ----
@@ -551,7 +589,7 @@ export function createDynamicOpenCodeModelDescriptor(
const aliases = options?.aliases?.map((alias) => alias.trim()).filter(Boolean) ?? [];
const family: ProviderFamily = (opPid && OPENCODE_PROVIDER_FAMILY_MAP[opPid]) || "opencode";
const isLocal = opPid ? LOCAL_OPENCODE_PROVIDERS.has(opPid) : false;
- const authTypes: AuthType[] = isLocal ? ["local"] : ["api-key"];
+ const authTypes: AuthType[] = isLocal ? ["local"] : opPid === "openrouter" ? ["openrouter"] : ["api-key"];
const color = options?.color ?? (opPid && OPENCODE_PROVIDER_COLORS[opPid]) ?? "#2563EB";
return {
id,
diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts
index 4adcf1175..edd1cfdce 100644
--- a/apps/desktop/src/shared/types/chat.ts
+++ b/apps/desktop/src/shared/types/chat.ts
@@ -652,6 +652,42 @@ export type AgentChatModelInfo = {
color?: string;
};
+export type AgentChatModelCatalogModel = AgentChatModelInfo & {
+ /** Canonical ADE registry id used for update/create calls. */
+ id: ModelId;
+ /** Provider/runtime model ref ADE sends under the hood. */
+ runtimeModelId: string;
+ provider: AgentChatProvider;
+ providerKey: string;
+ groupKey: AgentChatProvider;
+ isAvailable: boolean;
+};
+
+export type AgentChatModelCatalogSubsection = {
+ key: string;
+ label: string;
+ models: AgentChatModelCatalogModel[];
+};
+
+export type AgentChatModelCatalogProvider = {
+ key: string;
+ displayName: string;
+ badgeColor: string;
+ modelCount: number;
+ subsections: AgentChatModelCatalogSubsection[];
+};
+
+export type AgentChatModelCatalogGroup = {
+ key: AgentChatProvider;
+ displayName: string;
+ providers: AgentChatModelCatalogProvider[];
+};
+
+export type AgentChatModelCatalog = {
+ groups: AgentChatModelCatalogGroup[];
+ fetchedAt: string;
+};
+
export type AgentChatCreateArgs = {
laneId: string;
provider: AgentChatProvider;
diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts
index bbbd30ab8..4562db567 100644
--- a/apps/desktop/src/shared/types/config.ts
+++ b/apps/desktop/src/shared/types/config.ts
@@ -1192,6 +1192,9 @@ export type AiSettingsStatus = {
opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>;
apiKeyStore?: {
secureStorageAvailable: boolean;
+ macosKeychainAvailable?: boolean;
+ macosKeychainService?: string | null;
+ macosKeychainError?: string | null;
legacyPlaintextDetected: boolean;
decryptionFailed: boolean;
encryptedStorePath?: string | null;
diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts
index a0bc02353..96d02386e 100644
--- a/apps/desktop/src/shared/types/sync.ts
+++ b/apps/desktop/src/shared/types/sync.ts
@@ -565,6 +565,7 @@ export type SyncRemoteCommandAction =
| "chat.unarchive"
| "chat.delete"
| "chat.models"
+ | "chat.modelCatalog"
| "cto.getRoster"
| "cto.ensureSession"
| "cto.ensureAgentSession"
diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift
index db7b66837..1812ff6b6 100644
--- a/apps/ios/ADE/Models/RemoteModels.swift
+++ b/apps/ios/ADE/Models/RemoteModels.swift
@@ -1850,7 +1850,7 @@ struct AgentChatTranscriptResponse: Codable, Equatable {
var totalEntries: Int
}
-struct AgentChatModelReasoningEffort: Codable, Equatable, Identifiable {
+struct AgentChatModelReasoningEffort: Codable, Equatable, Hashable, Identifiable {
var id: String { effort }
var effort: String
var description: String
@@ -1870,6 +1870,53 @@ struct AgentChatModelInfo: Codable, Equatable, Identifiable {
var color: String?
}
+struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable {
+ var id: String
+ var runtimeModelId: String
+ var provider: String
+ var providerKey: String
+ var groupKey: String
+ var displayName: String
+ var description: String?
+ var isDefault: Bool
+ var reasoningEfforts: [AgentChatModelReasoningEffort]?
+ var maxThinkingTokens: Int?
+ var modelId: String?
+ var family: String?
+ var supportsReasoning: Bool?
+ var supportsTools: Bool?
+ var color: String?
+ var isAvailable: Bool
+}
+
+struct AgentChatModelCatalogSubsection: Codable, Equatable, Identifiable {
+ var id: String { key }
+ var key: String
+ var label: String
+ var models: [AgentChatModelCatalogModel]
+}
+
+struct AgentChatModelCatalogProvider: Codable, Equatable, Identifiable {
+ var id: String { key }
+ var key: String
+ var displayName: String
+ var badgeColor: String
+ var modelCount: Int
+ var subsections: [AgentChatModelCatalogSubsection]
+}
+
+struct AgentChatModelCatalogGroup: Codable, Equatable, Identifiable {
+ var id: String { key }
+ var key: String
+ var displayName: String
+ var providers: [AgentChatModelCatalogProvider]
+}
+
+struct AgentChatModelCatalog: Codable, Equatable {
+ var groups: [AgentChatModelCatalogGroup]
+ var fetchedAt: String
+}
+
struct LaneListSnapshot: Codable, Identifiable, Equatable {
var id: String { lane.id }
var lane: LaneSummary
diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift
index aff46579f..4581b9446 100644
--- a/apps/ios/ADE/Services/SyncService.swift
+++ b/apps/ios/ADE/Services/SyncService.swift
@@ -668,6 +668,12 @@ final class SyncService: ObservableObject {
private let chatModelsCacheTTL: TimeInterval = 300
private var chatModelsCache: [String: ChatModelsCacheEntry] = [:]
private var chatModelsInFlight: [String: Task<[AgentChatModelInfo], Error>] = [:]
+ private struct ChatModelCatalogCacheEntry {
+ var catalog: AgentChatModelCatalog
+ var fetchedAt: Date
+ }
+ private var chatModelCatalogCache: [String: ChatModelCatalogCacheEntry] = [:]
+ private var chatModelCatalogInFlight: [String: Task] = [:]
private let legacyDraftKey = "ade.sync.connectionDraft"
private let profileKey = "ade.sync.hostProfile"
@@ -3244,6 +3250,7 @@ final class SyncService: ObservableObject {
func rebaseContinueGit(laneId: String) async throws { _ = try await sendCommand(action: "git.rebaseContinue", args: ["laneId": laneId]) }
func rebaseAbortGit(laneId: String) async throws { _ = try await sendCommand(action: "git.rebaseAbort", args: ["laneId": laneId]) }
+ @MainActor
func listChatModels(provider: String) async throws -> [AgentChatModelInfo] {
let normalizedProvider = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let cacheKey = chatModelsCacheKey(provider: normalizedProvider)
@@ -3282,6 +3289,52 @@ final class SyncService: ObservableObject {
}
}
+ @MainActor
+ func cachedChatModelCatalog() -> AgentChatModelCatalog? {
+ let cacheKey = chatModelsCacheKey(provider: "catalog")
+ guard let cached = chatModelCatalogCache[cacheKey] else { return nil }
+ guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil }
+ return cached.catalog
+ }
+
+ @MainActor
+ func getChatModelCatalog() async throws -> AgentChatModelCatalog {
+ let cacheKey = chatModelsCacheKey(provider: "catalog")
+ let now = Date()
+
+ if let cached = chatModelCatalogCache[cacheKey],
+ now.timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL {
+ return cached.catalog
+ }
+
+ if let task = chatModelCatalogInFlight[cacheKey] {
+ return try await task.value
+ }
+
+ let task = Task { @MainActor [weak self] in
+ guard let self else { throw CancellationError() }
+ return try await self.sendDecodableCommand(
+ action: "chat.modelCatalog",
+ args: [:],
+ as: AgentChatModelCatalog.self
+ )
+ }
+ chatModelCatalogInFlight[cacheKey] = task
+
+ do {
+ let catalog = try await task.value
+ chatModelCatalogCache[cacheKey] = ChatModelCatalogCacheEntry(catalog: catalog, fetchedAt: now)
+ chatModelCatalogInFlight[cacheKey] = nil
+ return catalog
+ } catch {
+ chatModelCatalogInFlight[cacheKey] = nil
+ if let cached = chatModelCatalogCache[cacheKey] {
+ return cached.catalog
+ }
+ throw error
+ }
+ }
+
private func chatModelsCacheKey(provider: String) -> String {
let profile = activeHostProfile
let hostKey = profile?.hostIdentity
diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift
index 5920f1717..edc5e8b31 100644
--- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift
+++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift
@@ -478,7 +478,7 @@ struct WorkChatSessionView: View {
Task { @MainActor in
modelUpdateInFlight = true
defer { modelUpdateInFlight = false }
- let wasCurrentModel = option.id == currentModelId
+ let wasCurrentModel = workModelIdsEquivalent(option.id, currentModelId)
if !wasCurrentModel {
await onSelectModel(option.id)
}
diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift
index dbe9fd004..4d79e0f3e 100644
--- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift
+++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift
@@ -17,6 +17,25 @@ struct WorkModelOption: Identifiable, Hashable {
/// (e.g. "claude" for the CLAUDE brand avatar). For OpenCode-routed
/// models this is still the upstream family so the logo stays brand-true.
let provider: String
+ /// Reasoning efforts supplied by the paired desktop host. Empty means the
+ /// host did not advertise a selectable reasoning control for this model.
+ let reasoningEfforts: [AgentChatModelReasoningEffort]
+
+ init(
+ id: String,
+ displayName: String,
+ tier: Tier,
+ tagline: String,
+ provider: String,
+ reasoningEfforts: [AgentChatModelReasoningEffort] = []
+ ) {
+ self.id = id
+ self.displayName = displayName
+ self.tier = tier
+ self.tagline = tagline
+ self.provider = provider
+ self.reasoningEfforts = reasoningEfforts
+ }
}
extension WorkModelOption {
@@ -114,6 +133,8 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] {
WorkModelOption(id: "gpt-5.4", displayName: "GPT-5.4", tier: .flagship, tagline: "Affordable · 1M context", provider: "codex"),
WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4-Mini", tier: .fast, tagline: "Cheaper 1M-context variant", provider: "codex"),
WorkModelOption(id: "gpt-5.3-codex", displayName: "GPT-5.3-Codex", tier: .balanced, tagline: "Tuned for code edits", provider: "codex"),
+ WorkModelOption(id: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark", tier: .fast, tagline: "Text-only Codex fast path", provider: "codex"),
+ WorkModelOption(id: "gpt-5.2", displayName: "GPT-5.2", tier: .balanced, tagline: "Prior-gen GPT-5", provider: "codex"),
]
)
]
@@ -336,6 +357,80 @@ func workModelCatalogGroups(
)
}
+func workModelCatalogGroups(
+ hostCatalog: AgentChatModelCatalog,
+ currentModelId: String,
+ currentProvider: String
+) -> [WorkModelCatalogGroup] {
+ let groups = hostCatalog.groups.map { group in
+ WorkModelCatalogGroup(
+ key: group.key,
+ displayName: group.displayName,
+ providers: group.providers.map { provider in
+ let models = provider.subsections
+ .flatMap(\.models)
+ .filter(\.isAvailable)
+ .map { model in
+ workCatalogModelOption(
+ from: model,
+ topLevelProvider: group.key,
+ providerKey: provider.key
+ )
+ }
+ return WorkModelProvider(
+ key: provider.key,
+ displayName: provider.displayName,
+ models: workDeduplicatedModelOptions(models)
+ )
+ }
+ .filter { !$0.models.isEmpty || group.key == "opencode" }
+ )
+ }
+ .filter { !$0.providers.isEmpty }
+
+ return injectCurrentWorkModelIfNeeded(
+ into: groups,
+ currentModelId: currentModelId,
+ currentProvider: currentProvider
+ )
+}
+
+private func workCatalogModelOption(
+ from model: AgentChatModelCatalogModel,
+ topLevelProvider: String,
+ providerKey: String
+) -> WorkModelOption {
+ let displayName = model.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? model.id
+ : model.displayName
+ let trimmedDescription = model.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let tagline: String
+ if !trimmedDescription.isEmpty, trimmedDescription.localizedCaseInsensitiveCompare(displayName) != .orderedSame {
+ tagline = trimmedDescription
+ } else {
+ var parts: [String] = []
+ if model.isDefault {
+ parts.append("Default on the paired host")
+ }
+ if model.supportsReasoning == true {
+ parts.append("Reasoning")
+ }
+ if model.supportsTools == true {
+ parts.append("Tools")
+ }
+ tagline = parts.isEmpty ? "Available on the paired host" : parts.joined(separator: " · ")
+ }
+
+ return WorkModelOption(
+ id: model.id,
+ displayName: displayName,
+ tier: workDynamicModelTier(for: model.id),
+ tagline: tagline,
+ provider: workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey),
+ reasoningEfforts: model.reasoningEfforts ?? []
+ )
+}
+
private func workCuratedModelLookup(from groups: [WorkModelCatalogGroup]) -> [String: WorkModelOption] {
var lookup: [String: WorkModelOption] = [:]
for group in groups {
@@ -378,9 +473,15 @@ private func workModelLookupKeys(_ raw: String?) -> [String] {
}
append(trimmed)
+ if let registryId = workCanonicalClaudeRegistryId(for: trimmed) {
+ append(registryId)
+ }
if let registryId = workCanonicalCodexRegistryId(for: trimmed) {
append(registryId)
}
+ if let runtimeId = workClaudeRuntimeModelId(for: trimmed) {
+ append(runtimeId)
+ }
if let runtimeId = workCodexRuntimeModelId(for: trimmed) {
append(runtimeId)
}
@@ -394,6 +495,36 @@ private func workModelLookupKeys(_ raw: String?) -> [String] {
return keys
}
+private func workCanonicalClaudeRegistryId(for raw: String) -> String? {
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "opus", "claude-opus-4-7", "anthropic/claude-opus-4-7":
+ return "anthropic/claude-opus-4-7"
+ case "opus[1m]", "opus-1m", "claude-opus-4-7-1m", "claude-opus-4-7[1m]", "anthropic/claude-opus-4-7-1m":
+ return "anthropic/claude-opus-4-7-1m"
+ case "sonnet", "claude-sonnet-4-6", "anthropic/claude-sonnet-4-6":
+ return "anthropic/claude-sonnet-4-6"
+ case "haiku", "claude-haiku-4-5", "anthropic/claude-haiku-4-5":
+ return "anthropic/claude-haiku-4-5"
+ default:
+ return nil
+ }
+}
+
+private func workClaudeRuntimeModelId(for raw: String) -> String? {
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "opus", "claude-opus-4-7", "anthropic/claude-opus-4-7":
+ return "claude-opus-4-7"
+ case "opus[1m]", "opus-1m", "claude-opus-4-7-1m", "claude-opus-4-7[1m]", "anthropic/claude-opus-4-7-1m":
+ return "claude-opus-4-7[1m]"
+ case "sonnet", "claude-sonnet-4-6", "anthropic/claude-sonnet-4-6":
+ return "sonnet"
+ case "haiku", "claude-haiku-4-5", "anthropic/claude-haiku-4-5":
+ return "haiku"
+ default:
+ return nil
+ }
+}
+
private func workCanonicalCodexRegistryId(for raw: String) -> String? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex":
@@ -404,6 +535,10 @@ private func workCanonicalCodexRegistryId(for raw: String) -> String? {
return "openai/gpt-5.4-mini"
case "gpt-5.3-codex", "openai/gpt-5.3-codex":
return "openai/gpt-5.3-codex"
+ case "gpt-5.3", "gpt-5.3-codex-spark", "gpt-5.3-spark", "codex-spark", "spark", "openai/gpt-5.3-codex-spark":
+ return "openai/gpt-5.3-codex-spark"
+ case "gpt-5.2", "gpt-5.2-codex", "openai/gpt-5.2", "openai/gpt-5.2-codex":
+ return "openai/gpt-5.2"
default:
return nil
}
@@ -417,6 +552,12 @@ private func workCodexRuntimeModelId(for raw: String) -> String? {
return "gpt-5.4"
case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex":
return "gpt-5.4-mini"
+ case "gpt-5.3", "gpt-5.3-codex", "openai/gpt-5.3-codex":
+ return "gpt-5.3-codex"
+ case "gpt-5.3-codex-spark", "gpt-5.3-spark", "codex-spark", "spark", "openai/gpt-5.3-codex-spark":
+ return "gpt-5.3-codex-spark"
+ case "gpt-5.2", "gpt-5.2-codex", "openai/gpt-5.2", "openai/gpt-5.2-codex":
+ return "gpt-5.2"
default:
return nil
}
@@ -446,6 +587,10 @@ func workKnownModelDisplayName(_ raw: String?) -> String? {
return "GPT-5.4-Mini"
case "gpt-5.3-codex", "openai/gpt-5.3-codex":
return "GPT-5.3-Codex"
+ case "gpt-5.3-codex-spark", "gpt-5.3-spark", "codex-spark", "spark", "openai/gpt-5.3-codex-spark":
+ return "GPT-5.3-Codex-Spark"
+ case "gpt-5.2", "gpt-5.2-codex", "openai/gpt-5.2", "openai/gpt-5.2-codex":
+ return "GPT-5.2"
default:
return nil
}
@@ -610,31 +755,31 @@ private func workDynamicModelOption(
tagline = parts.isEmpty ? "Available on the paired host" : parts.joined(separator: " · ")
}
- let tier: WorkModelOption.Tier
- if let curated {
- tier = curated.tier
- } else {
- let normalized = model.id.lowercased()
- if normalized.contains("thinking") {
- tier = .reasoning
- } else if normalized.contains("mini") || normalized.contains("flash") || normalized == "auto" || normalized.contains("haiku") {
- tier = .fast
- } else if normalized.contains("opus") || normalized.contains("gpt-5.5") || normalized == "gpt-5" {
- tier = .flagship
- } else {
- tier = .balanced
- }
- }
-
return WorkModelOption(
id: model.id,
displayName: displayName,
- tier: tier,
+ tier: workDynamicModelTier(for: model.id, curated: curated),
tagline: tagline,
- provider: curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey)
+ provider: curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey),
+ reasoningEfforts: model.reasoningEfforts ?? []
)
}
+private func workDynamicModelTier(for modelId: String, curated: WorkModelOption? = nil) -> WorkModelOption.Tier {
+ if let curated { return curated.tier }
+ let normalized = modelId.lowercased()
+ if normalized.contains("thinking") {
+ return .reasoning
+ }
+ if normalized.contains("mini") || normalized.contains("spark") || normalized.contains("flash") || normalized == "auto" || normalized.contains("haiku") {
+ return .fast
+ }
+ if normalized.contains("opus") || normalized.contains("gpt-5.5") || normalized == "gpt-5" {
+ return .flagship
+ }
+ return .balanced
+}
+
private func workDeduplicatedModelOptions(_ models: [WorkModelOption]) -> [WorkModelOption] {
var seen = Set()
var deduplicated: [WorkModelOption] = []
diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift
index ee721202b..5f3808856 100644
--- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift
+++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift
@@ -160,57 +160,67 @@ struct WorkModelPickerSheet: View {
@MainActor
private func syncSelectionStateToCatalog() {
guard !catalog.isEmpty else { return }
+ if let location = catalogLocation(for: currentModelId) {
+ activeGroup = location.groupKey
+ activeProvider = location.providerKey
+ return
+ }
+
+ let targetGroupKey = workModelCatalogGroupKey(for: currentModelId, currentProvider: currentProvider)
if activeGroup.isEmpty || !catalog.contains(where: { $0.key == activeGroup }) {
- let targetGroupKey = workModelCatalogGroupKey(for: currentModelId, currentProvider: currentProvider)
activeGroup = catalog.first(where: { $0.key == targetGroupKey })?.key
?? catalog.first?.key
?? ""
}
- if activeProvider.isEmpty || activeProviderBlock == nil {
- if let block = activeGroupBlock {
- activeProvider = preferredProviderKey(in: block)
- }
+ if activeProvider.isEmpty || activeProviderBlock == nil, let block = activeGroupBlock {
+ activeProvider = preferredProviderKey(in: block)
}
}
@MainActor
private func loadLiveCatalog() async {
isLoadingCatalog = true
- usingCuratedFallback = false
- liveCatalog = nil
-
- let providers = ["claude", "codex", "cursor", "droid", "opencode"]
- var availableModelsByProvider: [String: [AgentChatModelInfo]] = [:]
- var successCount = 0
-
- for provider in providers {
- do {
- let models = try await syncService.listChatModels(provider: provider)
- availableModelsByProvider[provider] = models
- successCount += 1
- } catch {
- availableModelsByProvider[provider] = []
- }
+ defer {
+ isLoadingCatalog = false
+ syncSelectionStateToCatalog()
}
+ usingCuratedFallback = false
- guard !Task.isCancelled else { return }
-
- if successCount == 0 {
- usingCuratedFallback = true
- liveCatalog = nil
- isLoadingCatalog = false
+ if liveCatalog == nil, let cached = syncService.cachedChatModelCatalog() {
+ liveCatalog = workModelCatalogGroups(
+ hostCatalog: cached,
+ currentModelId: currentModelId,
+ currentProvider: currentProvider
+ )
syncSelectionStateToCatalog()
- return
}
- liveCatalog = workModelCatalogGroups(
- availableModelsByProvider: availableModelsByProvider,
- currentModelId: currentModelId,
- currentProvider: currentProvider
- )
- usingCuratedFallback = false
- isLoadingCatalog = false
- syncSelectionStateToCatalog()
+ do {
+ let hostCatalog = try await syncService.getChatModelCatalog()
+ guard !Task.isCancelled else { return }
+ liveCatalog = workModelCatalogGroups(
+ hostCatalog: hostCatalog,
+ currentModelId: currentModelId,
+ currentProvider: currentProvider
+ )
+ usingCuratedFallback = false
+ } catch {
+ guard !Task.isCancelled else { return }
+ if liveCatalog == nil {
+ usingCuratedFallback = true
+ }
+ }
+ }
+
+ private func catalogLocation(for modelId: String) -> (groupKey: String, providerKey: String)? {
+ for group in catalog {
+ for provider in group.providers {
+ if provider.models.contains(where: { workModelIdsEquivalent($0.id, modelId) }) {
+ return (group.key, provider.key)
+ }
+ }
+ }
+ return nil
}
private func preferredProviderKey(in block: WorkModelCatalogGroup) -> String {
@@ -255,25 +265,12 @@ struct WorkModelPickerSheet: View {
}
private func supportedReasoningTiers(for model: WorkModelOption) -> [String] {
- if let tiers = ADEColor.reasoningTiers(for: model.id), !tiers.isEmpty {
- return tiers
- }
- let lower = model.id.lowercased()
- if lower.contains("opus") {
- return lower.contains("1m") || lower.contains("[1m]")
- ? ["low", "medium", "high", "xhigh", "max"]
- : ["low", "medium", "high", "max"]
- }
- if lower.contains("sonnet") || lower.contains("thinking") {
- return ["low", "medium", "high"]
- }
- if lower.contains("gpt-5.4-mini") {
- return ["low", "medium", "high", "xhigh"]
- }
- if lower.contains("gpt-5") {
- return ["low", "medium", "high", "xhigh"]
+ var seen = Set()
+ return model.reasoningEfforts.compactMap { effort in
+ let tier = effort.effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !tier.isEmpty, seen.insert(tier).inserted else { return nil }
+ return tier
}
- return []
}
private func reasoningLabel(for tier: String) -> String {
diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift
index f18fbb70c..48c17b6f3 100644
--- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift
+++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift
@@ -61,13 +61,6 @@ struct WorkNewChatSheet: View {
icon: providerIcon("codex"),
tint: providerTint("codex")
),
- WorkProviderOption(
- id: "opencode",
- title: "OpenCode",
- subtitle: "Open runtime workflows and tools",
- icon: providerIcon("opencode"),
- tint: providerTint("opencode")
- ),
WorkProviderOption(
id: "cursor",
title: "Cursor",
@@ -75,6 +68,20 @@ struct WorkNewChatSheet: View {
icon: providerIcon("cursor"),
tint: providerTint("cursor")
),
+ WorkProviderOption(
+ id: "droid",
+ title: "Droid",
+ subtitle: "Factory Droid sessions",
+ icon: providerIcon("droid"),
+ tint: providerTint("droid")
+ ),
+ WorkProviderOption(
+ id: "opencode",
+ title: "OpenCode",
+ subtitle: "Open runtime workflows and tools",
+ icon: providerIcon("opencode"),
+ tint: providerTint("opencode")
+ ),
]
}
diff --git a/changelog/v1.0.11.mdx b/changelog/v1.0.11.mdx
index f228733a8..2aa34f4d0 100644
--- a/changelog/v1.0.11.mdx
+++ b/changelog/v1.0.11.mdx
@@ -7,7 +7,7 @@ Largest release to date — 102 commits across 12 merged PRs.
## Added
-- **Cursor ACP provider** — Cursor is now a first-class Agent Control Protocol provider with connection pooling, event mapping, config state tracking, model discovery, and provider logo. Model picker shows all registered models (greyed out when provider is unconfigured)
+- **Cursor provider** — Cursor is now a first-class chat provider with connection pooling, event mapping, config state tracking, model discovery, and provider logo. Model picker shows all registered models (greyed out when provider is unconfigured). This older transport has since been replaced by the Cursor SDK path.
- **PR convergence runtime** — New `issueInventoryService` scans PRs for review comments, CI failures, and merge conflicts to build an actionable issue inventory. "Path to Merge" tab made permanent in PR detail view with `PrConvergencePanel` (round timeline, issue cards, resolution status, pipeline controls) and `PrPipelineSettings` for configuring max rounds and auto-resolve policies
- **CTO convergence tools** — Five new operator tools: `readConvergenceRuntime`, `updateConvergenceRuntime`, `startConvergenceRound`, `stopConvergenceRound`, `readInventorySummary`
- **Agent session binding** — Chat sessions are now bound to the selected lane's worktree directory with the binding persisted across restarts. ADE launch directive injected into initial turns so agents know which lane/worktree they operate in
@@ -28,7 +28,7 @@ Largest release to date — 102 commits across 12 merged PRs.
## Changed
- **UI overhaul** — Redesigned chat composer footer, message list, session cards, and session list pane. Reordered tab navigation to prioritize Work and Lanes tabs. Work log blocks show only the latest tool call by default (expandable "Show N more")
-- **Agent chat service** — Expanded by +3,848 lines with Cursor ACP, unified ADE CLI bridging, convergence runtime, approval/elicitation flows, and initial turn activity centralization
+- **Agent chat service** — Expanded by +3,848 lines with Cursor chat support, unified ADE CLI bridging, convergence runtime, approval/elicitation flows, and initial turn activity centralization
- **Rebase flow** — Runtime lifecycle cleanup on worktree switch, `getRebaseNeed` throws on lookup errors (instead of returning null), rebase attention states preserved across emits
- **Lane import logic** — Detects and reuses existing local branches, only creates tracking branches when needed, attempts parent lane detection via merge-base
- **PR service** — Expanded with retarget error tracking, auto-rebase land support, and convergence state management
diff --git a/changelog/v1.0.19.mdx b/changelog/v1.0.19.mdx
index 65a6db51f..9cdc93f18 100644
--- a/changelog/v1.0.19.mdx
+++ b/changelog/v1.0.19.mdx
@@ -15,9 +15,9 @@ Setup takes about 30 seconds: open **Settings → Notifications → Mobile Push*
---
-## Cursor ACP integration
+## Legacy Cursor integration
-Cursor's agent can now connect to ADE directly as a chat provider via the **Agent Control Protocol (ACP)**. When Cursor is installed and authenticated, its models appear in the chat model selector alongside Claude, Codex, and OpenCode. ADE manages the ACP session pool and maps ACP events to its standard chat event model, so the UI behaves consistently across all providers.
+Cursor's agent can now connect to ADE directly as a chat provider. When Cursor is installed and authenticated, its models appear in the chat model selector alongside Claude, Codex, and OpenCode. ADE manages the session pool and maps provider events to its standard chat event model, so the UI behaves consistently across all providers. This older implementation has since been replaced by the Cursor SDK path.
The `ade` CLI is on `PATH` for Cursor-backed sessions, giving Cursor agents access to lane, git, PR, and action tools.
diff --git a/changelog/v1.1.7.mdx b/changelog/v1.1.7.mdx
index 003856306..167b79e6f 100644
--- a/changelog/v1.1.7.mdx
+++ b/changelog/v1.1.7.mdx
@@ -3,7 +3,7 @@ title: "v1.1.7"
description: "Release notes for ADE v1.1.7 — April 29, 2026"
---
-v1.1.7 is one of the largest ADE releases to date — 19 commits, 415 files, ~+48k/-16k lines. Headline beats: an in-chat **iOS Simulator** panel with auto-resolving visual stream, full **Factory Droid** ACP chat support alongside Claude / Cursor / Codex, **Windows code-signing + sync parity** for the Windows desktop build, **lane branch switching** with a first-class branch selector, **Live Activity & Lock Screen widget polish** on iOS, and a wholesale removal of the legacy context-packs system in favor of on-demand context doc generation. Plus dozens of smaller fixes across PRs, automations, terminals, sync, and the chat composer. Ships in **TestFlight build 9** of 1.1.1.
+v1.1.7 is one of the largest ADE releases to date — 19 commits, 415 files, ~+48k/-16k lines. Headline beats: an in-chat **iOS Simulator** panel with auto-resolving visual stream, full **Factory Droid** ACP chat support alongside Claude / Cursor SDK / Codex, **Windows code-signing + sync parity** for the Windows desktop build, **lane branch switching** with a first-class branch selector, **Live Activity & Lock Screen widget polish** on iOS, and a wholesale removal of the legacy context-packs system in favor of on-demand context doc generation. Plus dozens of smaller fixes across PRs, automations, terminals, sync, and the chat composer. Ships in **TestFlight build 9** of 1.1.1.
---
@@ -23,10 +23,10 @@ v1.1.7 is one of the largest ADE releases to date — 19 commits, 415 files, ~+4
- Factory Droid joins Claude Code and Cursor as a first-class ACP chat provider.
+ Factory Droid joins Claude Code and Cursor as a first-class chat provider.
- - **Droid ACP runtime.** `droidAcpPool` spawns `droid exec --output-format acp` and pools JSON-RPC connections by lane the same way the Cursor pool does. Authentication runs through Droid's CLI plus `FACTORY_API_KEY`. Dynamic `droid/*` model descriptors are discovered at runtime with provider-side defaults.
- - **Shared ACP host client.** `acpHostClient` factors the JSON-RPC handshake, lifecycle probing, and steer-queue plumbing out of the Cursor pool so Droid (and any future ACP provider) reuses one well-tested client. Cursor was migrated onto it in the same PR.
+ - **Droid ACP runtime.** `droidAcpPool` spawns `droid exec --output-format acp` and pools JSON-RPC connections by lane. Authentication runs through Droid's CLI plus `FACTORY_API_KEY`. Dynamic `droid/*` model descriptors are discovered at runtime with provider-side defaults.
+ - **Shared ACP host client.** `acpHostClient` factors the JSON-RPC handshake, lifecycle probing, and steer-queue plumbing into one well-tested client for Droid and any future ACP provider.
- **End-to-end chat wiring.** `DroidRuntime` is plumbed through `agentChatService` for turn execution, steer queue, interrupts, permission prompts, and persistence. AI status, model picker grouping, providers settings, and missions permissions all learn the new `droid` provider.
- **Pool reliability.** Acquires are now serialized per pool key with a shared pending-init lock, so two simultaneous chat opens cannot double-spawn. Stale pool entries are evicted on child `close`/`error`. Init/handshake failures kill the process and log the stderr tail. ACP terminal cwds resolve within the lane root, and `waitForTerminalExit` is capped with a SIGKILL escalation + close wait to avoid stuck terminals.
diff --git a/changelog/v1.1.8.mdx b/changelog/v1.1.8.mdx
index b0491b092..71c9e8a35 100644
--- a/changelog/v1.1.8.mdx
+++ b/changelog/v1.1.8.mdx
@@ -37,7 +37,7 @@ v1.1.8 lands three PRs since v1.1.7: an **Electron Viewer** surface that wires a
Carry-over from the Mobile Models Registry PR: the chat system prompt now names the host runtime so the model knows which wake-up primitives are honored.
- - **`systemPrompt.buildCodingAgentSystemPrompt`** takes an optional `runtime` parameter. New `describeRuntime()` helper covers all five ADE chat runtimes (Claude Agent SDK v2, Codex CLI, Cursor ACP, Droid ACP, OpenCode), including wake-up semantics — e.g. inside the SDK v2 chat the banner explicitly says "you are NOT in the Claude Code CLI; `ScheduleWakeup` is not honored — the host accepts the call but never re-invokes you; `Bash run_in_background` task notifications are queued until the next user message".
+ - **`systemPrompt.buildCodingAgentSystemPrompt`** takes an optional `runtime` parameter. New `describeRuntime()` helper covers all five ADE chat runtimes (Claude Agent SDK v2, Codex CLI, Cursor SDK, Droid ACP, OpenCode), including wake-up semantics — e.g. inside the SDK v2 chat the banner explicitly says "you are NOT in the Claude Code CLI; `ScheduleWakeup` is not honored — the host accepts the call but never re-invokes you; `Bash run_in_background` task notifications are queued until the next user message".
- **`agentChatService`** passes `runtime: "codex-cli"` through Codex's developer instructions and prepends the SDK-v2 runtime banner above the `## ADE Workspace` block on the Claude V2 system-prompt append array.
- **Tests.** Six new cases in `systemPrompt.test.ts` pin the banner content per runtime, and assert the block is omitted when `runtime` is not passed.
- **Steer chip dispatch.** `deriveRuntimeState` resolves a steer on any non-queued `user_message` with the same `steerId` so the chip clears after Send-Now (inline dispatch) and other delivery paths. `cancelSteer` emits the cancelled notice even when the queue is empty server-side, so the delete button clears stale chips after a dispatch race.
diff --git a/chat/overview.mdx b/chat/overview.mdx
index b806d1340..cdc3dba93 100644
--- a/chat/overview.mdx
+++ b/chat/overview.mdx
@@ -15,7 +15,7 @@ Agent Chat works across multiple provider backends:
|----------|---------|------|
| **Claude** | Anthropic SDK v2 sessions | CLI subscription or API key |
| **Codex** | OpenAI app-server JSON-RPC | CLI subscription or API key |
-| **Cursor** | ACP (Agent Control Protocol) | Cursor CLI auth |
+| **Cursor** | Cursor SDK | Cursor API key |
| **OpenCode** | OpenCode server (local process) | API key, OpenRouter, or local endpoint |
ADE detects which providers are available and lets you switch between them per-session using the model selector in the chat header. Each provider has its own session lifecycle, timeout behavior, and interrupt semantics.
@@ -133,7 +133,7 @@ Legacy keys `ai.chat.autoTitleEnabled` and `chat.autoTitleReasoningEffort` / `ch
Press **Escape** or click the **Stop** button to interrupt a running agent. Behavior varies by provider:
- **Claude** — graceful interrupt; agent reports partial results
- **Codex** — issues a turn/interrupt for the active turn; partial output is preserved in the transcript
-- **Cursor (ACP)** — session cancel via ACP protocol
+- **Cursor** — cancels the active SDK run
### Steering and editing
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 07ed61e2a..5a262c278 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -432,7 +432,7 @@ Every service lives under `apps/desktop/src/main/services//`. Summary:
| `agentTools/` | `agentToolsService.ts` | Agent tool registry metadata surfaced to the renderer. |
| `appControl/` | `appControlService.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). |
| `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. |
-| `chat/` | `agentChatService.ts`, `buildClaudeV2Message.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + mission worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool (replaces the older `cursorAcp*` files), recovers sessions on restart, and derives prompt-based lane names for parallel model launches. |
+| `chat/` | `agentChatService.ts`, `buildClaudeV2Message.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + mission worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, recovers sessions on restart, and derives prompt-based lane names for parallel model launches. |
| `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. |
| `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. |
| `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. |
diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md
index e6fb3a5d8..0bab31910 100644
--- a/docs/features/chat/README.md
+++ b/docs/features/chat/README.md
@@ -16,7 +16,7 @@ machinery layered on top.
| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers per-project (`.claude/commands/**`) and per-user (`~/.claude/commands/**`) slash commands, including `.md` command files and `.skill` user-invocable skills, parsing YAML frontmatter for description and argument hints. Consumed by `agentChatService` to enrich the `chat.slashCommands` response so the composer's picker lists local Claude commands alongside SDK-provided ones. |
| `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. |
| `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. |
-| `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. Replaces the older `cursorAcpPool.ts`. |
+| `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. |
| `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. |
| `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, and `CursorSdkWorkerInit` boot envelope. |
| `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`); decides which tool calls auto-approve and which require a user prompt. |
@@ -47,10 +47,8 @@ machinery layered on top.
defined in `cursorSdkProtocol.ts`; permissions, hooks, and the system
prompt are assembled by `cursorSdkPolicy.ts` and
`cursorSdkSystemPrompt.ts`, and SDK events are translated into
- ADE chat events by `cursorSdkEventMapper.ts`. The earlier ACP-based
- Cursor adapter (`cursorAcpPool.ts` / `cursorAcpEventMapper.ts` /
- `cursorAcpConfigState.ts`) and the `cursorAgentExecutable.ts` CLI
- resolver were removed when the SDK landed.
+ ADE chat events by `cursorSdkEventMapper.ts`. Cursor is SDK-backed here;
+ ACP is only used by providers that still expose an ACP host, such as Droid.
- **Lane-scoped.** Every session carries `laneId`; lane context (branch,
worktree path) is injected into the system prompt, and working-directory
resolution runs through `resolveLaneLaunchContext`.
diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md
index 195a68f33..6efa7c651 100644
--- a/docs/features/ios-simulator/README.md
+++ b/docs/features/ios-simulator/README.md
@@ -114,14 +114,15 @@ called from a non-darwin host.
- `iosurface-indigo` (`live-start`/`auto` preferred path) — ADE
lazily compiles `apps/desktop/native/ios-sim-helpers/sim-capture.swift`
and `sim-input.m` for the selected full Xcode, caches them under
- `native/ios-sim-helpers/build/-/`, reads
+ `native/ios-sim-helpers/build/-/` in source
+ checkouts or the user's ADE cache in packaged builds, reads
length-prefixed JPEG IOSurface frames from the helper, and bridges
them into the same localhost multipart MJPEG endpoint used by every
caller. Requires macOS, full Xcode 17.x or 26.x, `swiftc`, and
- `clang`; CLT-only machines fall back cleanly. ADE currently gates this
- path off in packaged builds until helper signing/notarization is cleared,
- and new Xcode majors should be added only after helper compile/smoke and
- real simulator validation.
+ `clang`; CLT-only machines fall back cleanly. Packaged ADE builds ship
+ the helper sources as app resources and keep generated helper binaries
+ outside the signed `.app` bundle. New Xcode majors should be added only
+ after helper compile/smoke and real simulator validation.
- `idb-h264-ffmpeg-mjpeg` (recovery-only) — exact-screen stream
through `idb video-stream --format h264` transcoded to MJPEG via
`ffmpeg`. The renderer reads the MJPEG endpoint with `fetch` +
diff --git a/docs/features/memory/compaction.md b/docs/features/memory/compaction.md
index 6d127b35a..7af10f5c8 100644
--- a/docs/features/memory/compaction.md
+++ b/docs/features/memory/compaction.md
@@ -64,10 +64,9 @@ actionable insight per memory, SAVE vs DO-NOT-SAVE lists, the
`item/completed` where `item.type === "contextCompaction"`; the
Codex runtime emits `context_compact` on completion. Codex has no
pre-compact hook equivalent.
-- **Cursor / ACP** — no compaction signal. Neither the base ACP spec
- nor Cursor's extensions define a compaction or summarization
- notification. Cursor summarizes internally but keeps it hidden from
- the protocol. No indicator possible today.
+- **Cursor SDK** — no exposed compaction signal. Cursor may summarize
+ internally, but the SDK does not expose a compaction or summarization
+ notification ADE can render today.
### Caveats
@@ -275,7 +274,7 @@ counts.
- **Pre-compact hook nudge is Claude-only.** Only the Claude Agent
SDK exposes a `PreCompact` hook that fires *before* compaction and
can influence it. Compaction *boundary events* are wired for
- Claude, OpenCode, and Codex; Cursor/ACP has no signal to listen
+ Claude, OpenCode, and Codex; Cursor SDK has no signal to listen
for.
- **Procedural learning clustering is text-similarity based.** If
`normalizeText` drops too much signal (e.g., stripping numbers in a
@@ -305,4 +304,4 @@ counts.
- [Embeddings](embeddings.md) -- vector search that powers
consolidation clustering and hybrid retrieval.
-
\ No newline at end of file
+
diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md
index 8c616b622..1b4df840f 100644
--- a/docs/features/terminals-and-sessions/pty-and-processes.md
+++ b/docs/features/terminals-and-sessions/pty-and-processes.md
@@ -537,10 +537,11 @@ processes.start → processService.startByDefinition
## Gotchas
- `ptyService.enrichSessions` (called from `registerIpc.sessionsList`)
- overlays live runtime state onto rows returned from
- `sessionService.list`. Callers that bypass `registerIpc` must either
- run sessions through `enrichSessions` or explicitly derive
- `runtimeState` from `status`.
+ overlays live PTY attachment state onto rows returned from
+ `sessionService.list`: `status`, `ptyId`, `endedAt`, `exitCode`,
+ and `runtimeState`. Callers that bypass `registerIpc` must either run
+ sessions through `enrichSessions` or explicitly reconcile live PTYs
+ before trusting persisted lifecycle fields.
- `registerIpc.sessionsList` and `.sessionsGet` both lazily hydrate
resume targets via `ptyService.ensureResumeTargets` for tracked,
ended Claude/Codex rows whose `resumeMetadata.targetId` is blank.
diff --git a/getting-started/connect-provider.mdx b/getting-started/connect-provider.mdx
index f03873d82..8c9ff11c9 100644
--- a/getting-started/connect-provider.mdx
+++ b/getting-started/connect-provider.mdx
@@ -44,8 +44,8 @@ ADE works best once you have at least one verified provider configured. There ar
-
- ADE detects installed CLIs and checks their auth status automatically.
+
+ ADE detects installed CLIs and SDK-backed provider keys automatically.
@@ -55,12 +55,12 @@ ADE works best once you have at least one verified provider configured. There ar
Install the `codex` CLI globally (`npm i -g @openai/codex`) and authenticate.
- Install Cursor and sign in. ADE uses Cursor's agent via the ACP protocol.
+ Add a Cursor API key. ADE uses Cursor's agent through the Cursor SDK.
- CLI-based providers are detected during onboarding and on each app launch. If you install a CLI after starting ADE, open **Settings** and select **AI Providers**, then click **Refresh** to re-detect.
+ Local CLIs and SDK-backed provider keys are checked during onboarding and on each app launch. If you install a CLI or add a provider key after starting ADE, open **Settings** and select **AI Providers**, then click **Refresh** to re-detect.
@@ -72,7 +72,7 @@ ADE works best once you have at least one verified provider configured. There ar
| If you want... | Use this | Why |
|----------------|----------|-----|
| The most complete ADE experience | **Anthropic (Claude)** | Works everywhere — chat, missions, CTO, automations |
-| You already use Cursor daily | **Cursor** | ADE integrates via ACP — no extra API key needed |
+| You already use Cursor daily | **Cursor** | ADE integrates through the Cursor SDK with a Cursor API key |
| Cost flexibility | **OpenRouter** | Route different agent roles to different models |
| Privacy / offline work | **Ollama (local)** | No API key, runs entirely on your machine |
diff --git a/getting-started/open-project.mdx b/getting-started/open-project.mdx
index 54a9b60e8..b87838488 100644
--- a/getting-started/open-project.mdx
+++ b/getting-started/open-project.mdx
@@ -57,7 +57,7 @@ When ADE opens a new repository, it launches the **project setup wizard** — a
Connect your AI providers. ADE supports two categories:
- **API key providers** — Anthropic (Claude), OpenAI (GPT, Codex). Paste your API key and ADE verifies it immediately.
- - **CLI-based providers** — Claude Code, Codex CLI, Cursor (ACP). ADE detects these from your PATH and checks authentication status.
+ - **Runtime-backed providers** — Claude Code, Codex CLI, Cursor SDK. ADE detects local runtimes and checks authentication status.
You need at least one provider to use agent features. You can add more later in Settings.
diff --git a/plans/cursor-sdk-chat-integration.md b/plans/cursor-sdk-chat-integration.md
index 76f602ba3..2be278866 100644
--- a/plans/cursor-sdk-chat-integration.md
+++ b/plans/cursor-sdk-chat-integration.md
@@ -3,7 +3,7 @@
Status: implemented; retained as historical integration notes
Date: 2026-04-29
-Current implementation note: Cursor chat in ADE is SDK-only. Cursor ACP fallback, `ADE_CURSOR_CHAT_TRANSPORT`, and the old Cursor-specific ACP pool/config/event mapper have been removed. Cursor auth is API-key based via `CURSOR_API_KEY` or ADE's encrypted API key store with provider id `cursor`.
+Current implementation note: Cursor chat in ADE is SDK-only. The old Cursor-specific alternate transport, `ADE_CURSOR_CHAT_TRANSPORT`, and its pool/config/event mapper have been removed. Cursor auth is API-key based via `CURSOR_API_KEY` or ADE's encrypted API key store with provider id `cursor`.
## Security note for the implementing agent
@@ -15,17 +15,17 @@ Do not add plaintext Cursor credentials to this plan or any repository file. Ass
Replace ADE's Cursor chat runtime path with the Cursor TypeScript SDK for this spike, while keeping ADE's permission/control surface. The desired near-term outcome is:
-- Cursor local chats in ADE run through `@cursor/sdk`, not Cursor ACP.
-- Cursor ACP is removed, so the SDK path is the only Cursor path.
+- Cursor local chats in ADE run through `@cursor/sdk`.
+- The SDK path is the only Cursor path.
- ADE still owns approvals, plan/read-only behavior, full-auto behavior, transcript/work-log mapping, cancellation, and process cleanup.
- The UI feels native next to Claude and Codex controls.
- The implementation is tested incrementally with a real Cursor API key via `CURSOR_API_KEY`.
-This is not just a transport swap. Cursor SDK has a richer runtime model than ACP, but it does not currently expose the same direct permission callback shape that ACP gives ADE. The integration should use an ADE-managed SDK worker process plus Cursor hooks as the permission bridge.
+This is not just a transport swap. Cursor SDK has a richer runtime model than the old path, but it does not currently expose the same direct permission callback shape ADE needs. The integration should use an ADE-managed SDK worker process plus Cursor hooks as the permission bridge.
## Why do this
-Cursor ACP works as a workaround, but it leaves ADE coupled to a CLI protocol surface instead of Cursor's new first-party agent API. The SDK gives ADE access to:
+The previous Cursor chat path left ADE coupled to a CLI protocol surface instead of Cursor's first-party agent API. The SDK gives ADE access to:
- Cursor's same local/cloud agent harness used by desktop, CLI, and web.
- Durable agent and run objects.
@@ -37,7 +37,7 @@ Cursor ACP works as a workaround, but it leaves ADE coupled to a CLI protocol su
- Custom subagents.
- Structured tool/thinking/status/task events and finer `onDelta` interaction updates.
-The strategic reason is cloud. ACP cannot launch or manage Cursor Cloud agents from ADE. SDK can.
+The strategic reason is cloud. The old path cannot launch or manage Cursor Cloud agents from ADE. SDK can.
## Sources
@@ -51,7 +51,7 @@ Primary Cursor sources:
Local ADE source anchors:
- Cursor SDK pool: `apps/desktop/src/main/services/chat/cursorSdkPool.ts`
-- Shared ACP host callbacks: `apps/desktop/src/main/services/chat/acpHostClient.ts`
+- Shared Droid host callbacks: `apps/desktop/src/main/services/chat/acpHostClient.ts`
- Main chat runtime: `apps/desktop/src/main/services/chat/agentChatService.ts`
- Current Cursor modes: `apps/desktop/src/shared/cursorModes.ts`
- Chat types: `apps/desktop/src/shared/types/chat.ts`
@@ -114,15 +114,15 @@ Cloud SDK:
## Historical ADE behavior replaced by SDK policy
-Cursor ACP previously launched:
+The previous Cursor chat path launched:
```text
-agent acp --workspace --model --sandbox [--mode ask|plan] [--force]
+legacy cursor launch command removed
```
-Previous Cursor ACP launch mapping:
+Previous Cursor launch mapping:
-| ADE state | ACP launch |
+| ADE state | Legacy launch |
| --- | --- |
| Agent/default | `--sandbox enabled` |
| Ask | `--mode ask --sandbox enabled` |
@@ -420,7 +420,7 @@ Cloud permissions caveat:
- Do not claim ADE can approve every cloud tool call unless Cursor adds a usable request-response API or hook callbacks to ADE are proven for cloud.
- Default cloud runs should not auto-create PRs.
-## Cursor ACP removal
+## Legacy Cursor transport removal
Cursor SDK is the only active Cursor chat/runtime path.
@@ -428,7 +428,7 @@ In code:
- `agentChatService` routes Cursor sessions through `cursorSdkPool`.
- `cursorSdkWorker` owns the SDK agent, local/cloud runs, model/repo catalog requests, and the hook bridge.
-- Persisted Cursor runtime state uses SDK agent/run ids, not ACP session ids.
+- Persisted Cursor runtime state uses SDK agent/run ids, not legacy session ids.
- Shared ACP host/client code remains for Factory Droid only.
## UI requirements
@@ -453,7 +453,7 @@ Composer:
Session summary:
-- Show Cursor SDK vs ACP transport while the spike is active, at least in debug/status details.
+- Show Cursor SDK transport status while the spike is active, at least in debug/status details.
- Show local vs cloud runtime if cloud is enabled.
- Show current model from SDK catalog.
@@ -527,7 +527,7 @@ Run narrower targeted tests first while iterating. Finish with the broader check
- Add `@cursor/sdk` to `apps/desktop/package.json`.
- Add SDK pool/worker/protocol plumbing.
-- Remove the Cursor ACP runtime path.
+- Remove the legacy Cursor runtime path.
- Add typed API-key auth and tests.
Acceptance:
@@ -611,8 +611,7 @@ Acceptance:
### Phase 7: Model catalog
-- Use `Cursor.models.list()` for Cursor models when SDK transport is active.
-- Keep CLI model discovery as fallback for ACP mode.
+- Use `Cursor.models.list()` for Cursor models.
- Preserve dynamic `cursor/` model IDs in ADE's model registry.
Acceptance:
@@ -667,7 +666,7 @@ Risk: Native optional package or bundling issues.
## Definition of done
-- Cursor SDK is the default and only active Cursor chat path unless ACP override is explicitly set.
+- Cursor SDK is the default and only active Cursor chat path.
- Local Cursor SDK chat can answer, inspect, edit, run shell, stream, and cancel.
- ADE permission presets work:
- Ask/read-only denies side effects.