diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 453a278f6..9502a1bae 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -1460,6 +1460,7 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ modelId: { type: "string" }, reasoningEffort: { type: "string" }, permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"] }, + droidPermissionMode: { type: "string", enum: ["read-only", "auto-low", "auto-medium", "auto-high"] }, title: { type: "string" }, initialPrompt: { type: "string" }, openInUi: { type: "boolean" } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 47e7ef96c..3df27ebeb 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -251,7 +251,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo const sessionService = createSessionService({ db }); sessionService.reconcileStaleRunningSessions({ status: "disposed", - excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"], + excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"], }); const projectConfigService = createProjectConfigService({ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ad0f94bd3..5d2abdd63 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1513,7 +1513,7 @@ function buildChatPlan(args: string[]): CliPlan { const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(sessionId ? { sessionId } : {}) }); if (sub === "list" || sub === "ls") return { kind: "execute", label: "chat list", steps: [actionStep("result", "chat", "listSessions", collectGenericObjectArgs(args))] }; if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionStep("result", "chat", "getSessionSummary", withSession())] }; - if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), surface: readValue(args, ["--surface"]) ?? "work" }))] }; + if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), surface: readValue(args, ["--surface"]) ?? "work" }))] }; if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] }; if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] }; if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] }; @@ -2045,12 +2045,14 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "-b", "-m", "-q", "-t", "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", - "--automation", "--base", "--base-branch", "--base-ref", "--body", "--branch", + "--automation", "--autonomy", + "--base", "--base-branch", "--base-ref", "--body", "--branch", "--branch-name", "--branch-ref", "--category", "--color", "--cols", "--command", "--comment", "--comment-id", "--commit", "--compare-ref", "--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data", "--depth", "--desc", - "--description", "--domain", "--duration-sec", "--enabled", "--event", + "--description", "--domain", "--droid-autonomy", "--droid-permission-mode", + "--duration-sec", "--enabled", "--event", "--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", "--input", "--input-json", "--instructions", "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 1298653c0..b16dec2b5 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-beta.1", "license": "AGPL-3.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.17.1", + "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@floating-ui/react": "^0.27.19", "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -19,7 +19,7 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", - "@opencode-ai/sdk": "^1.3.17", + "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -103,9 +103,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz", - "integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", + "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -3466,9 +3466,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz", - "integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==", + "version": "1.14.28", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.28.tgz", + "integrity": "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bb4bdb92d..0f421f480 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -44,7 +44,7 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.17.1", + "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@floating-ui/react": "^0.27.19", "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -54,7 +54,7 @@ "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", - "@opencode-ai/sdk": "^1.3.17", + "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 7bb97d72a..c5d9fdbc1 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -36,6 +36,7 @@ import { createJobEngine } from "./services/jobs/jobEngine"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver"; import { createAgentChatService } from "./services/chat/agentChatService"; +import { shutdownAcpCliConnections } from "./services/chat/acpCliPool"; import { createGithubService } from "./services/github/githubService"; import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService"; import { createPrService } from "./services/prs/prService"; @@ -4413,6 +4414,11 @@ app.whenReady().then(async () => { } shutdownOpenCodeServersBestEffort(); + try { + shutdownAcpCliConnections(); + } catch { + // ignore + } }; const finalizeAppExit = (exitCode: number): void => { diff --git a/apps/desktop/src/main/services/ai/agentExecutor.ts b/apps/desktop/src/main/services/ai/agentExecutor.ts index 7377833df..6db54c1e3 100644 --- a/apps/desktop/src/main/services/ai/agentExecutor.ts +++ b/apps/desktop/src/main/services/ai/agentExecutor.ts @@ -1,4 +1,4 @@ -export type AgentProvider = "claude" | "codex" | "cursor" | "opencode"; +export type AgentProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; export type AgentPermissionMode = "read-only" | "edit" | "full-auto"; diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 8ad45414b..328e9ed31 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -73,13 +73,13 @@ import { createAiIntegrationService } from "./aiIntegrationService"; type ServiceFactoryOptions = { aiConfig?: Record; dailyUsageCount?: number; - availability?: { claude: boolean; codex: boolean; cursor?: boolean }; + availability?: { claude: boolean; codex: boolean; cursor?: boolean; droid?: boolean }; providerMode?: "guest" | "subscription"; }; type DbRunCall = { sql: string; params: unknown[] }; -function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean }) { +function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean; droid: boolean }) { const checkedAt = "2025-01-01T00:00:00.000Z"; return { claude: { @@ -112,6 +112,16 @@ function makeProviderConnections(availability: { claude: boolean; codex: boolean blocker: availability.cursor ? null : "Cursor unavailable", lastCheckedAt: checkedAt, }, + droid: { + provider: "droid", + authAvailable: availability.droid, + runtimeDetected: availability.droid, + runtimeAvailable: availability.droid, + sources: [], + path: availability.droid ? "/usr/local/bin/droid" : null, + blocker: availability.droid ? null : "Droid unavailable", + lastCheckedAt: checkedAt, + }, }; } @@ -153,6 +163,7 @@ function makeService(options: ServiceFactoryOptions = {}) { claude: true, codex: true, cursor: false, + droid: false, ...(options.availability ?? {}), }; const statuses = [ @@ -177,6 +188,13 @@ function makeService(options: ServiceFactoryOptions = {}) { authenticated: availability.cursor, verified: true, }, + { + cli: "droid", + installed: availability.droid, + path: availability.droid ? "/usr/local/bin/droid" : null, + authenticated: availability.droid, + verified: true, + }, ]; mockState.getCachedCliAuthStatuses.mockReturnValue(statuses); mockState.detectAllAuth.mockResolvedValue([ @@ -189,6 +207,9 @@ function makeService(options: ServiceFactoryOptions = {}) { ...(availability.cursor ? [{ type: "cli-subscription", cli: "cursor", path: "/usr/local/bin/agent", authenticated: true, verified: true }] : []), + ...(availability.droid + ? [{ type: "cli-subscription", cli: "droid", path: "/usr/local/bin/droid", authenticated: true, verified: true }] + : []), ]); mockState.buildProviderConnections.mockResolvedValue(makeProviderConnections(availability)); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index d71b4f397..ba2cf3702 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -38,7 +38,7 @@ import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory, } from "../opencode/openCodeInventory"; -import { resolveOpenCodeExecutablePath, type DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; +import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; import { resolveOpenCodeBinary, type OpenCodeBinarySource } from "../opencode/openCodeBinaryManager"; import { initialize as initModelsDevService } from "./modelsDevService"; import { updateModelPricing } from "../../../shared/modelProfiles"; @@ -48,7 +48,9 @@ import { getApiKeyStoreStatus } from "./apiKeyStore"; import type { createMemoryService } from "../memory/memoryService"; import { inspectLocalProvider } from "./localModelDiscovery"; import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery"; +import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery"; import { resolveCursorAgentExecutable } from "./cursorAgentExecutable"; +import { resolveDroidExecutable } from "./droidExecutable"; import { buildProviderConnections } from "./providerConnectionStatus"; import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; @@ -91,15 +93,17 @@ export type AiIntegrationStatus = { claude: boolean; codex: boolean; cursor: boolean; + droid: boolean; }; models: { claude: AgentModelDescriptor[]; codex: AgentModelDescriptor[]; cursor: AgentModelDescriptor[]; + droid: AgentModelDescriptor[]; }; detectedAuth?: Array<{ type: "cli-subscription" | "api-key" | "openrouter" | "local"; - cli?: "claude" | "codex" | "cursor"; + cli?: "claude" | "codex" | "cursor" | "droid"; provider?: string; source?: "config" | "env" | "store"; endpointSource?: "auto" | "config"; @@ -327,11 +331,17 @@ function extractConfiguredLocalProviders( return out; } -function toCliAvailability(auth: DetectedAuth[]): { claude: boolean; codex: boolean; cursor: boolean } { +function toCliAvailability(auth: DetectedAuth[]): { + claude: boolean; + codex: boolean; + cursor: boolean; + droid: boolean; +} { return { claude: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "claude"), codex: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "codex"), cursor: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "cursor"), + droid: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "droid"), }; } @@ -730,7 +740,8 @@ export function createAiIntegrationService(args: { args.providerConnections && (args.providerConnections.claude.authAvailable || args.providerConnections.codex.authAvailable - || args.providerConnections.cursor.authAvailable) + || args.providerConnections.cursor.authAvailable + || args.providerConnections.droid.authAvailable) ) { return "subscription"; } @@ -752,10 +763,12 @@ export function createAiIntegrationService(args: { const claude = statuses.find((entry) => entry.cli === "claude"); const codex = statuses.find((entry) => entry.cli === "codex"); const cursor = statuses.find((entry) => entry.cli === "cursor"); + const droid = statuses.find((entry) => entry.cli === "droid"); return { claude: Boolean(claude?.installed && (claude.authenticated || !claude.verified)), codex: Boolean(codex?.installed && (codex.authenticated || !codex.verified)), cursor: Boolean(cursor?.installed && (cursor.authenticated || !cursor.verified)), + droid: Boolean(droid?.installed && (droid.authenticated || !droid.verified)), }; }; @@ -794,6 +807,26 @@ export function createAiIntegrationService(args: { } } + const hasDroidCliAuth = auth.some( + (entry) => + entry.type === "cli-subscription" + && entry.cli === "droid" + && entry.authenticated !== false, + ); + const hasDroidApiKey = Boolean(process.env.FACTORY_API_KEY?.trim()); + if (hasDroidCliAuth || hasDroidApiKey) { + try { + const { path: droidPath } = resolveDroidExecutable({ auth }); + const droidModels = await discoverDroidCliModelDescriptors(droidPath); + available = [ + ...available.filter((descriptor) => !(descriptor.family === "factory" && descriptor.isCliWrapped)), + ...droidModels, + ]; + } catch { + // Droid CLI missing or model discovery failed — omit dynamic Droid list + } + } + return available; }; @@ -1129,6 +1162,8 @@ export function createAiIntegrationService(args: { family = "openai"; } else if (provider === "cursor") { family = "cursor"; + } else if (provider === "droid") { + family = "factory"; } else { family = "anthropic"; } @@ -1203,6 +1238,7 @@ export function createAiIntegrationService(args: { resetClaudeRuntimeProbeCache(); resetLocalProviderDetectionCache(); clearCursorCliModelsCache(); + clearDroidCliModelsCache(); modelListCache.clear(); runtimeHealthVersion = getProviderRuntimeHealthVersion(); } @@ -1242,12 +1278,14 @@ export function createAiIntegrationService(args: { claude: providerConnections.claude.runtimeAvailable, codex: providerConnections.codex.runtimeAvailable, cursor: providerConnections.cursor.runtimeAvailable, + droid: providerConnections.droid.runtimeAvailable, }; const runtimeFilteredAvailable = available.filter((descriptor) => { if (!descriptor.isCliWrapped) return true; if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable; if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable; if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable; + if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable; return true; }); @@ -1309,6 +1347,7 @@ export function createAiIntegrationService(args: { claude: availability.claude ? await listModels("claude") : [], codex: availability.codex ? await listModels("codex") : [], cursor: availability.cursor ? await listModels("cursor") : [], + droid: availability.droid ? await listModels("droid") : [], }, detectedAuth: redactDetectedAuth(auth, cliStatuses), providerConnections, diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index b22259b40..91a9d5269 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -219,6 +219,53 @@ describe("authDetector", () => { ); }); + it("treats droid exec list-tools as a valid authenticated probe", async () => { + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-")); + process.env.HOME = tempHomeDir; + // Create a fake droid binary in a known bin dir so resolveDroidExecutable + // (which uses fs.statSync against real paths) finds it without falling + // through to the real CI PATH. + const droidBinDir = path.join(tempHomeDir, ".local", "bin"); + fs.mkdirSync(droidBinDir, { recursive: true }); + const fakeDroidPath = path.join(droidBinDir, "droid"); + fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + // Strip PATH so resolveExecutableFromKnownLocations skips real binaries + // on the CI runner and uses the temp home's known dirs. + process.env.PATH = ""; + + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "droid" || command.endsWith("/droid")) return fakeChild({ status: 0, stdout: "0.70.0\n" }); + return fakeError(); + } + if (command === "which") { + if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` }); + return fakeChild({ status: 1 }); + } + if ((command === "droid" || command.endsWith("/droid")) && args[0] === "exec" && args[1] === "--list-tools") { + return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" }); + } + if ((command === "droid" || command.endsWith("/droid")) && args[0] === "account") { + return fakeChild({ status: 1, stderr: "unknown command 'account'\n" }); + } + if ((command === "droid" || command.endsWith("/droid")) && args[0] === "whoami") { + return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" }); + } + return fakeChild({ status: 1 }); + }); + + const statuses = await detectCliAuthStatuses(); + const droid = statuses.find((entry) => entry.cli === "droid"); + + expect(droid).toEqual({ + cli: "droid", + installed: true, + path: fakeDroidPath, + authenticated: true, + verified: true, + }); + }); + it("does not report openai-compatible local providers when no models are loaded", async () => { vi.stubGlobal( "fetch", diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 568de48ae..98041bf57 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -2,6 +2,9 @@ // Auth Detector — discovers available authentication methods // --------------------------------------------------------------------------- +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { homedir } from "node:os"; import { spawnAsync } from "../shared/utils"; import { augmentProcessPathWithShellAndKnownCliDirs, @@ -11,8 +14,9 @@ import { import { getLocalProviderDefaultEndpoint, type LocalProviderFamily } from "../../../shared/modelRegistry"; import type { AiLocalProviderConfigs } from "../../../shared/types"; import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery"; +import { resolveDroidExecutable } from "./droidExecutable"; -type CliName = "claude" | "codex" | "cursor"; +type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; @@ -38,7 +42,7 @@ export type CliAuthStatus = { export type DetectedAuth = | { type: "cli-subscription"; - cli: CliName; + cli: "claude" | "codex" | "cursor" | "droid"; path: string; authenticated: boolean; verified: boolean; @@ -72,10 +76,12 @@ const CLI_AUTH_PROBES: Record = { ["status", "--json"], ["status"], ], + droid: [["--version"], ["-V"], ["version"]], }; function cliSpawnCommand(cli: CliName): string { - return cli === "cursor" ? "agent" : cli; + if (cli === "cursor") return "agent"; + return cli; } const AUTH_INDICATORS = [ @@ -366,6 +372,88 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: false, verified: false, paidPlan: false }; } +async function inspectDroidCliPresence(command: string): Promise<{ + installed: boolean; + authenticated: boolean; + verified: boolean; +}> { + const probes = CLI_AUTH_PROBES.droid ?? []; + let sawVersionOk = false; + for (const args of probes) { + try { + const result = await spawnAsync(command, args, { timeout: 8_000 }); + if (result.status === 0) { + sawVersionOk = true; + break; + } + } catch { + // try next probe + } + } + if (!sawVersionOk) { + return { installed: false, authenticated: false, verified: false }; + } + + if (process.env.FACTORY_API_KEY?.trim()) { + return { installed: true, authenticated: true, verified: true }; + } + + const settingsPath = path.join(homedir(), ".factory", "settings.json"); + try { + const raw = await readFile(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + const tokenLike = + typeof parsed.accessToken === "string" && parsed.accessToken.trim().length > 0 + ? parsed.accessToken + : typeof parsed.token === "string" && parsed.token.trim().length > 0 + ? parsed.token + : null; + if (tokenLike) { + return { installed: true, authenticated: true, verified: true }; + } + } catch { + // missing or unreadable settings — not authenticated via file + } + + try { + const result = await spawnAsync(command, ["exec", "--list-tools"], { timeout: 12_000 }); + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + const normalized = combined.toLowerCase(); + if (hasPattern(normalized, STRONG_UNAUTH_INDICATORS)) { + return { installed: true, authenticated: false, verified: true }; + } + if (result.status === 0) { + return { installed: true, authenticated: true, verified: true }; + } + if (hasPattern(normalized, AUTH_INDICATORS)) { + return { installed: true, authenticated: true, verified: true }; + } + } catch { + // Current Droid releases may not support this probe or it may time out; fall back. + } + + const authProbes: string[][] = [ + ["account", "status"], + ["whoami"], + ]; + for (const args of authProbes) { + try { + const result = await spawnAsync(command, args, { timeout: 12_000 }); + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (hasPattern(combined, STRONG_UNAUTH_INDICATORS)) { + return { installed: true, authenticated: false, verified: true }; + } + if (hasPattern(combined, AUTH_INDICATORS)) { + return { installed: true, authenticated: true, verified: true }; + } + } catch { + // try next probe + } + } + + return { installed: true, authenticated: false, verified: false }; +} + const ENV_KEY_MAP: Record = { ANTHROPIC_API_KEY: "anthropic", OPENAI_API_KEY: "openai", @@ -915,7 +1003,7 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom await refreshProcessPathFromShell(); } - const cliChecks: CliName[] = ["claude", "codex", "cursor"]; + const cliChecks: CliName[] = ["claude", "codex", "cursor", "droid"]; // Probe all CLIs in parallel const statuses = await Promise.all( @@ -944,6 +1032,34 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom paidPlan: auth.paidPlan, }; } + if (cli === "droid") { + // Prefer the path we already proved via commandPath() above; only fall + // back to resolveDroidExecutable() when commandPath() failed. + let droidPath: string; + if (path) { + droidPath = path; + } else { + const resolved = resolveDroidExecutable({ env: process.env }); + if (resolved.source === "fallback-command") { + return { + cli, + installed: false, + path: null, + authenticated: false, + verified: false, + }; + } + droidPath = resolved.path; + } + const auth = await inspectDroidCliPresence(droidPath); + return { + cli, + installed: auth.installed, + path: droidPath, + authenticated: auth.authenticated, + verified: auth.verified, + }; + } const auth = await inspectCliAuthentication(cli, cmd); return { cli, @@ -968,7 +1084,7 @@ export async function detectAllAuth( // 1. CLI subscriptions (connected and authenticated) const cliStatuses = await detectCliAuthStatuses(options); for (const cli of cliStatuses) { - if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor") continue; + if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor" && cli.cli !== "droid") continue; if (!cli.installed) continue; if (!cli.authenticated && cli.verified) continue; results.push({ @@ -997,6 +1113,23 @@ export async function detectAllAuth( } } + const factoryKey = process.env.FACTORY_API_KEY?.trim(); + if (factoryKey) { + const hasDroidCli = results.some((r) => r.type === "cli-subscription" && r.cli === "droid"); + if (!hasDroidCli) { + const resolved = resolveDroidExecutable({ env: process.env, auth: results }); + if (resolved.source !== "fallback-command") { + results.push({ + type: "cli-subscription", + cli: "droid", + path: resolved.path, + authenticated: true, + verified: true, + }); + } + } + } + // 2. API keys from config + secure local store const mergedApiKeys = new Map }>(); const normalizedConfig = normalizeApiKeys(configApiKeys); diff --git a/apps/desktop/src/main/services/ai/droidExecutable.test.ts b/apps/desktop/src/main/services/ai/droidExecutable.test.ts new file mode 100644 index 000000000..c13264a47 --- /dev/null +++ b/apps/desktop/src/main/services/ai/droidExecutable.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + resolveExecutableFromKnownLocations: vi.fn(), +})); + +vi.mock("./cliExecutableResolver", () => ({ + resolveExecutableFromKnownLocations: (...args: unknown[]) => + mockState.resolveExecutableFromKnownLocations(...args), +})); + +import { resolveDroidExecutable } from "./droidExecutable"; + +describe("resolveDroidExecutable", () => { + it("prefers DROID_EXECUTABLE env over auth detection and PATH lookup", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + + expect( + resolveDroidExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "droid", + path: "/Users/arul/.local/bin/droid", + authenticated: true, + verified: true, + }, + ], + env: { + DROID_EXECUTABLE: "/opt/droid/bin/droid", + PATH: "/usr/bin:/bin", + }, + }), + ).toEqual({ path: "/opt/droid/bin/droid", source: "path" }); + expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); + }); + + it("falls back to detected auth path before PATH lookup when no env override is set", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + + expect( + resolveDroidExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "droid", + path: "/Users/arul/.local/bin/droid", + authenticated: true, + verified: true, + }, + ], + env: { PATH: "/usr/bin:/bin" }, + }), + ).toEqual({ path: "/Users/arul/.local/bin/droid", source: "auth" }); + expect(mockState.resolveExecutableFromKnownLocations).not.toHaveBeenCalled(); + }); + + it("returns the bare 'droid' command as a fallback when no resolution succeeds", () => { + mockState.resolveExecutableFromKnownLocations.mockReset(); + mockState.resolveExecutableFromKnownLocations.mockReturnValue(null); + + expect(resolveDroidExecutable({ env: { PATH: "/usr/bin:/bin" } })).toEqual({ + path: "droid", + source: "fallback-command", + }); + expect(mockState.resolveExecutableFromKnownLocations).toHaveBeenCalledWith( + "droid", + expect.objectContaining({ PATH: "/usr/bin:/bin" }), + ); + }); +}); diff --git a/apps/desktop/src/main/services/ai/droidExecutable.ts b/apps/desktop/src/main/services/ai/droidExecutable.ts new file mode 100644 index 000000000..76fd015f4 --- /dev/null +++ b/apps/desktop/src/main/services/ai/droidExecutable.ts @@ -0,0 +1,44 @@ +import type { DetectedAuth } from "./authDetector"; +import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; + +export type DroidExecutableResolution = { + path: string; + source: "auth" | "path" | "common-dir" | "fallback-command"; +}; + +function findDroidAuthPath(auth?: DetectedAuth[]): string | null { + for (const entry of auth ?? []) { + if (entry.type !== "cli-subscription" || entry.cli !== "droid") continue; + const candidate = entry.path.trim(); + if (candidate) return candidate; + } + return null; +} + +/** Resolves the Factory Droid CLI binary (`droid`). */ +export function resolveDroidExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): DroidExecutableResolution { + const env = args?.env ?? process.env; + + const envPath = env.DROID_EXECUTABLE?.trim() || env.FACTORY_DROID_EXECUTABLE?.trim(); + if (envPath) { + return { path: envPath, source: "path" }; + } + + const authPath = findDroidAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + + const resolved = resolveExecutableFromKnownLocations("droid", env); + if (resolved) { + return { + path: resolved.path, + source: resolved.source === "path" ? "path" : "common-dir", + }; + } + + return { path: "droid", source: "fallback-command" }; +} diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 4bc88eb34..2d89872fe 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -9,7 +9,7 @@ import { getProviderRuntimeHealth } from "./providerRuntimeHealth"; import { nowIso } from "../shared/utils"; function createUnavailableStatus( - provider: "claude" | "codex" | "cursor", + provider: "claude" | "codex" | "cursor" | "droid", checkedAt: string, ): AiProviderConnectionStatus { return { @@ -104,7 +104,7 @@ export async function buildProviderConnections( } function buildStatus(args: { - provider: "claude" | "codex" | "cursor"; + provider: "claude" | "codex" | "cursor" | "droid"; flags: ReturnType; usageAvailable: boolean; cli: CliAuthStatus | null; @@ -221,5 +221,58 @@ export async function buildProviderConnections( }; // Cursor has no runtime-health probe yet. - return { claude, codex, cursor }; + const droidCli = cliStatuses.find((entry) => entry.cli === "droid") ?? null; + const factoryEnvAuth = Boolean(process.env.FACTORY_API_KEY?.trim()); + const droidRuntimeDetected = Boolean(droidCli?.installed); + const droidCliOk = Boolean(droidCli?.installed && droidCli.authenticated); + const droidExplicitlyBad = Boolean(droidCli?.installed && droidCli.verified && !droidCli.authenticated); + const droidAuthAvailable = Boolean(droidCliOk || factoryEnvAuth); + const droidRuntimeAvailable = Boolean( + droidAuthAvailable && droidRuntimeDetected && !(droidExplicitlyBad && !factoryEnvAuth), + ); + const droidFlags = { + runtimeDetected: droidRuntimeDetected, + cliAuthenticated: droidCliOk, + cliExplicitlyUnauthenticated: droidExplicitlyBad, + localCredsDetected: factoryEnvAuth, + authAvailable: droidAuthAvailable, + runtimeAvailable: droidRuntimeAvailable, + }; + + let droidBlocker: string | null = null; + if (!droidFlags.authAvailable && !droidFlags.runtimeDetected) { + droidBlocker = "No Factory Droid CLI (`droid`) or FACTORY_API_KEY was found locally."; + } else if (!droidFlags.authAvailable) { + droidBlocker = + "Droid CLI is installed but no credentials were detected. Set FACTORY_API_KEY or sign in with the Factory CLI (`droid`)."; + } else if (!droidFlags.runtimeDetected) { + droidBlocker = + "FACTORY_API_KEY is set, but ADE could not find the `droid` binary. Add Factory CLI to your PATH and refresh."; + } + + const droid: AiProviderConnectionStatus = { + ...createUnavailableStatus("droid", checkedAt), + authAvailable: droidFlags.authAvailable, + runtimeDetected: droidFlags.runtimeDetected, + runtimeAvailable: droidFlags.runtimeAvailable, + usageAvailable: droidRuntimeAvailable, + path: droidCli?.path ?? null, + sources: [ + { + kind: "local-credentials", + detected: factoryEnvAuth, + source: factoryEnvAuth ? "factory-env" : undefined, + }, + { + kind: "cli", + detected: Boolean(droidCli?.installed), + authenticated: droidCli?.authenticated, + verified: droidCli?.verified, + path: droidCli?.path ?? null, + }, + ], + blocker: droidBlocker, + }; + + return { claude, codex, cursor, droid }; } diff --git a/apps/desktop/src/main/services/chat/acpCliPool.ts b/apps/desktop/src/main/services/chat/acpCliPool.ts new file mode 100644 index 000000000..b7f34985f --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpCliPool.ts @@ -0,0 +1,276 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type InitializeResponse, +} from "@agentclientprotocol/sdk"; +import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; +import { createAcpHostClient } from "./acpHostClient"; +import { + destroyChildProcessStreams, + signalChildProcessTree, + terminateChildProcessTree, +} from "../shared/utils"; + +export type AcpCliSpawnSpec = { + command: string; + args: string[]; + cwd: string; + env?: NodeJS.ProcessEnv; +}; + +export type AcpCliPoolOptions = { + poolKey: string; + logPrefix: string; + spawn: AcpCliSpawnSpec; + appVersion: string; + afterInitialize?: (args: { + connection: ClientSideConnection; + initResult: InitializeResponse; + }) => Promise; +}; + +export type AcpCliPooled = { + connection: ClientSideConnection; + bridge: AcpHostBridge; + terminals: Map; + dispose: () => void; +}; + +const pools = new Map(); +/** In-flight initialization per pool key — concurrent acquires share one spawn + handshake. */ +const pendingInit = new Map>(); +const pendingInitProcesses = new Map(); +let poolEpoch = 0; + +const STDERR_LOG_MAX = 8_192; +const ACP_CLI_ACQUIRE_MAX_ATTEMPTS = 12; +const ACP_CLI_ACQUIRE_RETRY_BACKOFF_MS = 25; + +function killProcQuiet(proc: ChildProcessWithoutNullStreams | null): void { + if (!proc) return; + try { + signalChildProcessTree(proc, "SIGKILL"); + } catch { + // ignore + } + destroyChildProcessStreams(proc); +} + +function evictPoolEntry(poolKey: string, reason: string, err?: unknown): void { + const entry = pools.get(poolKey); + if (!entry) return; + console.error( + `${reason} for poolKey=${poolKey}:`, + err instanceof Error ? err.message : err ?? "", + ); + try { + entry.pooled.dispose(); + } catch { + // ignore + } + pools.delete(poolKey); +} + +export async function acquireAcpCliConnection(options: AcpCliPoolOptions): Promise { + const key = options.poolKey; + const initEpoch = poolEpoch; + + for (let attempt = 0; attempt < ACP_CLI_ACQUIRE_MAX_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((r) => setTimeout(r, ACP_CLI_ACQUIRE_RETRY_BACKOFF_MS)); + } + + const existing = pools.get(key); + if (existing) { + existing.ref += 1; + return existing.pooled; + } + + let initOwner = false; + let init = pendingInit.get(key); + if (!init) { + initOwner = true; + init = (async () => { + let proc: ChildProcessWithoutNullStreams | null = null; + const stderrChunks: Buffer[] = []; + const appendStderr = (d: Buffer | string): void => { + const buf = Buffer.isBuffer(d) ? d : Buffer.from(String(d), "utf8"); + stderrChunks.push(buf); + let total = 0; + for (const c of stderrChunks) total += c.length; + while (total > STDERR_LOG_MAX && stderrChunks.length > 1) { + total -= stderrChunks.shift()!.length; + } + }; + + try { + if (initEpoch !== poolEpoch) { + throw new Error("acpCliPool shutdown before spawn"); + } + proc = spawn(options.spawn.command, options.spawn.args, { + stdio: ["pipe", "pipe", "pipe"], + env: options.spawn.env ?? { ...process.env }, + cwd: options.spawn.cwd, + detached: process.platform !== "win32", + }); + pendingInitProcesses.set(key, proc); + + let failureHandled = false; + const onProcFailure = (label: string, err?: unknown) => { + if (failureHandled) return; + failureHandled = true; + const tail = Buffer.concat(stderrChunks).toString("utf8").trim(); + if (tail) { + console.error(`${options.logPrefix} ${label} stderr (tail) poolKey=${key}:`, tail); + } + killProcQuiet(proc); + evictPoolEntry(key, `${options.logPrefix} ${label}`, err); + }; + + proc.once("error", (err) => { + onProcFailure("process error", err); + }); + proc.once("close", (code, signal) => { + if (!pools.has(key)) return; + onProcFailure(`process closed code=${code} signal=${signal}`); + }); + + proc.stderr?.on("data", appendStderr); + + const terminals = new Map(); + const bridge: AcpHostBridge = { + onPermission: null, + onSessionUpdate: null, + getRootPath: () => options.spawn.cwd || "", + getDirtyFileText: null, + onTerminalOutputDelta: null, + flushTerminalOutput: null, + onTerminalDisposed: null, + }; + + const client = createAcpHostClient(bridge, terminals, { logPrefix: options.logPrefix }); + const toAgentStdin = Writable.toWeb(proc.stdin as Writable); + const fromAgentStdout = Readable.toWeb(proc.stdout as Readable); + const stream = ndJsonStream( + toAgentStdin as unknown as WritableStream, + fromAgentStdout as unknown as ReadableStream, + ); + const connection = new ClientSideConnection(() => client, stream); + + const initResult = await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "ade", title: "ADE", version: options.appVersion }, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + }); + + if (options.afterInitialize) { + await options.afterInitialize({ connection, initResult }); + } + + const pooled: AcpCliPooled = { + connection, + bridge, + terminals, + dispose: () => { + for (const termId of terminals.keys()) { + bridge.onTerminalDisposed?.(termId); + } + for (const t of terminals.values()) { + try { + if (!t.exited) signalChildProcessTree(t.proc, "SIGKILL"); + } catch { + // ignore + } + destroyChildProcessStreams(t.proc); + } + terminals.clear(); + try { + if (proc) { + terminateChildProcessTree(proc, null, 1_500); + } + } catch { + // ignore + } + }, + }; + + if (initEpoch !== poolEpoch) { + throw new Error("acpCliPool shutdown during initialization"); + } + pools.set(key, { ref: 1, pooled }); + } catch (err) { + const tail = Buffer.concat(stderrChunks).toString("utf8").trim(); + if (tail) { + console.error(`${options.logPrefix} init failed stderr (tail) poolKey=${key}:`, tail); + } + killProcQuiet(proc); + evictPoolEntry(key, `${options.logPrefix} initialization failed`, err); + throw err; + } finally { + pendingInitProcesses.delete(key); + } + })().finally(() => { + pendingInit.delete(key); + }); + pendingInit.set(key, init); + } + + try { + await init; + } catch (err) { + if (initOwner) throw err; + continue; + } + + const entry = pools.get(key); + if (!entry) { + continue; + } + if (!initOwner) { + entry.ref += 1; + } + return entry.pooled; + } + + throw new Error( + `acpCliPool: exceeded ${ACP_CLI_ACQUIRE_MAX_ATTEMPTS} acquire attempts for poolKey=${key} (init or pool entry never became ready).`, + ); +} + +export function releaseAcpCliConnection(poolKey: string): void { + const entry = pools.get(poolKey); + if (!entry) return; + entry.ref -= 1; + if (entry.ref <= 0) { + entry.pooled.dispose(); + pools.delete(poolKey); + } +} + +/** True when the inner ACP pool still holds a live connection for this key (not evicted after process exit). */ +export function hasActiveAcpCliPoolEntry(poolKey: string): boolean { + return pools.has(poolKey); +} + +export function shutdownAcpCliConnections(): void { + poolEpoch += 1; + for (const entry of pools.values()) { + try { + entry.pooled.dispose(); + } catch { + // ignore + } + } + pools.clear(); + for (const proc of pendingInitProcesses.values()) { + killProcQuiet(proc); + } + pendingInitProcesses.clear(); + pendingInit.clear(); +} diff --git a/apps/desktop/src/main/services/chat/acpHostClient.ts b/apps/desktop/src/main/services/chat/acpHostClient.ts new file mode 100644 index 000000000..5528fb528 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHostClient.ts @@ -0,0 +1,311 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { + type CreateTerminalRequest, + type KillTerminalRequest, + type ReadTextFileRequest, + type ReadTextFileResponse, + type ReleaseTerminalRequest, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type TerminalOutputRequest, + type TerminalOutputResponse, + type WaitForTerminalExitRequest, + type WaitForTerminalExitResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, + type Client, +} from "@agentclientprotocol/sdk"; +import { + hasNullByte, + readFileWithinRootSecure, + resolvePathWithinRoot, + secureWriteTextAtomicWithinRoot, + destroyChildProcessStreams, + signalChildProcessTree, + terminateChildProcessTree, +} from "../shared/utils"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; + +/** Bridge hooks for an ACP host (Cursor agent, Droid exec, etc.). */ +export type AcpHostBridge = { + onPermission: ((req: RequestPermissionRequest) => Promise) | null; + onSessionUpdate: ((n: SessionNotification) => void) | null; + getRootPath: () => string; + getDirtyFileText: ((absPath: string) => string | undefined | Promise) | null; + onTerminalOutputDelta: ((terminalId: string, acpSessionId: string) => void) | null; + flushTerminalOutput: ((terminalId: string, acpSessionId: string) => void) | null; + onTerminalDisposed: ((terminalId: string) => void) | null; +}; + +export type AcpHostTermState = { + proc: ChildProcessWithoutNullStreams; + output: string; + truncated: boolean; + limit: number; + cwd: string; + command: string; + exited: boolean; + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + acpSessionId: string; +}; + +function mergeEnvVars( + base: NodeJS.ProcessEnv, + extra?: Array<{ name: string; value: string }>, +): NodeJS.ProcessEnv { + const out = { ...base }; + if (!extra) return out; + for (const { name, value } of extra) { + if (name) out[name] = value; + } + return out; +} + +function appendOutput(state: AcpHostTermState, chunk: Buffer | string): void { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + state.output += text; + const lim = state.limit > 0 ? state.limit : 512 * 1024; + if (state.output.length > lim) { + state.output = state.output.slice(state.output.length - lim); + state.truncated = true; + } +} + +function applyLineLimit(text: string, line?: number | null, limit?: number | null): string { + const lines = text.split(/\r?\n/); + const start = typeof line === "number" && line > 0 ? line - 1 : 0; + const max = typeof limit === "number" && limit > 0 ? limit : lines.length; + return lines.slice(start, start + max).join("\n"); +} + +async function resolveDirtyText( + bridge: AcpHostBridge, + filePath: string, +): Promise { + const raw = bridge.getDirtyFileText?.(filePath); + const v = await Promise.resolve(raw); + return typeof v === "string" ? v : undefined; +} + +export type CreateAcpHostClientOptions = { + /** Log prefix, e.g. `[CursorAcpPool]` */ + logPrefix: string; +}; + +const WAIT_FOR_TERMINAL_EXIT_MAX_MS = 5 * 60_000; + +/** + * ACP `Client` implementation shared by Cursor (`agent acp`) and Factory Droid (`droid exec --output-format acp`). + */ +export function createAcpHostClient( + bridge: AcpHostBridge, + terminals: Map, + options: CreateAcpHostClientOptions, +): Client { + const { logPrefix } = options; + return { + async requestPermission(params: RequestPermissionRequest): Promise { + const handler = bridge.onPermission; + if (!handler) { + return { outcome: { outcome: "cancelled" } }; + } + return handler(params); + }, + + async sessionUpdate(params: SessionNotification): Promise { + bridge.onSessionUpdate?.(params); + }, + + async readTextFile(params: ReadTextFileRequest): Promise { + const p = params.path.trim(); + if (!path.isAbsolute(p)) { + throw new Error("ACP read_text_file requires an absolute path."); + } + const root = bridge.getRootPath(); + let buf: Buffer; + try { + buf = readFileWithinRootSecure(root, p); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err?.code === "ENOENT") { + const dirty = await resolveDirtyText(bridge, p); + if (dirty !== undefined) return { content: applyLineLimit(dirty, params.line, params.limit) }; + } + throw e; + } + if (hasNullByte(buf)) { + throw new Error("Binary files cannot be read as text."); + } + let text = buf.toString("utf8"); + const dirty = await resolveDirtyText(bridge, p); + if (dirty !== undefined) text = dirty; + return { content: applyLineLimit(text, params.line, params.limit) }; + }, + + async writeTextFile(params: WriteTextFileRequest): Promise { + const p = params.path.trim(); + if (!path.isAbsolute(p)) { + throw new Error("ACP write_text_file requires an absolute path."); + } + const root = bridge.getRootPath(); + secureWriteTextAtomicWithinRoot(root, p, params.content); + return {}; + }, + + async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { + const root = bridge.getRootPath(); + const requested = (params.cwd && params.cwd.trim()) || root; + let cwd = root; + try { + cwd = resolvePathWithinRoot(root, requested, { allowMissing: true }); + } catch (e) { + console.warn(`${logPrefix} terminal cwd rejected (outside lane root), using root:`, e); + } + const termId = randomUUID(); + const limit = typeof params.outputByteLimit === "number" && params.outputByteLimit > 0 + ? params.outputByteLimit + : 512 * 1024; + const env = mergeEnvVars(process.env, params.env ?? undefined); + const invocation = resolveCliSpawnInvocation(params.command, params.args ?? [], env); + const proc = spawn(invocation.command, invocation.args, { + cwd, + env, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + proc.on("error", (err) => { + console.error(`${logPrefix} terminal process error for termId=${termId}:`, err); + const t = terminals.get(termId); + if (t && !t.exited) { + t.exited = true; + t.exitCode = -1; + bridge.flushTerminalOutput?.(termId, params.sessionId); + } + }); + const state: AcpHostTermState = { + proc, + output: "", + truncated: false, + limit, + cwd, + command: `${params.command} ${(params.args ?? []).join(" ")}`.trim(), + exited: false, + exitCode: null, + exitSignal: null, + acpSessionId: params.sessionId, + }; + proc.stdout?.on("data", (d) => { + appendOutput(state, d); + bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); + }); + proc.stderr?.on("data", (d) => { + appendOutput(state, d); + bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); + }); + proc.on("close", (code, signal) => { + state.exited = true; + state.exitCode = code; + state.exitSignal = signal; + bridge.flushTerminalOutput?.(termId, state.acpSessionId); + }); + terminals.set(termId, state); + return { terminalId: termId }; + }, + + async terminalOutput(params: TerminalOutputRequest): Promise { + const t = terminals.get(params.terminalId); + if (!t) { + return { output: "", truncated: false }; + } + return { + output: t.output, + truncated: t.truncated, + ...(t.exited ? { exitStatus: { exitCode: t.exitCode, signal: t.exitSignal } } : {}), + }; + }, + + async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise { + const t = terminals.get(params.terminalId); + if (!t) { + return { exitCode: -1, signal: null }; + } + if (!t.exited) { + let killTimer: ReturnType | undefined; + const closed = new Promise((resolve) => { + t.proc.once("close", () => { + if (killTimer !== undefined) clearTimeout(killTimer); + resolve(); + }); + }); + const timedOut = new Promise((resolve) => { + killTimer = setTimeout(() => { + try { + if (!t.exited) { + killTimer = terminateChildProcessTree(t.proc, killTimer ?? null, 1_500); + } + } catch { + // ignore + } + console.warn( + `${logPrefix} waitForTerminalExit exceeded ${WAIT_FOR_TERMINAL_EXIT_MAX_MS}ms; initiated tree termination`, + ); + resolve(); + }, WAIT_FOR_TERMINAL_EXIT_MAX_MS); + }); + await Promise.race([closed, timedOut]); + if (!t.exited) { + await new Promise((resolve) => { + const tmo = setTimeout(resolve, 15_000); + t.proc.once("close", () => { + clearTimeout(tmo); + resolve(); + }); + }); + } + } + return { exitCode: t.exitCode ?? -1, signal: t.exitSignal }; + }, + + async killTerminal(params: KillTerminalRequest): Promise { + const t = terminals.get(params.terminalId); + if (t && !t.exited) { + try { + signalChildProcessTree(t.proc, "SIGTERM"); + } catch { + // ignore + } + // Escalate to SIGKILL if the child has not exited within the grace window. + const escalation = setTimeout(() => { + if (!t.exited) { + try { + signalChildProcessTree(t.proc, "SIGKILL"); + } catch { + // ignore + } + } + }, 1_500); + t.proc.once("close", () => clearTimeout(escalation)); + } + }, + + async releaseTerminal(params: ReleaseTerminalRequest): Promise { + const t = terminals.get(params.terminalId); + if (t) { + try { + if (!t.exited) signalChildProcessTree(t.proc, "SIGKILL"); + } catch { + // ignore + } + destroyChildProcessStreams(t.proc); + const id = params.terminalId; + terminals.delete(id); + bridge.onTerminalDisposed?.(id); + } + }, + }; +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 468de8cdd..c06956fde 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; }>(), + droidSessionCounter: 0, codexRequestPayloads: [] as Array>, codexResponseOverrides: new Map | ((payload: Record) => Record)>(), delayedCodexMethods: new Set(), @@ -44,6 +45,9 @@ const mockState = vi.hoisted(() => ({ cursorAcquireCalls: [] as Array>, cursorNewSessionCalls: [] as Array>, cursorPromptCalls: [] as Array>, + droidAcquireCalls: [] as Array>, + droidNewSessionCalls: [] as Array>, + droidPromptCalls: [] as Array>, emitCodexPayload(payload: Record) { mockState.codexLineHandler?.(JSON.stringify(payload)); }, @@ -401,6 +405,10 @@ vi.mock("../ai/cursorAgentExecutable", () => ({ resolveCursorAgentExecutable: vi.fn(() => ({ path: "/usr/local/bin/agent", source: "path" })), })); +vi.mock("../ai/droidExecutable", () => ({ + resolveDroidExecutable: vi.fn(() => ({ path: "/usr/local/bin/droid", source: "path" })), +})); + vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []), })); @@ -431,45 +439,93 @@ vi.mock("./cursorAcpPool", () => ({ acquireCursorAcpConnection: vi.fn(async (args: Record) => { mockState.cursorAcquireCalls.push(args); return { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.cursorNewSessionCalls.push(params); - mockState.cursorSessionCounter += 1; - return { - sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, - modes: { currentModeId: "edit" }, - models: { currentModelId: "auto" }, - configOptions: [], - }; - }), - prompt: vi.fn(async (params: Record) => { - mockState.cursorPromptCalls.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, + generation: 1, + pooled: { + connection: { + newSession: vi.fn(async (params: Record) => { + mockState.cursorNewSessionCalls.push(params); + mockState.cursorSessionCounter += 1; + return { + sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + modes: { currentModeId: "edit" }, + models: { currentModelId: "auto" }, + configOptions: [], + }; + }), + prompt: vi.fn(async (params: Record) => { + mockState.cursorPromptCalls.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(), }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), }; }), releaseCursorAcpConnection: vi.fn(), })); +vi.mock("./droidAcpPool", () => ({ + acquireDroidAcpConnection: vi.fn(async (args: Record) => { + mockState.droidAcquireCalls.push(args); + 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(), + }, + }; + }), + releaseDroidAcpConnection: vi.fn(), +})); + // --------------------------------------------------------------------------- // Import system under test (after mocks) // --------------------------------------------------------------------------- @@ -487,6 +543,7 @@ import { runGit } from "../git/git"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; import { acquireCursorAcpConnection } from "./cursorAcpPool"; +import { acquireDroidAcpConnection } from "./droidAcpPool"; import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; import { createDynamicOpenCodeModelDescriptor, @@ -826,6 +883,7 @@ beforeEach(() => { mockState.cursorSessionCounter = 0; mockState.openCodeSessionCounter = 0; mockState.openCodeSessions.clear(); + mockState.droidSessionCounter = 0; mockState.codexRequestPayloads = []; mockState.codexResponseOverrides.clear(); mockState.delayedCodexMethods.clear(); @@ -835,7 +893,11 @@ beforeEach(() => { mockState.cursorAcquireCalls = []; mockState.cursorNewSessionCalls = []; mockState.cursorPromptCalls = []; + mockState.droidAcquireCalls = []; + mockState.droidNewSessionCalls = []; + mockState.droidPromptCalls = []; vi.mocked(acquireCursorAcpConnection).mockClear(); + vi.mocked(acquireDroidAcpConnection).mockClear(); vi.mocked(streamText).mockReset(); vi.mocked(unstable_v2_createSession).mockReset(); vi.mocked(detectAllAuth).mockResolvedValue([]); @@ -9187,38 +9249,41 @@ describe("createAgentChatService", () => { vi.mocked(acquireCursorAcpConnection).mockImplementationOnce(async (args: Record) => { mockState.cursorAcquireCalls.push(args); return { - connection: { - newSession: vi.fn(() => new Promise((resolve) => { - resolveNewSession = resolve; - })), - loadSession: vi.fn(async () => ({ - modes: { currentModeId: "edit" }, - models: { - currentModelId: "auto", - availableModels: [{ modelId: "auto", name: "Auto" }], - }, - configOptions: [], - })), - prompt: vi.fn(async () => ({ - 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, + generation: 1, + pooled: { + connection: { + newSession: vi.fn(() => new Promise((resolve) => { + resolveNewSession = resolve; + })), + loadSession: vi.fn(async () => ({ + modes: { currentModeId: "edit" }, + models: { + currentModelId: "auto", + availableModels: [{ modelId: "auto", name: "Auto" }], + }, + configOptions: [], + })), + prompt: vi.fn(async () => ({ + 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(), }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), } as any; }); @@ -9309,57 +9374,60 @@ describe("createAgentChatService", () => { vi.mocked(acquireCursorAcpConnection).mockImplementationOnce(async (args: Record) => { mockState.cursorAcquireCalls.push(args); return { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.cursorNewSessionCalls.push(params); - mockState.cursorSessionCounter += 1; - return { - sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + generation: 1, + pooled: { + connection: { + newSession: vi.fn(async (params: Record) => { + mockState.cursorNewSessionCalls.push(params); + mockState.cursorSessionCounter += 1; + return { + sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + modes: { currentModeId: "edit" }, + models: { + currentModelId: "auto", + availableModels: [{ modelId: "auto", name: "Auto" }], + }, + configOptions: [], + }; + }), + loadSession: vi.fn(async () => ({ modes: { currentModeId: "edit" }, models: { currentModelId: "auto", availableModels: [{ modelId: "auto", name: "Auto" }], }, configOptions: [], - }; - }), - loadSession: vi.fn(async () => ({ - modes: { currentModeId: "edit" }, - models: { - currentModelId: "auto", - availableModels: [{ modelId: "auto", name: "Auto" }], - }, - configOptions: [], - })), - prompt: vi.fn((params: Record) => { - mockState.cursorPromptCalls.push(params); - promptCall += 1; - if (promptCall === 1) { - return new Promise((resolve) => { - resolveFirstPrompt = resolve as typeof resolveFirstPrompt; + })), + prompt: vi.fn((params: Record) => { + mockState.cursorPromptCalls.push(params); + promptCall += 1; + if (promptCall === 1) { + return new Promise((resolve) => { + resolveFirstPrompt = resolve as typeof resolveFirstPrompt; + }); + } + return Promise.resolve({ + stopReason: "end_turn", + usage: { inputTokens: 2, outputTokens: 3 }, }); - } - return Promise.resolve({ - stopReason: "end_turn", - usage: { inputTokens: 2, outputTokens: 3 }, - }); - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, + }), + 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(), }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), } as any; }); @@ -9450,57 +9518,60 @@ describe("createAgentChatService", () => { vi.mocked(acquireCursorAcpConnection).mockImplementationOnce(async (args: Record) => { mockState.cursorAcquireCalls.push(args); return { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.cursorNewSessionCalls.push(params); - mockState.cursorSessionCounter += 1; - return { - sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + generation: 1, + pooled: { + connection: { + newSession: vi.fn(async (params: Record) => { + mockState.cursorNewSessionCalls.push(params); + mockState.cursorSessionCounter += 1; + return { + sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + modes: { currentModeId: "edit" }, + models: { + currentModelId: "claude-4-sonnet", + availableModels: [ + { modelId: "auto", name: "Auto" }, + { modelId: "claude-4-sonnet", name: "Claude 4 Sonnet" }, + ], + }, + configOptions: [], + }; + }), + prompt: vi.fn(async (params: Record) => { + mockState.cursorPromptCalls.push(params); + return { + stopReason: "end_turn", + usage: { inputTokens: 2, outputTokens: 4 }, + }; + }), + loadSession: vi.fn(async () => ({ modes: { currentModeId: "edit" }, models: { - currentModelId: "claude-4-sonnet", + currentModelId: "auto", availableModels: [ { modelId: "auto", name: "Auto" }, { modelId: "claude-4-sonnet", name: "Claude 4 Sonnet" }, ], }, configOptions: [], - }; - }), - prompt: vi.fn(async (params: Record) => { - mockState.cursorPromptCalls.push(params); - return { - stopReason: "end_turn", - usage: { inputTokens: 2, outputTokens: 4 }, - }; - }), - loadSession: vi.fn(async () => ({ - modes: { currentModeId: "edit" }, - models: { - currentModelId: "auto", - availableModels: [ - { modelId: "auto", name: "Auto" }, - { modelId: "claude-4-sonnet", name: "Claude 4 Sonnet" }, - ], - }, - configOptions: [], - })), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, + })), + 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(), }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), } as any; }); @@ -9538,47 +9609,50 @@ describe("createAgentChatService", () => { vi.mocked(acquireCursorAcpConnection).mockImplementationOnce(async (args: Record) => { mockState.cursorAcquireCalls.push(args); return { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.cursorNewSessionCalls.push(params); - mockState.cursorSessionCounter += 1; - return { - sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, - modes: { currentModeId: "edit" }, - models: { - currentModelId: "default[]", - availableModels: [ - { modelId: "default[]", name: "Default" }, - { modelId: "auto", name: "Auto" }, - { modelId: "claude-4-sonnet", name: "Claude 4 Sonnet" }, - ], - }, - configOptions: [], - }; - }), - prompt: vi.fn(async (params: Record) => { - mockState.cursorPromptCalls.push(params); + generation: 1, + pooled: { + connection: { + newSession: vi.fn(async (params: Record) => { + mockState.cursorNewSessionCalls.push(params); + mockState.cursorSessionCounter += 1; return { - stopReason: "end_turn", - usage: { inputTokens: 2, outputTokens: 4 }, - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, + sessionId: `cursor-acp-session-${mockState.cursorSessionCounter}`, + modes: { currentModeId: "edit" }, + models: { + currentModelId: "default[]", + availableModels: [ + { modelId: "default[]", name: "Default" }, + { modelId: "auto", name: "Auto" }, + { modelId: "claude-4-sonnet", name: "Claude 4 Sonnet" }, + ], + }, + configOptions: [], + }; + }), + prompt: vi.fn(async (params: Record) => { + mockState.cursorPromptCalls.push(params); + return { + stopReason: "end_turn", + usage: { inputTokens: 2, outputTokens: 4 }, + }; + }), + 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(), }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), } as any; }); @@ -9605,6 +9679,336 @@ describe("createAgentChatService", () => { expect(persisted.modelId).toBe("cursor/auto"); }); + it("realigns a new Droid ACP session to the selected model before prompting", async () => { + const events: AgentChatEventEnvelope[] = []; + let currentModelId = "claude-opus-4-6"; + const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { + currentModelId = modelId; + }); + const loadSession = vi.fn(async () => ({ + models: { + currentModelId, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, + ], + }, + configOptions: [], + })); + + vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { + mockState.droidAcquireCalls.push(args); + 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, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, + ], + }, + configOptions: [], + }; + }), + loadSession, + unstable_setSessionModel: setSessionModel, + prompt: vi.fn(async (params: Record) => { + mockState.droidPromptCalls.push(params); + return { + stopReason: "end_turn", + usage: { inputTokens: 2, outputTokens: 4 }, + }; + }), + 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(), + }, + } as any; + }); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + 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 the selected Droid model.", + }, { awaitDispatch: true }); + + const doneEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "done" && event.sessionId === session.id, + ); + const updated = await service.getSessionSummary(session.id); + + expect(mockState.droidAcquireCalls[0]?.modelId).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(setSessionModel).toHaveBeenCalledWith({ + sessionId: "droid-acp-session-1", + modelId: "custom:claude-sonnet-4-6-thinking-32000", + }); + // The model switch must complete before the first prompt() call so the + // agent never receives the prompt under the wrong model. + const setModelOrder = setSessionModel.mock.invocationCallOrder[0]; + const firstPromptOrder = ( + mockState.droidPromptCalls.length > 0 + ? (vi.mocked(acquireDroidAcpConnection).mock.results[0]?.value as any)?.pooled?.connection?.prompt?.mock?.invocationCallOrder?.[0] + : undefined + ); + expect(setModelOrder).toBeDefined(); + expect(firstPromptOrder).toBeDefined(); + expect(setModelOrder).toBeLessThan(firstPromptOrder!); + expect(updated?.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); + expect(doneEvent.event.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); + }); + + it("translates Droid custom help ids to ACP session ids before setting the model", async () => { + const events: AgentChatEventEnvelope[] = []; + let currentModelId = "claude-opus-4-6"; + const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { + if (modelId !== "custom:Claude-Sonnet-4.6-(High)-1") { + const error = new Error("Invalid params: Model not recognized") as Error & { + code?: number; + data?: Record; + }; + error.code = -32602; + error.data = { modelId }; + throw error; + } + currentModelId = modelId; + }); + + vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { + mockState.droidAcquireCalls.push(args); + 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, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:Claude-Sonnet-4.6-(High)-1", name: "Claude Sonnet 4.6 (High)" }, + ], + }, + configOptions: [], + }; + }), + loadSession: vi.fn(async () => ({})), + unstable_setSessionModel: setSessionModel, + prompt: vi.fn(async (params: Record) => { + mockState.droidPromptCalls.push(params); + return { + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 2 }, + }; + }), + 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(), + }, + } as any; + }); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + 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 the high-reasoning custom Sonnet model.", + }, { awaitDispatch: true }); + + const doneEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "done" && event.sessionId === session.id, + ); + const updated = await service.getSessionSummary(session.id); + + expect(setSessionModel).toHaveBeenCalledWith({ + sessionId: "droid-acp-session-1", + modelId: "custom:Claude-Sonnet-4.6-(High)-1", + }); + // The untranslated help id ("custom:claude-sonnet-4-6-thinking-32000") + // must never be sent to ACP — only the translated ACP-recognized id. + const sentModelIds = setSessionModel.mock.calls.map((call) => (call[0] as { modelId: string }).modelId); + expect(sentModelIds).not.toContain("custom:claude-sonnet-4-6-thinking-32000"); + expect(updated?.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); + expect(doneEvent.event.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); + }); + + it("realigns a resumed Droid ACP session to the selected model during warmup", async () => { + let currentModelId = "claude-opus-4-6"; + const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { + currentModelId = modelId; + }); + const loadSession = vi.fn(async () => ({ + models: { + currentModelId, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, + ], + }, + configOptions: [], + })); + const resumeSession = vi.fn(async () => ({ + models: { + currentModelId, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, + ], + }, + configOptions: [], + })); + + vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { + mockState.droidAcquireCalls.push(args); + 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, + availableModels: [ + { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, + ], + }, + configOptions: [], + }; + }), + unstable_resumeSession: resumeSession, + loadSession, + unstable_setSessionModel: setSessionModel, + prompt: vi.fn(async (params: Record) => { + mockState.droidPromptCalls.push(params); + return { + stopReason: "end_turn", + usage: { inputTokens: 2, outputTokens: 4 }, + }; + }), + 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(), + }, + } as any; + }); + + const { service } = 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", + }); + + const persisted = readPersistedChatState(session.id); + writePersistedChatState(session.id, { + ...persisted, + acpSessionId: "persisted-droid-session-1", + }); + + await service.warmupModel({ + sessionId: session.id, + modelId: "droid/custom:claude-sonnet-4-6-thinking-32000", + }); + + const updated = await service.getSessionSummary(session.id); + + expect(resumeSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "persisted-droid-session-1", + cwd: fs.realpathSync(tmpRoot), + mcpServers: expect.any(Array), + })); + expect(setSessionModel).toHaveBeenCalledWith({ + sessionId: "persisted-droid-session-1", + modelId: "custom:claude-sonnet-4-6-thinking-32000", + }); + expect(mockState.droidNewSessionCalls).toHaveLength(0); + expect(updated?.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); + expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); + }); + it("prefers an explicit Cursor mode over legacy full-auto launch settings", async () => { const { service } = createService(); @@ -9629,4 +10033,88 @@ describe("createAgentChatService", () => { force: false, }); }); + + it("surfaces structured Droid ACP failures without collapsing them to [object Object]", async () => { + const events: AgentChatEventEnvelope[] = []; + + vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { + mockState.droidAcquireCalls.push(args); + 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: "custom:Claude-Sonnet-4.6-(High)-1", + availableModels: [ + { modelId: "custom:Claude-Sonnet-4.6-(High)-1", name: "Claude Sonnet 4.6 (High)" }, + ], + }, + configOptions: [], + }; + }), + loadSession: vi.fn(async () => ({})), + unstable_setSessionModel: vi.fn(async () => {}), + prompt: vi.fn(async () => { + throw { + code: -32603, + message: "Connection error.", + data: "This might be a network issue. Please check your internet connection.", + }; + }), + 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(), + }, + } as any; + }); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + 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: "test", + }, { awaitDispatch: true }); + + const errorEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "error" && event.sessionId === session.id, + ); + + expect(errorEvent.event.message).toBe("Connection error."); + expect(errorEvent.event.detail).toContain("network issue"); + expect(errorEvent.event.errorInfo).toEqual({ + category: "network", + provider: "Factory Droid", + model: "Claude Sonnet 4.6 (High)", + }); + }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1352e7833..511ef0480 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -85,6 +85,7 @@ import type { AgentChatDeleteArgs, AgentChatDispatchSteerArgs, AgentChatDispatchSteerResult, + AgentChatDroidPermissionMode, AgentChatCancelDispatchedSteerArgs, AgentChatCancelDispatchedSteerResult, AgentChatDisposeArgs, @@ -139,6 +140,7 @@ import { LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, pickDefaultCursorDescriptorFromCliList, + pickDefaultDroidDescriptorFromCliList, resolveModelAlias, resolveModelDescriptorForProvider, resolveProviderGroupForModel, @@ -190,15 +192,32 @@ import { } from "../opencode/openCodeRuntime"; import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; -import type { PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; +import type { + ClientSideConnection, + CloseSessionRequest, + CloseSessionResponse, + PermissionOption, + RequestPermissionRequest, + RequestPermissionResponse, + ResumeSessionRequest, + ResumeSessionResponse, +} from "@agentclientprotocol/sdk"; import { resolveCursorAgentExecutable } from "../ai/cursorAgentExecutable"; +import { resolveDroidExecutable } from "../ai/droidExecutable"; import { acquireCursorAcpConnection, releaseCursorAcpConnection, type CursorAcpLaunchSettings, type CursorAcpPooled, } from "./cursorAcpPool"; +import { + acquireDroidAcpConnection, + releaseDroidAcpConnection, + type DroidAcpLaunchSettings, + type DroidAcpPooled, +} from "./droidAcpPool"; import { discoverCursorCliModelDescriptors } from "./cursorModelsDiscovery"; +import { discoverDroidCliModelDescriptors } from "./droidModelsDiscovery"; import { mapAcpSessionNotificationToChatEvents, mapStopReasonToTerminalEvents, @@ -244,6 +263,7 @@ type PersistedChatState = { codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeSnapshot?: AgentChatCursorModeSnapshot; cursorModeId?: string | null; cursorConfigValues?: Record; @@ -508,6 +528,7 @@ type CursorPermissionWaiter = { type CursorRuntime = { kind: "cursor"; poolKey: string; + poolGeneration: number; pooled: CursorAcpPooled | null; acpSessionId: string | null; activeTurnId: string | null; @@ -526,7 +547,27 @@ type CursorRuntime = { configOptions: AgentChatCursorConfigOption[]; }; -type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime; +type DroidRuntime = { + kind: "droid"; + poolKey: string; + poolGeneration: number; + pooled: DroidAcpPooled | null; + acpSessionId: string | null; + activeTurnId: string | null; + busy: boolean; + interrupted: boolean; + /** The model ADE intends this session to use. */ + modelId: string; + /** The model ACP reports the live session is currently using. */ + currentModelId: string | null; + availableModelIds: string[]; + acpModelIdByDisplayKey: Map; + displayKeyByAcpModelId: Map; + pendingSteers: QueuedSteer[]; + permissionWaiters: Map; +}; + +type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -707,11 +748,11 @@ function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true if (managed.closed) return { ready: false, reason: "Session is disposed" }; if (!managed.runtime) return { ready: false, reason: "No runtime initialized" }; const rt = managed.runtime; - if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor") && rt.busy) { + if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid") && rt.busy) { return { ready: false, reason: "Turn already active" }; } if (rt.kind === "opencode" && rt.pendingApprovals.size > 0) return { ready: false, reason: "Pending approvals not resolved" }; - if (rt.kind === "cursor" && rt.permissionWaiters.size > 0) { + if ((rt.kind === "cursor" || rt.kind === "droid") && rt.permissionWaiters.size > 0) { return { ready: false, reason: "Pending permissions not resolved" }; } return { ready: true }; @@ -725,7 +766,7 @@ function hasLivePendingInput(managed: ManagedChatSession | null | undefined): bo if (runtime.kind === "codex") return runtime.approvals.size > 0; if (runtime.kind === "claude") return runtime.approvals.size > 0; if (runtime.kind === "opencode") return runtime.pendingApprovals.size > 0; - if (runtime.kind === "cursor") return runtime.permissionWaiters.size > 0; + if (runtime.kind === "cursor" || runtime.kind === "droid") return runtime.permissionWaiters.size > 0; return false; } @@ -977,6 +1018,7 @@ type PreparedSendMessage = { onDispatched?: () => void; turnId?: string; optimisticCursorTurnStart?: boolean; + optimisticAcpTurnStart?: boolean; }; type ResolvedAgentChatFileRef = AgentChatFileRef & { @@ -1004,10 +1046,12 @@ const DEFAULT_CODEX_DESCRIPTOR = getDefaultModelDescriptor("codex"); const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); const DEFAULT_OPENCODE_DESCRIPTOR = getDefaultModelDescriptor("opencode"); const DEFAULT_CURSOR_DESCRIPTOR = getDefaultModelDescriptor("cursor"); +const DEFAULT_DROID_DESCRIPTOR = getDefaultModelDescriptor("droid"); const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.providerModelId ?? "gpt-5.5"; const DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_DESCRIPTOR?.providerModelId ?? DEFAULT_CLAUDE_DESCRIPTOR?.shortId ?? "sonnet"; const DEFAULT_OPENCODE_MODEL_ID = DEFAULT_OPENCODE_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-4-6"; const DEFAULT_CURSOR_MODEL = DEFAULT_CURSOR_DESCRIPTOR?.providerModelId ?? "auto"; +const DEFAULT_DROID_MODEL = DEFAULT_DROID_DESCRIPTOR?.providerModelId ?? "claude-sonnet-4-5-20250929"; const DEFAULT_REASONING_EFFORT = "medium"; const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5"; const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024; @@ -1149,6 +1193,21 @@ function resolveSessionModelDescriptor(session: AgentChatSession): ModelDescript return null; } + if (session.provider === "droid") { + if (session.modelId) { + const byStoredId = getModelById(session.modelId) ?? resolveModelAlias(session.modelId); + if (byStoredId) return byStoredId; + } + if (session.model) { + return ( + getModelById(`droid/${session.model}`) + ?? resolveModelDescriptorForProvider(session.model, "droid") + ?? null + ); + } + return null; + } + return getModelById(session.model) ?? resolveModelAlias(session.model) ?? null; } @@ -1245,12 +1304,13 @@ function describeCodexModel(value: string): string | null { function isChatToolType( toolType: TerminalToolType | null | undefined, -): toolType is "codex-chat" | "claude-chat" | "opencode-chat" | "cursor" { +): toolType is "codex-chat" | "claude-chat" | "opencode-chat" | "cursor" | "droid-chat" { return ( toolType === "codex-chat" || toolType === "claude-chat" || toolType === "opencode-chat" || toolType === "cursor" + || toolType === "droid-chat" ); } @@ -1258,6 +1318,7 @@ function providerFromToolType(toolType: TerminalToolType | null | undefined): Ag if (toolType === "opencode-chat") return "opencode"; if (toolType === "claude-chat") return "claude"; if (toolType === "cursor") return "cursor"; + if (toolType === "droid-chat") return "droid"; return "codex"; } @@ -1265,6 +1326,7 @@ function toolTypeFromProvider(provider: AgentChatProvider): TerminalToolType { if (provider === "opencode") return "opencode-chat"; if (provider === "claude") return "claude-chat"; if (provider === "cursor") return "cursor"; + if (provider === "droid") return "droid-chat"; return "codex-chat"; } @@ -1307,6 +1369,226 @@ function formatCodexErrorInfo(value: unknown): string | undefined { } } +type ChatErrorCategory = "auth" | "rate_limit" | "budget" | "network" | "unknown"; + +function readErrorMessage(value: unknown): string { + if (value instanceof Error) { + const message = trimLine(value.message); + if (message) return message; + } + if (value && typeof value === "object") { + const record = value as Record; + const message = trimLine(typeof record.message === "string" ? record.message : null); + if (message) return message; + } + return trimLine(typeof value === "string" ? value : null) + ?? trimLine(String(value)) + ?? "Unknown error."; +} + +function readErrorStatusCode(value: unknown): number | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.status === "number") return record.status; + if (typeof record.statusCode === "number") return record.statusCode; + const data = record.data; + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const nested = data as Record; + if (typeof nested.status === "number") return nested.status; + if (typeof nested.statusCode === "number") return nested.statusCode; + return null; +} + +function parseEmbeddedJsonObject(value: string): Record | null { + const trimmed = value.trim(); + if (!trimmed.length) return null; + const start = trimmed.indexOf("{"); + if (start === -1) return null; + try { + const parsed = JSON.parse(trimmed.slice(start)) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function readStructuredErrorPayload(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if ("detail" in record || "details" in record || "title" in record || "requestId" in record || "status" in record) { + return record; + } + } + if (typeof value === "string") { + return parseEmbeddedJsonObject(value); + } + return null; +} + +function readErrorPayload(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + return readStructuredErrorPayload(record.data) ?? readStructuredErrorPayload(value); +} + +function splitDetailSummary(detail: string | null | undefined): { message: string | null; remainder: string | null } { + const trimmed = trimLine(detail); + if (!trimmed) return { message: null, remainder: null }; + const [firstLine, ...rest] = trimmed.split(/\r?\n/); + return { + message: trimLine(firstLine), + remainder: trimLine(rest.join("\n")), + }; +} + +function readErrorDetail(value: unknown): string | null { + const payload = readErrorPayload(value); + if (payload) { + const title = trimLine(typeof payload.title === "string" ? payload.title : null); + const detail = trimLine( + typeof payload.detail === "string" + ? payload.detail + : typeof payload.details === "string" + ? payload.details + : typeof payload.message === "string" + ? payload.message + : null, + ); + const requestId = trimLine(typeof payload.requestId === "string" ? payload.requestId : null); + const lines = uniqueNonEmpty([ + detail, + title && title !== detail ? title : null, + requestId ? `Request ID: ${requestId}` : null, + ], 3); + if (lines.length) return lines.join("\n"); + } + + if (value && typeof value === "object") { + const record = value as Record; + const data = trimLine(typeof record.data === "string" ? record.data : null); + const message = readErrorMessage(value); + if (data && data !== message) return data; + } + + return null; +} + +function classifyAcpHostError( + error: unknown, + providerLabel: string, + modelDisplayName: string, +): { + message: string; + detail?: string; + errorInfo: { category: ChatErrorCategory; provider?: string; model?: string }; +} { + const rawMessage = readErrorMessage(error); + const rawDetail = readErrorDetail(error); + const statusCode = readErrorStatusCode(error); + const combinedLower = `${rawMessage}\n${rawDetail ?? ""}`.toLowerCase(); + + const payload = readErrorPayload(error); + const payloadDetail = trimLine( + typeof payload?.detail === "string" + ? payload.detail + : typeof payload?.details === "string" + ? payload.details + : null, + ); + const payloadRequestId = trimLine(typeof payload?.requestId === "string" ? payload.requestId : null); + + if ( + statusCode === 429 + || combinedLower.includes("rate limit") + || combinedLower.includes("429") + || combinedLower.includes("too many requests") + ) { + return { + message: `Rate limited by ${providerLabel}. The runtime should recover automatically, but you may want to retry with a different model.`, + ...(rawDetail ? { detail: rawDetail } : {}), + errorInfo: { category: "rate_limit", provider: providerLabel, model: modelDisplayName }, + }; + } + + if ( + statusCode === 401 + || statusCode === 403 + || combinedLower.includes("unauthorized") + || combinedLower.includes("forbidden") + || combinedLower.includes("authentication failed") + || combinedLower.includes("invalid api key") + || combinedLower.includes("api key") + ) { + return { + message: `Authentication failed for ${modelDisplayName}. Check your ${providerLabel} credentials and try again.`, + ...(rawDetail ? { detail: rawDetail } : {}), + errorInfo: { category: "auth", provider: providerLabel, model: modelDisplayName }, + }; + } + + if ( + statusCode === 402 + || combinedLower.includes("payment required") + || combinedLower.includes("billing") + || combinedLower.includes("subscribe") + || combinedLower.includes("token limit reached") + ) { + const detailLines = uniqueNonEmpty([ + payloadRequestId ? `Request ID: ${payloadRequestId}` : null, + rawDetail && rawDetail !== payloadDetail ? rawDetail : null, + ], 3); + return { + message: payloadDetail ?? "Billing is required for this model before the request can continue.", + ...(detailLines.length ? { detail: detailLines.join("\n") } : {}), + errorInfo: { category: "budget", provider: providerLabel, model: modelDisplayName }, + }; + } + + if ( + combinedLower.includes("timeout") + || combinedLower.includes("timed out") + || combinedLower.includes("econnrefused") + || combinedLower.includes("enotfound") + || combinedLower.includes("network") + || combinedLower.includes("fetch failed") + || combinedLower.includes("econnreset") + || combinedLower.includes("socket hang up") + || combinedLower.includes("connection error") + || combinedLower.includes("proxy") + || combinedLower.includes("firewall") + ) { + return { + message: rawMessage, + ...(rawDetail ? { detail: rawDetail } : {}), + errorInfo: { category: "network", provider: providerLabel, model: modelDisplayName }, + }; + } + + if (isAbortRelatedError(error)) { + return { + message: "Session was interrupted.", + errorInfo: { category: "unknown", provider: providerLabel, model: modelDisplayName }, + }; + } + + if ((rawMessage === "[object Object]" || /^internal error(?::\s*agent error)?$/i.test(rawMessage)) && rawDetail) { + const promoted = splitDetailSummary(rawDetail); + return { + message: promoted.message ?? rawMessage, + ...(promoted.remainder ? { detail: promoted.remainder } : {}), + errorInfo: { category: "unknown", provider: providerLabel, model: modelDisplayName }, + }; + } + + return { + message: rawMessage, + ...(rawDetail && rawDetail !== rawMessage ? { detail: rawDetail } : {}), + errorInfo: { category: "unknown", provider: providerLabel, model: modelDisplayName }, + }; +} + function mapApprovalDecisionForCodex(decision: AgentChatApprovalDecision): "accept" | "acceptForSession" | "decline" | "cancel" { if (decision === "accept_for_session") return "acceptForSession"; if (decision === "accept") return "accept"; @@ -1405,10 +1687,11 @@ function defaultChatSessionTitle(provider: AgentChatProvider): string { if (provider === "codex") return "Codex Chat"; if (provider === "claude") return "Claude Chat"; if (provider === "cursor") return "Cursor Chat"; + if (provider === "droid") return "Droid Chat"; return "AI Chat"; } -const DEFAULT_SESSION_TITLES = new Set(["Codex Chat", "Claude Chat", "AI Chat", "Cursor Chat"]); +const DEFAULT_SESSION_TITLES = new Set(["Codex Chat", "Claude Chat", "AI Chat", "Cursor Chat", "Droid Chat"]); function hasCustomChatSessionTitle(title: string | null | undefined, provider: AgentChatProvider): boolean { const normalized = String(title ?? "").trim(); @@ -1419,6 +1702,7 @@ function resumeCommandForProvider(provider: AgentChatProvider, sessionId: string if (provider === "codex") return "chat:codex"; if (provider === "opencode") return `chat:opencode:${sessionId}`; if (provider === "cursor") return `chat:cursor:${sessionId}`; + if (provider === "droid") return `chat:droid:${sessionId}`; return `chat:claude:${sessionId}`; } @@ -1479,6 +1763,7 @@ function resolveModelIdFromStoredValue( if (providerHint === "claude" && !(aliasMatch.family === "anthropic" && aliasMatch.isCliWrapped)) return undefined; if (providerHint === "opencode" && aliasMatch.isCliWrapped) return undefined; if (providerHint === "cursor" && aliasMatch.family !== "cursor") return undefined; + if (providerHint === "droid" && aliasMatch.family !== "factory") return undefined; return aliasMatch.id; } @@ -1499,6 +1784,8 @@ function resolveModelIdFromStoredValue( preferred = matches.find((entry) => !entry.isCliWrapped); } else if (providerHint === "cursor") { preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "cursor"); + } else if (providerHint === "droid") { + preferred = matches.find((entry) => entry.isCliWrapped && entry.family === "factory"); } return preferred?.id ?? matches[0]?.id; @@ -1550,6 +1837,7 @@ function fallbackModelForProvider(provider: AgentChatProvider): string { if (provider === "codex") return DEFAULT_CODEX_MODEL; if (provider === "claude") return DEFAULT_CLAUDE_MODEL; if (provider === "cursor") return DEFAULT_CURSOR_MODEL; + if (provider === "droid") return DEFAULT_DROID_MODEL; return DEFAULT_OPENCODE_MODEL_ID; } @@ -1731,6 +2019,15 @@ function buildExecutionModeDirective( ].join("\n"); } + if (provider === "droid" && (mode === "parallel" || mode === "subagents" || mode === "teams")) { + return [ + "[ADE launch directive]", + "Use Droid's available delegation or mission-style tools for independent subtasks when they will materially improve latency or coverage.", + "Split bounded work into narrowly scoped delegates, let them complete independently, then reconcile the results before the final answer.", + "If the task is tightly coupled, stay focused instead of forcing delegation.", + ].join("\n"); + } + return null; } @@ -1992,6 +2289,7 @@ const VALID_CODEX_APPROVAL_POLICIES = new Set(["untrusted", "on-request", "on-fa const VALID_CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); const VALID_CODEX_CONFIG_SOURCES = new Set(["flags", "config-toml"]); const VALID_OPENCODE_PERMISSION_MODES = new Set(["plan", "edit", "full-auto"]); +const VALID_DROID_PERMISSION_MODES = new Set(["read-only", "auto-low", "auto-medium", "auto-high"]); function normalizePersistedEnum(value: unknown, validSet: Set): T | undefined { if (typeof value !== "string") return undefined; @@ -2023,6 +2321,10 @@ function normalizePersistedOpenCodePermissionMode(value: unknown): AgentChatOpen return normalizePersistedEnum(value, VALID_OPENCODE_PERMISSION_MODES); } +function normalizePersistedDroidPermissionMode(value: unknown): AgentChatDroidPermissionMode | undefined { + return normalizePersistedEnum(value, VALID_DROID_PERMISSION_MODES); +} + function legacyPermissionModeToClaudePermissionMode( mode: AgentChatSession["permissionMode"] | undefined, ): AgentChatClaudePermissionMode | undefined { @@ -2101,9 +2403,58 @@ function legacyPermissionModeToOpenCodePermissionMode( return mode === "default" || mode === "config-toml" ? "edit" : normalizeOpenCodePermissionMode(mode); } +function legacyPermissionModeToDroidPermissionMode( + mode: AgentChatSession["permissionMode"] | undefined, +): AgentChatDroidPermissionMode | undefined { + switch (mode) { + case "plan": + return "read-only"; + case "edit": + return "auto-low"; + case "default": + return "auto-medium"; + case "full-auto": + return "auto-high"; + default: + return undefined; + } +} + +function legacyOpenCodePermissionModeToDroidPermissionMode( + mode: AgentChatOpenCodePermissionMode | undefined, +): AgentChatDroidPermissionMode | undefined { + switch (mode) { + case "plan": + return "read-only"; + case "edit": + return "auto-low"; + case "full-auto": + return "auto-high"; + default: + return undefined; + } +} + +function droidPermissionModeToLegacyPermissionMode( + mode: AgentChatDroidPermissionMode | undefined, +): AgentChatSession["permissionMode"] | undefined { + switch (mode) { + case "read-only": + return "plan"; + case "auto-low": + return "edit"; + case "auto-medium": + return "default"; + case "auto-high": + return "full-auto"; + default: + return undefined; + } +} + function syncLegacyPermissionMode(session: Pick< AgentChatSession, - "provider" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" + "provider" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" >): AgentChatSession["permissionMode"] | undefined { if (session.provider === "claude") { if (session.interactionMode === "plan") { @@ -2136,6 +2487,13 @@ function syncLegacyPermissionMode(session: Pick< return undefined; } + if (session.provider === "droid") { + return droidPermissionModeToLegacyPermissionMode( + session.droidPermissionMode + ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode), + ); + } + switch (session.opencodePermissionMode) { case "plan": case "edit": @@ -2149,7 +2507,7 @@ function syncLegacyPermissionMode(session: Pick< function applyLegacyPermissionModeToNativeControls( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" >, mode: AgentChatSession["permissionMode"] | undefined, ): void { @@ -2169,6 +2527,11 @@ function applyLegacyPermissionModeToNativeControls( return; } + if (session.provider === "droid") { + session.droidPermissionMode = legacyPermissionModeToDroidPermissionMode(mode); + return; + } + session.opencodePermissionMode = legacyPermissionModeToOpenCodePermissionMode(mode); } @@ -2205,7 +2568,7 @@ function applyClaudePlanModeTransition( function hydrateNativePermissionControls( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" >, ): void { if (session.provider === "claude") { @@ -2215,6 +2578,10 @@ function hydrateNativePermissionControls( session.codexApprovalPolicy = session.codexApprovalPolicy ?? legacyPermissionModeToCodexApprovalPolicy(session.permissionMode); session.codexSandbox = session.codexSandbox ?? legacyPermissionModeToCodexSandbox(session.permissionMode); session.codexConfigSource = session.codexConfigSource ?? legacyPermissionModeToCodexConfigSource(session.permissionMode); + } else if (session.provider === "droid") { + session.droidPermissionMode = session.droidPermissionMode + ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) + ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode); } else { session.opencodePermissionMode = session.opencodePermissionMode ?? legacyPermissionModeToOpenCodePermissionMode(session.permissionMode); } @@ -2383,6 +2750,16 @@ function resolveSessionOpenCodePermissionMode( ?? fallback; } +function resolveSessionDroidPermissionMode( + session: Pick, + fallback: AgentChatDroidPermissionMode, +): AgentChatDroidPermissionMode { + return session.droidPermissionMode + ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) + ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode) + ?? fallback; +} + function applyLocalHarnessPermissionMode(args: { descriptor?: ModelDescriptor; requestedPermissionMode?: AgentChatSession["permissionMode"]; @@ -2469,6 +2846,45 @@ function cursorAcpSessionRequest>(request: T): } as T; } +type AcpSessionLifecycleConnection = ClientSideConnection & { + unstable_closeSession?: (params: CloseSessionRequest) => Promise; + unstable_resumeSession?: (params: ResumeSessionRequest) => Promise; +}; + +function acpSessionLifecycle(connection: ClientSideConnection): AcpSessionLifecycleConnection { + return connection as AcpSessionLifecycleConnection; +} + +async function closeAcpSession( + connection: ClientSideConnection | null | undefined, + sessionId: string | null | undefined, +): Promise { + const normalizedSessionId = sessionId?.trim(); + if (!connection || !normalizedSessionId) return; + const lifecycle = acpSessionLifecycle(connection); + if (typeof lifecycle.closeSession === "function") { + await lifecycle.closeSession({ sessionId: normalizedSessionId }); + return; + } + if (typeof lifecycle.unstable_closeSession === "function") { + await lifecycle.unstable_closeSession({ sessionId: normalizedSessionId }); + } +} + +async function resumeAcpSession( + connection: ClientSideConnection, + request: ResumeSessionRequest, +): Promise { + const lifecycle = acpSessionLifecycle(connection); + if (typeof lifecycle.resumeSession === "function") { + return lifecycle.resumeSession(request); + } + if (typeof lifecycle.unstable_resumeSession === "function") { + return lifecycle.unstable_resumeSession(request); + } + return null; +} + function normalizeCursorConfigValueRecord( value: unknown, ): Record | undefined { @@ -2542,6 +2958,43 @@ function resolveCursorRuntimeModelSdkId( return DEFAULT_CURSOR_MODEL; } +function resolveDroidRuntimeModelId( + session: Pick, +): string { + const byModelId = session.modelId ? getModelById(session.modelId) ?? resolveModelAlias(session.modelId) : null; + if (byModelId?.family === "factory") { + return byModelId.providerModelId; + } + + const rawModel = String(session.model ?? "").trim(); + if (rawModel.length) { + const resolved = getModelById(`droid/${rawModel}`) ?? resolveModelDescriptorForProvider(rawModel, "droid"); + if (resolved?.family === "factory") { + return resolved.providerModelId; + } + } + + return DEFAULT_DROID_MODEL; +} + +function resolveDroidAcpLaunchSettings( + session: Pick, +): DroidAcpLaunchSettings { + const mode = resolveSessionDroidPermissionMode(session, "auto-low"); + switch (mode) { + case "read-only": + return { autonomy: "none" }; + case "auto-low": + return { autonomy: "low" }; + case "auto-medium": + return { autonomy: "medium" }; + case "auto-high": + return { autonomy: "high" }; + default: + return { autonomy: "low" }; + } +} + function normalizeCursorReportedModelId( modelId: string | null | undefined, availableModelIds: readonly string[] = [], @@ -2554,10 +3007,38 @@ function normalizeCursorReportedModelId( return descriptor?.family === "cursor" ? descriptor.providerModelId : null; } +function normalizeDroidReportedModelId( + modelId: string | null | undefined, + availableModelIds: readonly string[] = [], +): string | null { + const trimmed = String(modelId ?? "").trim(); + if (!trimmed.length) return null; + const descriptor = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); + if (descriptor?.family === "factory") { + return descriptor.providerModelId; + } + if (availableModelIds.includes(trimmed)) { + return trimmed; + } + return /^[\w.:()+-]+$/i.test(trimmed) ? trimmed : null; +} + +function normalizeDroidDisplayKey(value: string | null | undefined): string | null { + const normalized = String(value ?? "").replace(/\s+/g, " ").trim().toLowerCase(); + return normalized.length ? normalized : null; +} + +function resolveDroidDisplayKeyForModelId(modelId: string | null | undefined): string | null { + const trimmed = String(modelId ?? "").trim(); + if (!trimmed.length) return null; + const descriptor = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); + return normalizeDroidDisplayKey(descriptor?.displayName ?? trimmed); +} + function normalizeSessionNativePermissionControls( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" >, config: ResolvedChatConfig, ): void { @@ -2568,6 +3049,7 @@ function normalizeSessionNativePermissionControls( delete session.codexSandbox; delete session.codexConfigSource; delete session.opencodePermissionMode; + delete session.droidPermissionMode; } else if (session.provider === "codex") { delete session.interactionMode; session.codexConfigSource = resolveSessionCodexConfigSource(session); @@ -2580,6 +3062,15 @@ function normalizeSessionNativePermissionControls( } delete session.claudePermissionMode; delete session.opencodePermissionMode; + delete session.droidPermissionMode; + } else if (session.provider === "droid") { + delete session.interactionMode; + session.droidPermissionMode = resolveSessionDroidPermissionMode(session, "auto-low"); + delete session.claudePermissionMode; + delete session.codexApprovalPolicy; + delete session.codexSandbox; + delete session.codexConfigSource; + delete session.opencodePermissionMode; } else { delete session.interactionMode; session.opencodePermissionMode = resolveSessionOpenCodePermissionMode(session, config.opencodePermissionMode); @@ -2587,6 +3078,7 @@ function normalizeSessionNativePermissionControls( delete session.codexApprovalPolicy; delete session.codexSandbox; delete session.codexConfigSource; + delete session.droidPermissionMode; } session.permissionMode = syncLegacyPermissionMode(session); @@ -2669,7 +3161,13 @@ function normalizeSessionProfile(value: unknown): "light" | "workflow" | undefin } function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { - return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "opencode" ? "full_tooling" : "fallback"; + return provider === "codex" + || provider === "claude" + || provider === "cursor" + || provider === "droid" + || provider === "opencode" + ? "full_tooling" + : "fallback"; } function isLightweightSession(session: Pick): boolean { @@ -2823,8 +3321,112 @@ export function createAgentChatService(args: { }; const managedSessions = new Map(); - const cursorAcpSessionOwners = new Map(); - const cursorAcpBridgeWired = new WeakSet(); + const acpHostSessionOwners = new Map(); + const acpHostBridgeWired = new WeakSet(); + /** + * Dedup guard for Droid ACP session notifications. + * + * The droid exec binary has two duplicate-emission behaviors: + * 1. Duplicate `current_mode_update` notifications per turn. + * 2. After streaming `agent_message_chunk` deltas for the current turn, it + * sends a final chunk containing the concatenation of ALL previous turns' + * assistant text (conversation history replay). + * + * Per ACP session we track: + * - `historyText`: accumulated text from all completed turns (used to detect + * the history-replay chunk in subsequent turns) + * - `currentTurnText`: text streamed so far in the active turn + * - `seenModes`: mode IDs already emitted in the active turn + */ + const droidSessionDedup = new Map; + }>(); + + function isDuplicateDroidNotification( + sessionId: string, + turnId: string, + note: { update: Record }, + ): boolean { + const u = note.update; + + if (u.sessionUpdate === "agent_message_chunk") { + const c = u.content as { type?: string; text?: string } | undefined; + const chunkText = c?.text ?? ""; + if (!chunkText.length) return false; + + let entry = droidSessionDedup.get(sessionId); + if (!entry) { + entry = { historyText: "", currentTurnText: "", currentTurnId: turnId, seenModes: new Set() }; + droidSessionDedup.set(sessionId, entry); + } + + // New turn — commit previous turn's text to history and reset. + if (entry.currentTurnId !== turnId) { + entry.historyText += entry.currentTurnText; + entry.currentTurnText = ""; + entry.currentTurnId = turnId; + entry.seenModes.clear(); + } + + // Replay chunks are full multi-line agent_message_chunks containing text + // from previous turns. Restrict the substring check to chunks long enough + // that an accidental match against a tiny streaming delta (e.g. " yes") + // is implausible. + const REPLAY_MIN_LEN = 32; + if ( + chunkText.length >= REPLAY_MIN_LEN + && entry.historyText.length > 0 + && entry.historyText.includes(chunkText) + ) { + return true; + } + + // Also catch the case where this chunk replays the current turn's + // own streamed text (the original dedup scenario). + if ( + chunkText.length >= REPLAY_MIN_LEN + && entry.currentTurnText.length > 0 + && entry.currentTurnText.includes(chunkText) + ) { + return true; + } + + // Genuine streaming delta — accumulate. + entry.currentTurnText += chunkText; + return false; + } + + if (u.sessionUpdate === "current_mode_update") { + const modeId = String(u.currentModeId ?? ""); + let entry = droidSessionDedup.get(sessionId); + if (!entry) { + entry = { historyText: "", currentTurnText: "", currentTurnId: turnId, seenModes: new Set() }; + droidSessionDedup.set(sessionId, entry); + } + if (entry.currentTurnId !== turnId) { + entry.historyText += entry.currentTurnText; + entry.currentTurnText = ""; + entry.currentTurnId = turnId; + entry.seenModes.clear(); + } + if (entry.seenModes.has(modeId)) { + return true; + } + entry.seenModes.add(modeId); + return false; + } + + return false; + } + + function clearDroidSessionDedup(sessionId: string): void { + droidSessionDedup.delete(sessionId); + } + /** Interrupt arrived while `ensureDroidRuntime` was still acquiring the pooled CLI. */ + const droidRuntimeSetupInterruptRequested = new WeakMap(); const sessionTurnCollectors = new Map(); const subagentStates = new Map>(); const AUTO_MEMORY_CATEGORY_ALLOWLIST = new Set([ @@ -5102,7 +5704,8 @@ export function createAgentChatService(args: { && (managed.runtime?.kind === "claude" || managed.runtime?.kind === "codex" || managed.runtime?.kind === "opencode" - || managed.runtime?.kind === "cursor") + || managed.runtime?.kind === "cursor" + || managed.runtime?.kind === "droid") ) { teardownRuntime(managed, "project_close"); refreshReconstructionContext(managed); @@ -5201,6 +5804,7 @@ export function createAgentChatService(args: { ...(managed.session.codexSandbox ? { codexSandbox: managed.session.codexSandbox } : {}), ...(managed.session.codexConfigSource ? { codexConfigSource: managed.session.codexConfigSource } : {}), ...(managed.session.opencodePermissionMode ? { opencodePermissionMode: managed.session.opencodePermissionMode } : {}), + ...(managed.session.droidPermissionMode ? { droidPermissionMode: managed.session.droidPermissionMode } : {}), ...(managed.session.cursorModeSnapshot ? { cursorModeSnapshot: managed.session.cursorModeSnapshot } : {}), ...(managed.session.cursorModeId !== undefined ? { cursorModeId: managed.session.cursorModeId } : {}), ...(managed.session.cursorConfigValues ? { cursorConfigValues: managed.session.cursorConfigValues } : {}), @@ -5212,7 +5816,7 @@ export function createAgentChatService(args: { ...(managed.session.capabilityMode ? { capabilityMode: managed.session.capabilityMode } : {}), ...(managed.session.completion ? { completion: managed.session.completion } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), - ...(managed.runtime?.kind === "cursor" && managed.runtime.acpSessionId + ...((managed.runtime?.kind === "cursor" || managed.runtime?.kind === "droid") && managed.runtime.acpSessionId ? { acpSessionId: managed.runtime.acpSessionId } : {}), ...(managed.runtime?.kind === "claude" @@ -5283,7 +5887,7 @@ export function createAgentChatService(args: { if (record.version !== 1 && record.version !== 2) return null; let provider = record.provider; if (provider === "unified") provider = "opencode"; - if (provider !== "codex" && provider !== "claude" && provider !== "opencode" && provider !== "cursor") { + if (provider !== "codex" && provider !== "claude" && provider !== "opencode" && provider !== "cursor" && provider !== "droid") { return null; } const laneId = String(record.laneId ?? "").trim(); @@ -5303,6 +5907,11 @@ export function createAgentChatService(args: { const codexSandbox = normalizePersistedCodexSandbox(record.codexSandbox); const codexConfigSource = normalizePersistedCodexConfigSource(record.codexConfigSource); const opencodePermissionMode = normalizePersistedOpenCodePermissionMode(record.opencodePermissionMode ?? (record as any).unifiedPermissionMode); + const droidPermissionMode = normalizePersistedDroidPermissionMode(record.droidPermissionMode) + ?? (provider === "droid" + ? legacyPermissionModeToDroidPermissionMode(permissionMode) + ?? legacyOpenCodePermissionModeToDroidPermissionMode(opencodePermissionMode) + : undefined); const cursorModeSnapshot = record.cursorModeSnapshot && typeof record.cursorModeSnapshot === "object" ? record.cursorModeSnapshot as AgentChatCursorModeSnapshot : undefined; @@ -5361,6 +5970,7 @@ export function createAgentChatService(args: { ...(codexSandbox ? { codexSandbox } : {}), ...(codexConfigSource ? { codexConfigSource } : {}), ...(opencodePermissionMode ? { opencodePermissionMode } : {}), + ...(droidPermissionMode ? { droidPermissionMode } : {}), ...(cursorModeSnapshot ? { cursorModeSnapshot } : {}), ...(cursorModeId !== undefined ? { cursorModeId } : {}), ...(cursorConfigValues ? { cursorConfigValues } : {}), @@ -6092,23 +6702,37 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "cursor") { const rt = managed.runtime; if (rt.acpSessionId) { - cursorAcpSessionOwners.delete(rt.acpSessionId); - void rt.pooled?.connection.unstable_closeSession?.({ sessionId: rt.acpSessionId }).catch(() => {}); + acpHostSessionOwners.delete(rt.acpSessionId); + void closeAcpSession(rt.pooled?.connection, rt.acpSessionId).catch(() => {}); } for (const [, w] of rt.permissionWaiters) { w.resolve({ outcome: { outcome: "cancelled" } }); } rt.permissionWaiters.clear(); - if (rt.pooled) releaseCursorAcpConnection(rt.poolKey); + if (rt.pooled) releaseCursorAcpConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } - managed.runtimeInvalidated = !preserveClaudeResumeState; - if (!preserveClaudeResumeState) { - clearLaneDirectiveKey(managed); - } - }; - - const keepChatSessionOpen = ( + if (managed.runtime?.kind === "droid") { + const rt = managed.runtime; + if (rt.acpSessionId) { + acpHostSessionOwners.delete(rt.acpSessionId); + clearDroidSessionDedup(rt.acpSessionId); + void closeAcpSession(rt.pooled?.connection, rt.acpSessionId).catch(() => {}); + } + for (const [, w] of rt.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); + } + rt.permissionWaiters.clear(); + if (rt.pooled) releaseDroidAcpConnection(rt.poolKey, rt.poolGeneration); + managed.runtime = null; + } + managed.runtimeInvalidated = !preserveClaudeResumeState; + if (!preserveClaudeResumeState) { + clearLaneDirectiveKey(managed); + } + }; + + const keepChatSessionOpen = ( managed: ManagedChatSession, args: { message: string; @@ -6372,6 +6996,8 @@ export function createAgentChatService(args: { ? DEFAULT_OPENCODE_MODEL_ID : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id + : provider === "droid" + ? DEFAULT_DROID_DESCRIPTOR?.id : undefined); const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); @@ -10228,7 +10854,7 @@ export function createAgentChatService(args: { const cancelQueuedSteers = ( managed: ManagedChatSession, - runtime: Pick, + runtime: Pick, reason: "interrupted" | "failed" | "disposed", ): void => { const cancelled = runtime.pendingSteers.splice(0); @@ -10338,7 +10964,7 @@ export function createAgentChatService(args: { const deliverNextQueuedSteer = async ( managed: ManagedChatSession, - runtime: ClaudeRuntime | OpenCodeRuntime | CursorRuntime, + runtime: ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime, ): Promise => { if (managed.closed) return false; @@ -10395,6 +11021,14 @@ export function createAgentChatService(args: { resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); + } else if (runtime.kind === "droid") { + await runDroidTurn(managed, { + promptText, + displayText: trimmed, + attachments: [], + resolvedAttachments: [], + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, + }); } else { await runTurn(managed, { promptText, @@ -10872,6 +11506,7 @@ export function createAgentChatService(args: { codexSandbox: requestedCodexSandbox, codexConfigSource: requestedCodexConfigSource, opencodePermissionMode: requestedOpenCodePermissionModeArg, + droidPermissionMode: requestedDroidPermissionModeArg, cursorModeId: requestedCursorModeId, cursorConfigValues: requestedCursorConfigValues, permissionMode: requestedPermMode, @@ -10901,6 +11536,8 @@ export function createAgentChatService(args: { ? DEFAULT_CLAUDE_MODEL : provider === "cursor" ? DEFAULT_CURSOR_MODEL + : provider === "droid" + ? DEFAULT_DROID_MODEL : ""); // Resolve modelId from registry if provided const requestedModelDescriptor = modelId ? getModelById(modelId) ?? resolveModelAlias(modelId) : undefined; @@ -10914,6 +11551,9 @@ export function createAgentChatService(args: { if (provider === "cursor" && !resolvedModelId) { throw new Error("Cursor chat requires a known model. Pick a Cursor model from the model list."); } + if (provider === "droid" && !resolvedModelId) { + throw new Error("Droid chat requires a known model. Pick a Droid model from the model list."); + } const resolvedDescriptor = requestedModelDescriptor ?? (resolvedModelId ? getModelById(resolvedModelId) : undefined); if (resolvedModelId && !resolvedDescriptor) { @@ -10939,7 +11579,7 @@ export function createAgentChatService(args: { : normalizeReasoningEffort(reasoningEffort); const normalizedReasoningEffort = effectiveProvider === "opencode" ? rawEffort - : effectiveProvider === "cursor" + : effectiveProvider === "cursor" || effectiveProvider === "droid" ? null : validateReasoningEffort(effectiveProvider === "claude" ? "claude" : "codex", rawEffort); const normalizedCursorModeId = typeof requestedCursorModeId === "string" @@ -10960,6 +11600,7 @@ export function createAgentChatService(args: { const effectiveCodexApprovalPolicy = identityPinned ? undefined : requestedCodexApprovalPolicy; const effectiveCodexSandbox = identityPinned ? undefined : requestedCodexSandbox; const effectiveCodexConfigSource = identityPinned ? undefined : requestedCodexConfigSource; + const requestedDroidPermissionMode = identityPinned ? undefined : requestedDroidPermissionModeArg; let effectivePermissionMode = identityKey ? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider) : requestedPermMode; @@ -11019,6 +11660,14 @@ export function createAgentChatService(args: { : {}), }; } + if (effectiveProvider === "droid") { + return { + droidPermissionMode: requestedDroidPermissionMode + ?? legacyPermissionModeToDroidPermissionMode(effectivePermissionMode) + ?? legacyOpenCodePermissionModeToDroidPermissionMode(requestedOpenCodePermissionMode) + ?? "auto-low", + }; + } return { opencodePermissionMode: requestedOpenCodePermissionMode ?? legacyPermissionModeToOpenCodePermissionMode(effectivePermissionMode) @@ -11198,6 +11847,7 @@ export function createAgentChatService(args: { codexSandbox: args.codexSandbox ?? managed.session.codexSandbox, codexConfigSource: args.codexConfigSource ?? managed.session.codexConfigSource, opencodePermissionMode: args.opencodePermissionMode ?? managed.session.opencodePermissionMode, + droidPermissionMode: args.droidPermissionMode ?? managed.session.droidPermissionMode, permissionMode: args.permissionMode ?? managed.session.permissionMode, cursorModeId: args.cursorModeId !== undefined ? args.cursorModeId : managed.session.cursorModeId, cursorConfigValues: args.cursorConfigValues !== undefined @@ -11303,7 +11953,11 @@ export function createAgentChatService(args: { refreshReconstructionContext(managed); } - if (managed.session.provider === "cursor" && managed.session.status === "active" && !allowActiveSession) { + if ( + (managed.session.provider === "cursor" || managed.session.provider === "droid") + && managed.session.status === "active" + && !allowActiveSession + ) { throw new Error("Turn is already active."); } @@ -11390,7 +12044,15 @@ export function createAgentChatService(args: { const { managed } = prepared; if (managed.closed) return; - const message = error instanceof Error ? error.message : String(error); + const descriptor = resolveSessionModelDescriptor(managed.session); + const acpError = (managed.session.provider === "cursor" || managed.session.provider === "droid") + ? classifyAcpHostError( + error, + managed.session.provider === "droid" ? "Factory Droid" : "Cursor", + descriptor?.displayName ?? managed.session.model, + ) + : null; + const message = acpError?.message ?? (error instanceof Error ? error.message : String(error)); const turnId = prepared.turnId ?? randomUUID(); // If the failure is "turn already active", the original turn is still running. @@ -11424,10 +12086,16 @@ export function createAgentChatService(args: { managed.runtime.busy = false; managed.runtime.activeTurnId = null; } + if (managed.runtime?.kind === "droid" && !isBusyError) { + managed.runtime.busy = false; + managed.runtime.activeTurnId = null; + } emitChatEvent(managed, { type: "error", message, + ...(acpError?.detail ? { detail: acpError.detail } : {}), + ...(acpError?.errorInfo ? { errorInfo: acpError.errorInfo } : {}), turnId, }); emitChatEvent(managed, { @@ -11506,17 +12174,19 @@ export function createAgentChatService(args: { } }; - const buildCursorPendingInputRequest = ( + const buildAcpHostPendingInputRequest = ( itemId: string, req: RequestPermissionRequest, + source: "cursor" | "droid", turnId?: string | null, ): PendingInputRequest => ({ requestId: itemId, itemId, - source: "cursor", + source, kind: "permissions", - title: req.toolCall.title ?? "Cursor permission required", - description: req.toolCall.title ?? "Cursor needs approval before continuing.", + title: req.toolCall.title ?? (source === "droid" ? "Droid permission required" : "Cursor permission required"), + description: req.toolCall.title + ?? (source === "droid" ? "Droid needs approval before continuing." : "Cursor needs approval before continuing."), questions: [], allowsFreeform: false, blocking: true, @@ -11554,6 +12224,87 @@ export function createAgentChatService(args: { } }; + const syncDroidSessionDescriptor = ( + managed: ManagedChatSession, + providerModelId: string, + options: { + runtime?: DroidRuntime | null; + updateSelection?: boolean; + updateCurrent?: boolean; + } = {}, + ): void => { + const trimmed = providerModelId.trim(); + if (!trimmed.length) return; + const runtime = options.runtime ?? (managed.runtime?.kind === "droid" ? managed.runtime : null); + managed.session.model = trimmed; + const descriptor = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); + const runtimeModelId = descriptor?.providerModelId ?? trimmed; + if (descriptor) { + managed.session.modelId = descriptor.id; + } else { + delete managed.session.modelId; + } + if (runtime) { + if (options.updateSelection !== false) { + runtime.modelId = runtimeModelId; + } + if (options.updateCurrent) { + runtime.currentModelId = runtimeModelId; + } + } + }; + + const updateDroidAcpModelLookups = ( + runtime: DroidRuntime, + entries: Array<{ modelId?: string | null; name?: string | null } | null> | null | undefined, + ): void => { + for (const entry of entries ?? []) { + const rawModelId = String(entry?.modelId ?? "").trim(); + if (!rawModelId.length) continue; + const displayKey = normalizeDroidDisplayKey(entry?.name) + ?? resolveDroidDisplayKeyForModelId(rawModelId); + if (!displayKey) continue; + runtime.acpModelIdByDisplayKey.set(displayKey, rawModelId); + runtime.displayKeyByAcpModelId.set(rawModelId, displayKey); + } + }; + + const resolveDroidAcpModelId = ( + runtime: DroidRuntime, + canonicalModelId: string, + ): string => { + const trimmed = canonicalModelId.trim(); + if (!trimmed.length) return trimmed; + const displayKey = resolveDroidDisplayKeyForModelId(trimmed); + if (displayKey) { + return runtime.acpModelIdByDisplayKey.get(displayKey) ?? trimmed; + } + return trimmed; + }; + + const resolveCanonicalDroidModelId = ( + managed: ManagedChatSession, + runtime: DroidRuntime, + acpModelId: string | null | undefined, + ): string | null => { + const trimmed = String(acpModelId ?? "").trim(); + if (!trimmed.length) return null; + + const direct = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); + if (direct?.family === "factory") { + const selectedCanonicalModelId = runtime.modelId.trim() || resolveDroidRuntimeModelId(managed.session); + const selectedDisplayKey = resolveDroidDisplayKeyForModelId(selectedCanonicalModelId); + const currentDisplayKey = runtime.displayKeyByAcpModelId.get(trimmed) + ?? resolveDroidDisplayKeyForModelId(trimmed); + if (selectedDisplayKey && currentDisplayKey && selectedDisplayKey === currentDisplayKey) { + return selectedCanonicalModelId; + } + return direct.providerModelId; + } + + return /^[\w.:()+-]+$/i.test(trimmed) ? trimmed : null; + }; + const applyCursorConfigSnapshot = ( managed: ManagedChatSession, runtime: CursorRuntime, @@ -11581,6 +12332,187 @@ export function createAgentChatService(args: { syncCursorModeSnapshot(managed, runtime); }; + const applyDroidModelSnapshot = ( + _managed: ManagedChatSession, + runtime: DroidRuntime, + payload: { + models?: { + currentModelId?: string | null; + availableModels?: Array<{ modelId?: string | null; name?: string | null } | null> | null; + } | null; + configOptions?: Parameters[0]; + } | null | undefined, + ): { + currentModelId: string | null; + modelConfigId: string | null; + } => { + const configSnapshot = readCursorAcpConfigSnapshot(payload?.configOptions); + updateDroidAcpModelLookups(runtime, payload?.models?.availableModels); + for (const modelId of configSnapshot.availableModelIds) { + const rawModelId = String(modelId ?? "").trim(); + if (!rawModelId.length) continue; + const displayKey = resolveDroidDisplayKeyForModelId(rawModelId); + if (!displayKey) continue; + runtime.acpModelIdByDisplayKey.set(displayKey, rawModelId); + runtime.displayKeyByAcpModelId.set(rawModelId, displayKey); + } + const reportedAvailableModelIds = payload?.models?.availableModels + ?.map((entry) => normalizeDroidReportedModelId(entry?.modelId ?? null)) + .filter((entry): entry is string => Boolean(entry)) ?? []; + const availableModelIds = Array.from(new Set([ + ...runtime.availableModelIds, + ...reportedAvailableModelIds, + ...configSnapshot.availableModelIds + .map((entry) => normalizeDroidReportedModelId(entry)) + .filter((entry): entry is string => Boolean(entry)), + ])); + runtime.availableModelIds = availableModelIds; + const currentModelId = normalizeDroidReportedModelId( + payload?.models?.currentModelId ?? configSnapshot.currentModelId, + availableModelIds, + ); + if (currentModelId) { + runtime.currentModelId = currentModelId; + } + return { + currentModelId, + modelConfigId: configSnapshot.modelConfigId, + }; + }; + + const refreshDroidSessionState = async ( + managed: ManagedChatSession, + runtime: DroidRuntime, + reason: "after_prompt" | "manual_sync" | "session_update" | "ensure_before_sync" | "set_model_failed", + ): Promise<{ + currentModelId: string | null; + modelConfigId: string | null; + }> => { + const sessionId = runtime.acpSessionId?.trim(); + if (!sessionId || !runtime.pooled) { + return { currentModelId: runtime.currentModelId, modelConfigId: null }; + } + + const loadSession = runtime.pooled.connection.loadSession?.bind(runtime.pooled.connection); + if (!loadSession) { + return { currentModelId: runtime.currentModelId, modelConfigId: null }; + } + + try { + const loaded = await loadSession(cursorAcpSessionRequest({ + sessionId, + cwd: managed.laneWorktreePath, + }) as Parameters[0]); + const snapshot = applyDroidModelSnapshot(managed, runtime, loaded); + if ((reason === "after_prompt" || reason === "set_model_failed") && snapshot.currentModelId) { + const canonicalModelId = resolveCanonicalDroidModelId(managed, runtime, snapshot.currentModelId); + if (canonicalModelId) { + syncDroidSessionDescriptor(managed, canonicalModelId, { runtime }); + runtime.currentModelId = snapshot.currentModelId; + } + } + return snapshot; + } catch (error) { + logger.warn("agent_chat.droid_load_session_failed", { + sessionId: managed.session.id, + acpSessionId: sessionId, + reason, + error: error instanceof Error ? error.message : String(error), + }); + return { currentModelId: runtime.currentModelId, modelConfigId: null }; + } + }; + + const ensureDroidSessionState = async ( + managed: ManagedChatSession, + runtime: DroidRuntime, + ): Promise => { + const sessionId = runtime.acpSessionId?.trim(); + if (!sessionId || !runtime.pooled) return; + + if (!runtime.currentModelId) { + await refreshDroidSessionState(managed, runtime, "ensure_before_sync"); + } + + const desiredModelId = runtime.modelId.trim() || resolveDroidRuntimeModelId(managed.session); + const desiredAcpModelId = resolveDroidAcpModelId(runtime, desiredModelId); + if (!desiredModelId.length || !desiredAcpModelId.length) return; + + if (runtime.currentModelId === desiredAcpModelId) { + syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); + runtime.currentModelId = desiredAcpModelId; + return; + } + + let modelUpdated = false; + const loadSnapshot = await refreshDroidSessionState(managed, runtime, "manual_sync"); + if (loadSnapshot.currentModelId === desiredAcpModelId) { + syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); + runtime.currentModelId = desiredAcpModelId; + return; + } + + if ( + loadSnapshot.modelConfigId + && runtime.availableModelIds.includes(desiredAcpModelId) + && typeof runtime.pooled.connection.setSessionConfigOption === "function" + ) { + try { + const response = await runtime.pooled.connection.setSessionConfigOption({ + sessionId, + configId: loadSnapshot.modelConfigId, + value: desiredAcpModelId, + }); + const applied = applyDroidModelSnapshot(managed, runtime, response); + if (!applied.currentModelId) { + runtime.currentModelId = desiredAcpModelId; + } + syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); + modelUpdated = true; + } catch (error) { + logger.warn("agent_chat.droid_set_session_model_config_failed", { + sessionId: managed.session.id, + acpSessionId: sessionId, + desiredModelId, + configId: loadSnapshot.modelConfigId, + currentModelId: runtime.currentModelId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (!modelUpdated && typeof runtime.pooled.connection.unstable_setSessionModel === "function") { + try { + await runtime.pooled.connection.unstable_setSessionModel({ + sessionId, + modelId: desiredAcpModelId, + }); + syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); + runtime.currentModelId = desiredAcpModelId; + modelUpdated = true; + } catch (error) { + logger.warn("agent_chat.droid_set_session_model_failed", { + sessionId: managed.session.id, + acpSessionId: sessionId, + desiredModelId, + currentModelId: runtime.currentModelId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (!modelUpdated) { + const refreshed = await refreshDroidSessionState(managed, runtime, "set_model_failed"); + if (refreshed.currentModelId) { + const canonicalModelId = resolveCanonicalDroidModelId(managed, runtime, refreshed.currentModelId); + if (canonicalModelId) { + syncDroidSessionDescriptor(managed, canonicalModelId, { runtime }); + runtime.currentModelId = refreshed.currentModelId; + } + } + } + }; + const ensureCursorSessionState = async ( managed: ManagedChatSession, runtime: CursorRuntime, @@ -11830,13 +12762,13 @@ export function createAgentChatService(args: { return blocks; }; - const emitCursorTerminalCommandIfBound = ( - pooled: CursorAcpPooled, + const emitAcpHostTerminalCommandIfBound = ( + pooled: CursorAcpPooled | DroidAcpPooled, acpSessionId: string, terminalId: string, ): void => { - const owner = cursorAcpSessionOwners.get(acpSessionId); - if (!owner?.runtime || owner.runtime.kind !== "cursor") return; + const owner = acpHostSessionOwners.get(acpSessionId); + if (!owner?.runtime || (owner.runtime.kind !== "cursor" && owner.runtime.kind !== "droid")) return; const binding = pooled.terminalWorkLogBindings.get(terminalId); if (!binding) return; const t = pooled.terminals.get(terminalId); @@ -11855,8 +12787,8 @@ export function createAgentChatService(args: { }); }; - const scheduleCursorTerminalEmit = ( - pooled: CursorAcpPooled, + const scheduleAcpHostTerminalEmit = ( + pooled: CursorAcpPooled | DroidAcpPooled, terminalId: string, acpSessionId: string, ): void => { @@ -11867,38 +12799,52 @@ export function createAgentChatService(args: { terminalId, setTimeout(() => { pooled.terminalOutputTimers.delete(terminalId); - emitCursorTerminalCommandIfBound(pooled, acpSessionId, terminalId); + emitAcpHostTerminalCommandIfBound(pooled, acpSessionId, terminalId); }, DEBOUNCE_MS), ); }; - const wireCursorAcpBridgeHandlers = (pooled: CursorAcpPooled): void => { - if (cursorAcpBridgeWired.has(pooled)) return; - cursorAcpBridgeWired.add(pooled); + const wireAcpHostBridgeHandlers = (pooled: CursorAcpPooled | DroidAcpPooled): void => { + if (acpHostBridgeWired.has(pooled)) return; + acpHostBridgeWired.add(pooled); pooled.bridge.onSessionUpdate = (note) => { - const owner = cursorAcpSessionOwners.get(note.sessionId); - if (!owner?.runtime || owner.runtime.kind !== "cursor") return; - const previousModeId = owner.runtime.currentModeId; - if (note.update.sessionUpdate === "current_mode_update") { - owner.runtime.currentModeId = note.update.currentModeId; - if (note.update.currentModeId !== "plan") { - owner.runtime.defaultModeId = note.update.currentModeId; - } - // Sync session-level mode fields so ensureCursorSessionState won't - // revert Cursor back to the old mode on the next turn. - owner.session.cursorModeId = note.update.currentModeId; - if (note.update.currentModeId === "plan") { - owner.session.opencodePermissionMode = "plan"; - } else if (!owner.session.opencodePermissionMode || owner.session.opencodePermissionMode === "plan") { - owner.session.opencodePermissionMode = "edit"; - } - syncCursorModeSnapshot(owner, owner.runtime); - persistChatState(owner); - } else if (note.update.sessionUpdate === "config_option_update") { - applyCursorConfigSnapshot(owner, owner.runtime, readCursorAcpConfigSnapshot(note.update.configOptions)); - persistChatState(owner); - } - const turnId = owner.runtime.activeTurnId ?? ""; + const owner = acpHostSessionOwners.get(note.sessionId); + if (!owner?.runtime) return; + const rt = owner.runtime; + if (rt.kind !== "cursor" && rt.kind !== "droid") return; + + // Droid exec sends streaming chunks + a final complete-text replay, and + // duplicate current_mode_update notifications. Suppress the duplicates. + if (rt.kind === "droid" && isDuplicateDroidNotification(note.sessionId, rt.activeTurnId ?? "", note as { update: Record })) { + return; + } + + let previousModeId: string | null = null; + if (rt.kind === "cursor") { + previousModeId = rt.currentModeId; + if (note.update.sessionUpdate === "current_mode_update") { + rt.currentModeId = note.update.currentModeId; + if (note.update.currentModeId !== "plan") { + rt.defaultModeId = note.update.currentModeId; + } + owner.session.cursorModeId = note.update.currentModeId; + if (note.update.currentModeId === "plan") { + owner.session.opencodePermissionMode = "plan"; + } else if (!owner.session.opencodePermissionMode || owner.session.opencodePermissionMode === "plan") { + owner.session.opencodePermissionMode = "edit"; + } + syncCursorModeSnapshot(owner, rt); + persistChatState(owner); + } else if (note.update.sessionUpdate === "config_option_update") { + applyCursorConfigSnapshot(owner, rt, readCursorAcpConfigSnapshot(note.update.configOptions)); + persistChatState(owner); + } + } else if (rt.kind === "droid" && note.update.sessionUpdate === "config_option_update") { + void refreshDroidSessionState(owner, rt, "session_update").then(() => { + persistChatState(owner); + }); + } + const turnId = rt.activeTurnId ?? ""; const resolveTerminal = (tid: string) => { const t = pooled.terminals.get(tid); if (!t) return null; @@ -11928,7 +12874,7 @@ export function createAgentChatService(args: { } }; pooled.bridge.onTerminalOutputDelta = (terminalId, acpSessionId) => { - scheduleCursorTerminalEmit(pooled, terminalId, acpSessionId); + scheduleAcpHostTerminalEmit(pooled, terminalId, acpSessionId); }; pooled.bridge.flushTerminalOutput = (terminalId, acpSessionId) => { const pending = pooled.terminalOutputTimers.get(terminalId); @@ -11936,7 +12882,7 @@ export function createAgentChatService(args: { clearTimeout(pending); pooled.terminalOutputTimers.delete(terminalId); } - emitCursorTerminalCommandIfBound(pooled, acpSessionId, terminalId); + emitAcpHostTerminalCommandIfBound(pooled, acpSessionId, terminalId); }; pooled.bridge.onTerminalDisposed = (terminalId) => { const pending = pooled.terminalOutputTimers.get(terminalId); @@ -11947,11 +12893,11 @@ export function createAgentChatService(args: { pooled.terminalWorkLogBindings.delete(terminalId); }; pooled.bridge.onPermission = async (req) => { - const owner = cursorAcpSessionOwners.get(req.sessionId); - if (!owner || owner.runtime?.kind !== "cursor") { + const owner = acpHostSessionOwners.get(req.sessionId); + if (!owner || (owner.runtime?.kind !== "cursor" && owner.runtime?.kind !== "droid")) { return { outcome: { outcome: "cancelled" } }; } - const cursorRt = owner.runtime; + const acpRt = owner.runtime; // Auto-allow the ADE `ask_user` tool — the inline question card // provides its own answer UI, and the permission prompt just hides it. const rawInput = req.toolCall.rawInput as Record | null | undefined; @@ -11965,22 +12911,34 @@ export function createAgentChatService(args: { } } const itemId = randomUUID(); + const source = acpRt.kind === "droid" ? "droid" : "cursor"; return new Promise((outerResolve) => { - cursorRt.permissionWaiters.set(itemId, { + acpRt.permissionWaiters.set(itemId, { options: req.options, resolve: (resp: RequestPermissionResponse) => { - cursorRt.permissionWaiters.delete(itemId); + acpRt.permissionWaiters.delete(itemId); outerResolve(resp); }, }); - const request = buildCursorPendingInputRequest(itemId, req, cursorRt.activeTurnId ?? null); + const request = buildAcpHostPendingInputRequest( + itemId, + req, + source, + acpRt.activeTurnId ?? null, + ); emitChatEvent(owner, { type: "approval_request", itemId, kind: "tool_call", description: req.toolCall.title ?? "Permission required", - turnId: cursorRt.activeTurnId ?? undefined, - detail: { cursorAcp: true, request, toolCall: req.toolCall, options: req.options }, + turnId: acpRt.activeTurnId ?? undefined, + detail: { + cursorAcp: source === "cursor", + acpHost: source, + request, + toolCall: req.toolCall, + options: req.options, + }, }); }); }; @@ -11998,9 +12956,9 @@ export function createAgentChatService(args: { const existing = managed.runtime; if (existing.poolKey !== poolKey) { if (existing.acpSessionId) { - cursorAcpSessionOwners.delete(existing.acpSessionId); + acpHostSessionOwners.delete(existing.acpSessionId); try { - await existing.pooled?.connection.unstable_closeSession?.({ sessionId: existing.acpSessionId }); + await closeAcpSession(existing.pooled?.connection, existing.acpSessionId); } catch { // ignore } @@ -12009,11 +12967,11 @@ export function createAgentChatService(args: { w.resolve({ outcome: { outcome: "cancelled" } }); } existing.permissionWaiters.clear(); - if (existing.pooled) releaseCursorAcpConnection(existing.poolKey); + if (existing.pooled) releaseCursorAcpConnection(existing.poolKey, existing.poolGeneration); managed.runtime = null; } else { if (!existing.pooled) throw new Error("Cursor ACP connection not available"); - wireCursorAcpBridgeHandlers(existing.pooled); + wireAcpHostBridgeHandlers(existing.pooled); existing.pooled.bridge.getRootPath = () => managed.laneWorktreePath; existing.pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; return existing; @@ -12029,7 +12987,7 @@ export function createAgentChatService(args: { if (activeCount >= MAX_CONCURRENT_ACTIVE_RUNTIMES) evictLeastRecentRuntime(managed.session.id); } - const pooled = await acquireCursorAcpConnection({ + const acquired = await acquireCursorAcpConnection({ poolKey, agentPath: resolveCursorAgentExecutable().path, workspacePath: managed.laneWorktreePath, @@ -12037,13 +12995,16 @@ export function createAgentChatService(args: { launchSettings: resolveCursorAcpLaunchSettings(managed.session), appVersion, }); - wireCursorAcpBridgeHandlers(pooled); + const pooled = acquired.pooled; + const poolGeneration = acquired.generation; + wireAcpHostBridgeHandlers(pooled); pooled.bridge.getRootPath = () => managed.laneWorktreePath; pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; const rt: CursorRuntime = { kind: "cursor", poolKey, + poolGeneration, pooled, acpSessionId: null, activeTurnId: null, @@ -12064,12 +13025,13 @@ export function createAgentChatService(args: { managed.runtime = rt; const persistedAcp = readPersistedState(managed.session.id)?.acpSessionId?.trim(); - if (persistedAcp && typeof pooled.connection.unstable_resumeSession === "function") { + if (persistedAcp) { try { - const resumed = await pooled.connection.unstable_resumeSession({ + const resumed = await resumeAcpSession(pooled.connection, cursorAcpSessionRequest({ sessionId: persistedAcp, cwd: managed.laneWorktreePath, - }); + }) as ResumeSessionRequest); + if (!resumed) throw new Error("Cursor ACP agent does not support session resume"); const resumedAvailableModelIds = resumed.models?.availableModels ?.map((entry) => String(entry?.modelId ?? "").trim()) .filter(Boolean) ?? []; @@ -12088,7 +13050,7 @@ export function createAgentChatService(args: { syncCursorSessionDescriptor(managed, rt.currentModelId); } syncCursorModeSnapshot(managed, rt); - cursorAcpSessionOwners.set(persistedAcp, managed); + acpHostSessionOwners.set(persistedAcp, managed); } catch { // stale session id — create a new ACP session on first prompt } @@ -12196,7 +13158,7 @@ export function createAgentChatService(args: { syncCursorSessionDescriptor(managed, runtime.currentModelId); } syncCursorModeSnapshot(managed, runtime); - cursorAcpSessionOwners.set(sid, managed); + acpHostSessionOwners.set(sid, managed); persistChatState(managed); } @@ -12278,7 +13240,13 @@ export function createAgentChatService(args: { persistChatState(managed); } catch (error) { markSessionIdleWithFreshCache(managed); - const msg = error instanceof Error ? error.message : String(error); + const descriptor = resolveSessionModelDescriptor(managed.session); + const acpError = classifyAcpHostError( + error, + "Cursor", + descriptor?.displayName ?? managed.session.model, + ); + const msg = acpError.message; // Drain pending permission waiters so they don't block future sends. for (const [, w] of runtime.permissionWaiters) { @@ -12301,7 +13269,13 @@ export function createAgentChatService(args: { emitChatEvent(managed, ev); } } else { - emitChatEvent(managed, { type: "error", message: msg, turnId }); + emitChatEvent(managed, { + type: "error", + message: msg, + ...(acpError.detail ? { detail: acpError.detail } : {}), + errorInfo: acpError.errorInfo, + turnId, + }); emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); emitChatEvent(managed, { type: "done", @@ -12335,79 +13309,518 @@ export function createAgentChatService(args: { } }; - const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => { - const { - sessionId, - managed, - promptText, - visibleText, - attachments, - resolvedAttachments, - reasoningEffort, - laneDirectiveKey, - providerSlashCommand, - forceClaudeUserMessage, - onDispatched, - turnId, - optimisticCursorTurnStart, - } = prepared; + const droidPoolKeyFor = (managed: ManagedChatSession, resolvedModelId: string): string => { + const launch = resolveDroidAcpLaunchSettings(managed.session); + return [ + managed.session.laneId, + managed.laneWorktreePath, + resolvedModelId, + launch.autonomy, + ].join(":"); + }; - // OpenCode runtime dispatch - if (managed.session.provider === "opencode") { - if (!managed.runtime || managed.runtime.kind !== "opencode") { - const restarted = await startOpenCodeSessionRuntime(managed); - if (restarted !== "handled" || !managed.runtime) { - throw new Error(`OpenCode runtime is not available for session '${managed.session.id}'.`); + const ensureDroidRuntime = async (managed: ManagedChatSession): Promise => { + const launchModelId = resolveDroidRuntimeModelId(managed.session); + const poolKey = droidPoolKeyFor(managed, launchModelId); + const shouldSyncSessionModel = managed.session.model !== launchModelId || !managed.session.modelId; + if (shouldSyncSessionModel) { + syncDroidSessionDescriptor(managed, launchModelId); + persistChatState(managed); + } + if (managed.runtime?.kind === "droid") { + const existing = managed.runtime; + if (existing.poolKey !== poolKey) { + if (existing.acpSessionId) { + acpHostSessionOwners.delete(existing.acpSessionId); + try { + await closeAcpSession(existing.pooled?.connection, existing.acpSessionId); + } catch { + // ignore + } } - } - if (reasoningEffort) { - managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); - } - // Re-sync permission mode so mid-session changes take effect on this turn. - if (managed.runtime?.kind === "opencode") { - const chatConfig = resolveChatConfig(); - const previousPermissionMode = managed.runtime.permissionMode; - managed.runtime.permissionMode = resolveSessionOpenCodePermissionMode( - managed.session, - chatConfig.opencodePermissionMode, - ); - if (managed.runtime.permissionMode !== previousPermissionMode) { - persistChatState(managed); + for (const [, w] of existing.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); } + existing.permissionWaiters.clear(); + if (existing.pooled) releaseDroidAcpConnection(existing.poolKey, existing.poolGeneration); + managed.runtime = null; + } else { + if (!existing.pooled) throw new Error("Droid ACP connection not available"); + droidRuntimeSetupInterruptRequested.delete(managed); + wireAcpHostBridgeHandlers(existing.pooled); + existing.pooled.bridge.getRootPath = () => managed.laneWorktreePath; + existing.pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; + await ensureDroidSessionState(managed, existing); + persistChatState(managed); + return existing; } - await runTurn(managed, { - promptText, - displayText: visibleText, - attachments, - resolvedAttachments, - laneDirectiveKey, - providerSlashCommand, - onDispatched, - }); - return; - } - - if (managed.session.provider === "cursor") { - const chatConfig = resolveChatConfig(); - managed.session.opencodePermissionMode = resolveSessionOpenCodePermissionMode( - managed.session, - chatConfig.opencodePermissionMode, - ); - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; - await runCursorTurn(managed, { - promptText, - displayText: visibleText, - attachments, - resolvedAttachments, - laneDirectiveKey, - turnId, - optimisticCursorTurnStart, - onDispatched, - }); - return; + } else if (managed.runtime) { + teardownRuntime(managed, "handle_close"); } - if (managed.session.provider === "codex") { + { + let activeCount = 0; + for (const [, s] of managedSessions) { if (s.runtime) activeCount++; } + if (activeCount >= MAX_CONCURRENT_ACTIVE_RUNTIMES) evictLeastRecentRuntime(managed.session.id); + } + + const throwIfDroidSetupInterrupted = (): void => { + if (!droidRuntimeSetupInterruptRequested.get(managed)) return; + droidRuntimeSetupInterruptRequested.delete(managed); + throw new Error("Droid session interrupted."); + }; + + throwIfDroidSetupInterrupted(); + let pooled: DroidAcpPooled | null = null; + let poolGeneration = 0; + let released = false; + try { + const auth = await detectAuth(); + throwIfDroidSetupInterrupted(); + const acquired = await acquireDroidAcpConnection({ + poolKey, + droidPath: resolveDroidExecutable({ auth }).path, + workspacePath: managed.laneWorktreePath, + modelId: launchModelId, + launchSettings: resolveDroidAcpLaunchSettings(managed.session), + appVersion, + }); + pooled = acquired.pooled; + poolGeneration = acquired.generation; + throwIfDroidSetupInterrupted(); + wireAcpHostBridgeHandlers(pooled); + pooled.bridge.getRootPath = () => managed.laneWorktreePath; + pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; + + const rt: DroidRuntime = { + kind: "droid", + poolKey, + poolGeneration, + pooled, + acpSessionId: null, + activeTurnId: null, + busy: false, + interrupted: false, + modelId: launchModelId, + currentModelId: null, + availableModelIds: [], + acpModelIdByDisplayKey: new Map(), + displayKeyByAcpModelId: new Map(), + pendingSteers: [], + permissionWaiters: new Map(), + }; + + const persistedAcp = readPersistedState(managed.session.id)?.acpSessionId?.trim(); + if (persistedAcp) { + try { + const resumed = await resumeAcpSession(pooled.connection, cursorAcpSessionRequest({ + sessionId: persistedAcp, + cwd: managed.laneWorktreePath, + }) as ResumeSessionRequest); + if (!resumed) throw new Error("Droid ACP agent does not support session resume"); + rt.acpSessionId = persistedAcp; + applyDroidModelSnapshot(managed, rt, resumed); + acpHostSessionOwners.set(persistedAcp, managed); + } catch { + // stale session id — create a new ACP session on first prompt + } + } + + throwIfDroidSetupInterrupted(); + if (managed.closed) { + releaseDroidAcpConnection(poolKey, poolGeneration); + released = true; + droidRuntimeSetupInterruptRequested.delete(managed); + throw new Error("Droid session closed during setup."); + } + managed.runtime = rt; + await ensureDroidSessionState(managed, rt); + persistChatState(managed); + droidRuntimeSetupInterruptRequested.delete(managed); + return rt; + } catch (err) { + if (!released && pooled && managed.runtime?.kind !== "droid") { + releaseDroidAcpConnection(poolKey, poolGeneration); + } + droidRuntimeSetupInterruptRequested.delete(managed); + throw err; + } + }; + + const runDroidTurn = async ( + managed: ManagedChatSession, + args: { + promptText: string; + displayText: string; + attachments: AgentChatFileRef[]; + resolvedAttachments: ResolvedAgentChatFileRef[]; + laneDirectiveKey?: string | null; + turnId?: string; + optimisticDroidTurnStart?: boolean; + onDispatched?: () => void; + }, + ): Promise => { + const turnId = args.turnId ?? randomUUID(); + let runtime: DroidRuntime; + try { + runtime = await ensureDroidRuntime(managed); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === "Droid session interrupted." || msg === "Droid session closed during setup.") { + managed.session.status = "idle"; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + })) { + emitChatEvent(managed, ev); + } + persistChatState(managed); + return; + } + throw e; + } + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) { + throw new Error(validation.reason); + } + runtime.interrupted = false; + runtime.busy = true; + runtime.activeTurnId = turnId; + setSessionActive(managed); + + const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText; + if (!args.optimisticDroidTurnStart) { + emitPreparedUserMessage(managed, { + text: displayText, + attachments: args.attachments, + turnId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + captureTurnBeforeSha(managed); + } + emitChatEvent(managed, { + type: "activity", + ...initialTurnActivity(managed.session), + turnId, + }); + + const turnStartedAt = Date.now(); + let shouldDeliverQueuedSteer = false; + try { + const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, displayText, args.attachments); + const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); + if (autoMemoryNotice) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "memory", + message: autoMemoryNotice.message, + detail: autoMemoryNotice.detail, + turnId, + }); + } + + let composed = args.promptText; + const reconstructionContext = managed.pendingReconstructionContext?.trim() ?? ""; + if (reconstructionContext.length) { + composed = [ + "System context (CTO reconstruction, do not echo verbatim):", + reconstructionContext, + "", + composed, + ].join("\n"); + managed.pendingReconstructionContext = null; + } + if (autoMemoryPlan.contextText.length) { + composed = `${autoMemoryPlan.contextText}\n\n${composed}`; + } + + if (runtime.interrupted) { + managed.session.status = "idle"; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + })) { + emitChatEvent(managed, ev); + } + persistChatState(managed); + return; + } + + const promptBlocks = buildCursorAcpPromptBlocks(composed, args.resolvedAttachments); + + if (!runtime.acpSessionId) { + if (!runtime.pooled) throw new Error("Droid ACP connection not available"); + const created = await runtime.pooled.connection.newSession(cursorAcpSessionRequest({ + cwd: managed.laneWorktreePath, + }) as Parameters[0]); + const sid = created.sessionId; + runtime.acpSessionId = sid; + applyDroidModelSnapshot(managed, runtime, created); + acpHostSessionOwners.set(sid, managed); + persistChatState(managed); + } + + await ensureDroidSessionState(managed, runtime); + if (runtime.interrupted) { + managed.session.status = "idle"; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + })) { + emitChatEvent(managed, ev); + } + persistChatState(managed); + return; + } + persistChatState(managed); + + logger.info("agent_chat.droid_prompt_start", { + sessionId: managed.session.id, + turnId, + model: managed.session.model, + durationMs: Date.now() - turnStartedAt, + }); + + if (!runtime.pooled) throw new Error("Droid ACP connection not available"); + + if (args.onDispatched) { + args.onDispatched(); + args.onDispatched = undefined; + } + + const promptRes = await runtime.pooled.connection.prompt({ + sessionId: runtime.acpSessionId!, + prompt: promptBlocks, + }); + + await refreshDroidSessionState(managed, runtime, "after_prompt"); + + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + + const descriptor = resolveSessionModelDescriptor(managed.session); + const usage = promptRes.usage + ? { + inputTokens: promptRes.usage.inputTokens, + outputTokens: promptRes.usage.outputTokens, + cacheReadTokens: promptRes.usage.cachedReadTokens ?? null, + cacheCreationTokens: promptRes.usage.cachedWriteTokens ?? null, + } + : undefined; + + void emitTurnDiffSummaryIfChanged(managed, turnId); + if (runtime.interrupted || promptRes.stopReason === "cancelled") { + markSessionIdleWithFreshCache(managed); + cancelQueuedSteers(managed, runtime, "interrupted"); + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + usage, + })) { + emitChatEvent(managed, ev); + } + } else { + markSessionIdleWithFreshCache(managed); + emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: promptRes.stopReason, + turnId, + model: managed.session.model, + ...(managed.session.modelId + ? { modelId: managed.session.modelId } + : descriptor + ? { modelId: descriptor.id } + : {}), + usage, + })) { + emitChatEvent(managed, ev); + } + shouldDeliverQueuedSteer = runtime.pendingSteers.length > 0; + } + + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: "Droid agent turn completed.", + }); + persistChatState(managed); + } catch (error) { + markSessionIdleWithFreshCache(managed); + const descriptor = resolveSessionModelDescriptor(managed.session); + const acpError = classifyAcpHostError( + error, + "Factory Droid", + descriptor?.displayName ?? managed.session.model, + ); + const msg = acpError.message; + const treatAsInterrupt = + runtime.interrupted || msg === "Droid session closed during setup."; + + for (const [, w] of runtime.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); + } + runtime.permissionWaiters.clear(); + + cancelQueuedSteers(managed, runtime, treatAsInterrupt ? "interrupted" : "failed"); + void emitTurnDiffSummaryIfChanged(managed, turnId); + + if (treatAsInterrupt) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + })) { + emitChatEvent(managed, ev); + } + } else { + emitChatEvent(managed, { + type: "error", + message: msg, + ...(acpError.detail ? { detail: acpError.detail } : {}), + errorInfo: acpError.errorInfo, + turnId, + }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: `Turn failed: ${msg}`, + }); + } + persistChatState(managed); + } finally { + runtime.busy = false; + runtime.activeTurnId = null; + if (managed.session.status === "active") { + setSessionIdle(managed); + } + } + if (!managed.closed && shouldDeliverQueuedSteer) { + try { + await deliverNextQueuedSteer(managed, runtime); + } catch (error) { + logger.warn("agent_chat.droid_deliver_queued_steer_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => { + const { + sessionId, + managed, + promptText, + visibleText, + attachments, + resolvedAttachments, + reasoningEffort, + laneDirectiveKey, + providerSlashCommand, + forceClaudeUserMessage, + onDispatched, + turnId, + optimisticCursorTurnStart, + optimisticAcpTurnStart, + } = prepared; + + // OpenCode runtime dispatch + if (managed.session.provider === "opencode") { + if (!managed.runtime || managed.runtime.kind !== "opencode") { + const restarted = await startOpenCodeSessionRuntime(managed); + if (restarted !== "handled" || !managed.runtime) { + throw new Error(`OpenCode runtime is not available for session '${managed.session.id}'.`); + } + } + if (reasoningEffort) { + managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); + } + // Re-sync permission mode so mid-session changes take effect on this turn. + if (managed.runtime?.kind === "opencode") { + const chatConfig = resolveChatConfig(); + const previousPermissionMode = managed.runtime.permissionMode; + managed.runtime.permissionMode = resolveSessionOpenCodePermissionMode( + managed.session, + chatConfig.opencodePermissionMode, + ); + if (managed.runtime.permissionMode !== previousPermissionMode) { + persistChatState(managed); + } + } + await runTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + providerSlashCommand, + onDispatched, + }); + return; + } + + if (managed.session.provider === "cursor") { + const chatConfig = resolveChatConfig(); + managed.session.opencodePermissionMode = resolveSessionOpenCodePermissionMode( + managed.session, + chatConfig.opencodePermissionMode, + ); + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + await runCursorTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + turnId, + optimisticCursorTurnStart, + onDispatched, + }); + return; + } + + if (managed.session.provider === "droid") { + const chatConfig = resolveChatConfig(); + managed.session.opencodePermissionMode = resolveSessionOpenCodePermissionMode( + managed.session, + chatConfig.opencodePermissionMode, + ); + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + await runDroidTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + turnId, + optimisticDroidTurnStart: optimisticAcpTurnStart, + onDispatched, + }); + return; + } + + if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); const nextReasoningEffort = validateReasoningEffort("codex", normalizeReasoningEffort(reasoningEffort)); if (nextReasoningEffort) { @@ -12545,10 +13958,14 @@ export function createAgentChatService(args: { }) : null; - if (prepared.managed.session.provider === "cursor") { + if (prepared.managed.session.provider === "cursor" || prepared.managed.session.provider === "droid") { const turnId = randomUUID(); prepared.turnId = turnId; - prepared.optimisticCursorTurnStart = true; + if (prepared.managed.session.provider === "cursor") { + prepared.optimisticCursorTurnStart = true; + } else { + prepared.optimisticAcpTurnStart = true; + } emitChatEvent(prepared.managed, { type: "user_message", text: prepared.visibleText, @@ -12698,6 +14115,50 @@ export function createAgentChatService(args: { return { steerId, queued: false }; } + if (managed.session.provider === "droid") { + if (managed.runtime?.kind === "droid" && managed.runtime.busy) { + const rt = managed.runtime; + if (rt.pendingSteers.length >= MAX_PENDING_STEERS) { + logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: rt.pendingSteers.length }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Steer dropped — the queue is full. Wait for the current turn to finish.", + turnId: rt.activeTurnId ?? undefined, + }); + return { steerId, queued: false }; + } + rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], resolvedAttachments: [] }); + emitChatEvent(managed, { + type: "user_message", + text: trimmed, + steerId, + turnId: rt.activeTurnId ?? undefined, + deliveryState: "queued", + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Message queued — will be sent when the current turn completes.", + turnId: rt.activeTurnId ?? undefined, + }); + persistChatState(managed); + return { steerId, queued: true }; + } + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments: [], + }); + if (!preparedSteer) { + return { steerId, queued: false }; + } + await executePreparedSendMessage(preparedSteer); + return { steerId, queued: false }; + } + if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); await runtime.collaborationModesReady?.catch(() => {}); @@ -13050,6 +14511,31 @@ export function createAgentChatService(args: { return; } + if (managed.runtime?.kind === "droid") { + const rt = managed.runtime; + rt.interrupted = true; + if (rt.acpSessionId) { + try { + await rt.pooled?.connection.cancel({ sessionId: rt.acpSessionId }); + } catch { + // ignore + } + } + for (const [, w] of rt.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); + } + rt.permissionWaiters.clear(); + cancelQueuedSteers(managed, rt, "interrupted"); + return; + } + + if (managed.session.provider === "droid") { + droidRuntimeSetupInterruptRequested.set(managed, true); + cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); + persistChatState(managed); + return; + } + if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); await runtime.collaborationModesReady?.catch(() => {}); @@ -13210,6 +14696,12 @@ export function createAgentChatService(args: { managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; enforceManagedLocalHarnessPermissionMode(managed); sessionService.setResumeCommand(sessionId, `chat:cursor:${sessionId}`); + } else if (managed.session.provider === "droid") { + await ensureDroidRuntime(managed); + managed.session.opencodePermissionMode = persisted?.opencodePermissionMode ?? managed.session.opencodePermissionMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + enforceManagedLocalHarnessPermissionMode(managed); + sessionService.setResumeCommand(sessionId, `chat:droid:${sessionId}`); } else if (managed.session.provider === "opencode" || (managed.session.modelId && !isCliWrappedModelId(managed.session.modelId))) { const result = await startOpenCodeSessionRuntime(managed); if (result === "handled" && managed.runtime?.kind === "opencode") { @@ -13286,6 +14778,8 @@ export function createAgentChatService(args: { ? DEFAULT_OPENCODE_MODEL_ID : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id + : provider === "droid" + ? DEFAULT_DROID_DESCRIPTOR?.id : undefined); const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; return { @@ -13315,6 +14809,9 @@ export function createAgentChatService(args: { ...(liveSession?.opencodePermissionMode || persisted?.opencodePermissionMode ? { opencodePermissionMode: liveSession?.opencodePermissionMode ?? persisted?.opencodePermissionMode } : {}), + ...(liveSession?.droidPermissionMode || persisted?.droidPermissionMode + ? { droidPermissionMode: liveSession?.droidPermissionMode ?? persisted?.droidPermissionMode } + : {}), ...(liveSession?.cursorModeSnapshot || persisted?.cursorModeSnapshot ? { cursorModeSnapshot: liveSession?.cursorModeSnapshot ?? persisted?.cursorModeSnapshot } : {}), @@ -13454,6 +14951,8 @@ export function createAgentChatService(args: { if (workerIdentity?.adapterType === "openclaw-webhook" || workerIdentity?.adapterType === "process") return "opencode"; if (preferredProviderRaw.includes("codex") || preferredProviderRaw.includes("openai")) return "codex"; if (preferredProviderRaw.includes("claude") || preferredProviderRaw.includes("anthropic")) return "claude"; + if (preferredProviderRaw.includes("droid") || preferredProviderRaw.includes("factory")) return "droid"; + if (preferredProviderRaw.includes("cursor")) return "cursor"; return "opencode"; })(); @@ -13473,6 +14972,8 @@ export function createAgentChatService(args: { if (!resolvedDescriptor.isCliWrapped) return "opencode"; if (resolvedDescriptor.family === "openai") return "codex"; if (resolvedDescriptor.family === "anthropic") return "claude"; + if (resolvedDescriptor.family === "cursor") return "cursor"; + if (resolvedDescriptor.family === "factory") return "droid"; return providerFromPreference; })(); @@ -13694,7 +15195,7 @@ export function createAgentChatService(args: { return; } - if (managed.runtime?.kind === "cursor") { + if (managed.runtime?.kind === "cursor" || managed.runtime?.kind === "droid") { const pending = managed.runtime.permissionWaiters.get(itemId); if (!pending) { // Treat missing waiter as a benign race (e.g. the Cursor turn already @@ -13786,6 +15287,27 @@ export function createAgentChatService(args: { } } + if (provider === "droid") { + try { + const auth = await detectAuth(); + const droidPath = resolveDroidExecutable({ auth }).path; + const ordered = await discoverDroidCliModelDescriptors(droidPath); + const preferred = pickDefaultDroidDescriptorFromCliList(ordered); + return ordered.map((d) => ({ + id: d.id, + displayName: d.displayName, + description: `${d.displayName} (Factory Droid CLI)`, + isDefault: preferred ? d.id === preferred.id : false, + reasoningEfforts: d.reasoningTiers?.map((tier) => ({ + effort: tier, + description: `${tier} reasoning`, + })) ?? [], + })); + } catch { + return []; + } + } + if (provider === "opencode") { try { const effectiveConfig = projectConfigService.get().effective; @@ -13952,6 +15474,18 @@ export function createAgentChatService(args: { } } + if (managed.runtime?.kind === "droid") { + managed.runtime.interrupted = true; + cancelQueuedSteers(managed, managed.runtime, "disposed"); + if (managed.runtime.acpSessionId) { + try { + await managed.runtime.pooled?.connection.cancel({ sessionId: managed.runtime.acpSessionId }); + } catch { + // ignore + } + } + } + // Mark streaming runtimes as interrupted so the catch block handles gracefully if (managed.runtime?.kind === "claude" || managed.runtime?.kind === "opencode") { managed.runtime.interrupted = true; @@ -14134,6 +15668,7 @@ export function createAgentChatService(args: { codexSandbox, codexConfigSource, opencodePermissionMode, + droidPermissionMode, cursorModeId, cursorConfigValues, permissionMode, @@ -14299,6 +15834,10 @@ export function createAgentChatService(args: { managed.session.opencodePermissionMode = opencodePermissionMode; } + if (droidPermissionMode !== undefined && !identityPinned) { + managed.session.droidPermissionMode = droidPermissionMode; + } + if (cursorModeId !== undefined) { managed.session.cursorModeId = typeof cursorModeId === "string" ? (cursorModeId.trim() || null) @@ -14320,6 +15859,7 @@ export function createAgentChatService(args: { || codexSandbox !== undefined || codexConfigSource !== undefined || opencodePermissionMode !== undefined + || droidPermissionMode !== undefined || cursorModeId !== undefined || cursorConfigValues !== undefined ) { @@ -14370,6 +15910,9 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "cursor" && !managed.runtime.busy) { await ensureCursorSessionState(managed, managed.runtime); } + if (managed.runtime?.kind === "droid" && !managed.runtime.busy) { + await ensureDroidSessionState(managed, managed.runtime); + } } if (title !== undefined) { @@ -14418,8 +15961,9 @@ export function createAgentChatService(args: { if (!descriptor) return; const isCursorCli = descriptor.family === "cursor" && descriptor.isCliWrapped; + const isDroidCli = descriptor.family === "factory" && descriptor.isCliWrapped; const isAnthropicCli = descriptor.family === "anthropic" && descriptor.isCliWrapped; - if (!isAnthropicCli && !isCursorCli) return; + if (!isAnthropicCli && !isCursorCli && !isDroidCli) return; if (isCursorCli) { if (managed.session.provider !== "cursor") return; @@ -14453,13 +15997,36 @@ export function createAgentChatService(args: { syncCursorSessionDescriptor(managed, runtime.currentModelId); } syncCursorModeSnapshot(managed, runtime); - cursorAcpSessionOwners.set(sid, managed); + acpHostSessionOwners.set(sid, managed); } await ensureCursorSessionState(managed, runtime); persistChatState(managed); return; } + if (isDroidCli) { + if (managed.session.provider !== "droid") return; + if (managed.session.modelId !== descriptor.id) return; + if (managed.session.status === "active") return; + if (managed.runtime && managed.runtime.kind !== "droid") return; + if (managed.runtime?.kind === "droid" && managed.runtime.busy) return; + + const runtime = await ensureDroidRuntime(managed); + if (!runtime.pooled) return; + if (!runtime.acpSessionId) { + const created = await runtime.pooled.connection.newSession(cursorAcpSessionRequest({ + cwd: managed.laneWorktreePath, + }) as Parameters[0]); + const sid = created.sessionId; + runtime.acpSessionId = sid; + applyDroidModelSnapshot(managed, runtime, created); + acpHostSessionOwners.set(sid, managed); + } + await ensureDroidSessionState(managed, runtime); + persistChatState(managed); + return; + } + // Warmup should never rewrite the live session model. It's only allowed to // prime the currently-selected Claude runtime when the backend session is // already aligned with the requested model and fully idle. diff --git a/apps/desktop/src/main/services/chat/cursorAcpPool.ts b/apps/desktop/src/main/services/chat/cursorAcpPool.ts index 0d6d66b63..ef30ad8e8 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpPool.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpPool.ts @@ -1,240 +1,8 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { Readable, Writable } from "node:stream"; -import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Client, - type CreateTerminalRequest, - type KillTerminalRequest, - type ReadTextFileRequest, - type ReadTextFileResponse, - type ReleaseTerminalRequest, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, - type TerminalOutputRequest, - type TerminalOutputResponse, - type WaitForTerminalExitRequest, - type WaitForTerminalExitResponse, - type WriteTextFileRequest, - type WriteTextFileResponse, -} from "@agentclientprotocol/sdk"; -import { hasNullByte, readFileWithinRootSecure, secureWriteTextAtomicWithinRoot } from "../shared/utils"; -import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; +import type { ClientSideConnection, InitializeResponse } from "@agentclientprotocol/sdk"; +import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; +import { acquireAcpCliConnection, hasActiveAcpCliPoolEntry, releaseAcpCliConnection } from "./acpCliPool"; -export type CursorAcpBridge = { - onPermission: ((req: RequestPermissionRequest) => Promise) | null; - onSessionUpdate: ((n: SessionNotification) => void) | null; - getRootPath: () => string; - getDirtyFileText: ((absPath: string) => string | undefined | Promise) | null; - /** Fired after stdout/stderr appends — used to stream shell output into chat. */ - onTerminalOutputDelta: ((terminalId: string, acpSessionId: string) => void) | null; - /** Flush debounced terminal streaming (e.g. on process exit). */ - flushTerminalOutput: ((terminalId: string, acpSessionId: string) => void) | null; - onTerminalDisposed: ((terminalId: string) => void) | null; -}; - -type TermState = { - proc: ChildProcessWithoutNullStreams; - output: string; - truncated: boolean; - limit: number; - cwd: string; - command: string; - exited: boolean; - exitCode: number | null; - exitSignal: NodeJS.Signals | null; - acpSessionId: string; -}; - -function mergeEnvVars( - base: NodeJS.ProcessEnv, - extra?: Array<{ name: string; value: string }>, -): NodeJS.ProcessEnv { - const out = { ...base }; - if (!extra) return out; - for (const { name, value } of extra) { - if (name) out[name] = value; - } - return out; -} - -function appendOutput(state: TermState, chunk: Buffer | string): void { - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - state.output += text; - const lim = state.limit > 0 ? state.limit : 512 * 1024; - if (state.output.length > lim) { - state.output = state.output.slice(state.output.length - lim); - state.truncated = true; - } -} - -async function resolveDirtyText( - bridge: CursorAcpBridge, - filePath: string, -): Promise { - const raw = bridge.getDirtyFileText?.(filePath); - const v = await Promise.resolve(raw); - return typeof v === "string" ? v : undefined; -} - -function createCursorAcpClient(bridge: CursorAcpBridge, terminals: Map): Client { - return { - async requestPermission(params: RequestPermissionRequest): Promise { - const handler = bridge.onPermission; - if (!handler) { - return { outcome: { outcome: "cancelled" } }; - } - return handler(params); - }, - - async sessionUpdate(params: SessionNotification): Promise { - bridge.onSessionUpdate?.(params); - }, - - async readTextFile(params: ReadTextFileRequest): Promise { - const p = params.path.trim(); - if (!path.isAbsolute(p)) { - throw new Error("ACP read_text_file requires an absolute path."); - } - const root = bridge.getRootPath(); - let buf: Buffer; - try { - buf = readFileWithinRootSecure(root, p); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err?.code === "ENOENT") { - const dirty = await resolveDirtyText(bridge, p); - if (dirty !== undefined) return { content: applyLineLimit(dirty, params.line, params.limit) }; - } - throw e; - } - if (hasNullByte(buf)) { - throw new Error("Binary files cannot be read as text."); - } - let text = buf.toString("utf8"); - const dirty = await resolveDirtyText(bridge, p); - if (dirty !== undefined) text = dirty; - return { content: applyLineLimit(text, params.line, params.limit) }; - }, - - async writeTextFile(params: WriteTextFileRequest): Promise { - const p = params.path.trim(); - if (!path.isAbsolute(p)) { - throw new Error("ACP write_text_file requires an absolute path."); - } - const root = bridge.getRootPath(); - secureWriteTextAtomicWithinRoot(root, p, params.content); - return {}; - }, - - async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { - const cwd = (params.cwd && params.cwd.trim()) || bridge.getRootPath(); - const termId = randomUUID(); - const limit = typeof params.outputByteLimit === "number" && params.outputByteLimit > 0 - ? params.outputByteLimit - : 512 * 1024; - const env = mergeEnvVars(process.env, params.env ?? undefined); - const invocation = resolveCliSpawnInvocation(params.command, params.args ?? [], env); - const proc = spawn(invocation.command, invocation.args, { - cwd, - env, - stdio: ["pipe", "pipe", "pipe"], - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - proc.on("error", (err) => { - console.error(`[CursorAcpPool] terminal process error for termId=${termId}:`, err); - const t = terminals.get(termId); - if (t && !t.exited) { - t.exited = true; - t.exitCode = -1; - bridge.flushTerminalOutput?.(termId, params.sessionId); - } - }); - const state: TermState = { - proc, - output: "", - truncated: false, - limit, - cwd, - command: `${params.command} ${(params.args ?? []).join(" ")}`.trim(), - exited: false, - exitCode: null, - exitSignal: null, - acpSessionId: params.sessionId, - }; - proc.stdout?.on("data", (d) => { - appendOutput(state, d); - bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); - }); - proc.stderr?.on("data", (d) => { - appendOutput(state, d); - bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); - }); - proc.on("close", (code, signal) => { - state.exited = true; - state.exitCode = code; - state.exitSignal = signal; - bridge.flushTerminalOutput?.(termId, state.acpSessionId); - }); - terminals.set(termId, state); - return { terminalId: termId }; - }, - - async terminalOutput(params: TerminalOutputRequest): Promise { - const t = terminals.get(params.terminalId); - if (!t) { - return { output: "", truncated: false }; - } - return { - output: t.output, - truncated: t.truncated, - ...(t.exited ? { exitStatus: { exitCode: t.exitCode, signal: t.exitSignal } } : {}), - }; - }, - - async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise { - const t = terminals.get(params.terminalId); - if (!t) { - return { exitCode: -1, signal: null }; - } - if (!t.exited) { - await new Promise((resolve) => { - const done = () => resolve(); - t.proc.once("close", done); - }); - } - return { exitCode: t.exitCode ?? -1, signal: t.exitSignal }; - }, - - async killTerminal(params: KillTerminalRequest): Promise { - const t = terminals.get(params.terminalId); - if (t && !t.exited) { - terminateProcessTree(t.proc, "SIGTERM"); - } - }, - - async releaseTerminal(params: ReleaseTerminalRequest): Promise { - const t = terminals.get(params.terminalId); - if (t) { - if (!t.exited) terminateProcessTree(t.proc, "SIGKILL"); - const id = params.terminalId; - terminals.delete(id); - bridge.onTerminalDisposed?.(id); - } - }, - }; -} - -function applyLineLimit(text: string, line?: number | null, limit?: number | null): string { - const lines = text.split(/\r?\n/); - const start = typeof line === "number" && line > 0 ? line - 1 : 0; - const max = typeof limit === "number" && limit > 0 ? limit : lines.length; - return lines.slice(start, start + max).join("\n"); -} +export type CursorAcpBridge = AcpHostBridge; export type CursorTerminalWorkLogBinding = { itemId: string; @@ -246,7 +14,7 @@ export type CursorTerminalWorkLogBinding = { export type CursorAcpPooled = { connection: ClientSideConnection; bridge: CursorAcpBridge; - terminals: Map; + terminals: Map; /** Maps ACP terminal id → work chat command row identity for streaming output */ terminalWorkLogBindings: Map; terminalOutputTimers: Map>; @@ -259,7 +27,20 @@ export type CursorAcpLaunchSettings = { force: boolean; }; -const pool = new Map(); +let cursorGenCounter = 0; +const cursorPools = new Map(); +const pendingCursorInit = new Map>(); + +function internalPoolKey(poolKey: string): string { + return `cursor:${poolKey}`; +} + +function clearCursorTerminalTimers(pooled: CursorAcpPooled): void { + for (const h of pooled.terminalOutputTimers.values()) { + clearTimeout(h); + } + pooled.terminalOutputTimers.clear(); +} export async function acquireCursorAcpConnection(args: { poolKey: string; @@ -268,13 +49,7 @@ export async function acquireCursorAcpConnection(args: { modelSdkId: string; launchSettings: CursorAcpLaunchSettings; appVersion: string; -}): Promise { - const existing = pool.get(args.poolKey); - if (existing) { - existing.ref += 1; - return existing.pooled; - } - +}): Promise<{ pooled: CursorAcpPooled; generation: number }> { const spawnArgs = [ "acp", "--workspace", @@ -295,94 +70,102 @@ export async function acquireCursorAcpConnection(args: { spawnArgs.push("--api-key", apiKey); } - const env = { ...process.env }; - const invocation = resolveCliSpawnInvocation(args.agentPath, spawnArgs, env); - const proc = spawn(invocation.command, invocation.args, { - stdio: ["pipe", "pipe", "pipe"], - env, - cwd: args.workspacePath, - detached: process.platform !== "win32", - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - - proc.on("error", (err) => { - console.error(`[CursorAcpPool] agent process error for poolKey=${args.poolKey}:`, err); - const entry = pool.get(args.poolKey); - if (entry) { - entry.pooled.dispose(); - pool.delete(args.poolKey); - } - }); - - const terminals = new Map(); - const bridge: CursorAcpBridge = { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, + const acpOptions = { + poolKey: internalPoolKey(args.poolKey), + logPrefix: "[CursorAcpPool]", + appVersion: args.appVersion, + spawn: { + command: args.agentPath, + args: spawnArgs, + cwd: args.workspacePath, + env: { ...process.env } as NodeJS.ProcessEnv, + }, + afterInitialize: async ({ connection, initResult }: { connection: ClientSideConnection; initResult: InitializeResponse }) => { + const authMethods = initResult.authMethods ?? []; + const needsCursorLogin = authMethods.some( + (m: (typeof authMethods)[number]) => "id" in m && m.id === "cursor_login", + ); + if (needsCursorLogin && !apiKey) { + await connection.authenticate({ methodId: "cursor_login" }).catch(() => { + // Interactive login may fail headless — user should run `agent login` + }); + } + }, }; - const client = createCursorAcpClient(bridge, terminals); - const input = Writable.toWeb(proc.stdin) as WritableStream; - const output = Readable.toWeb(proc.stdout) as ReadableStream; - const stream = ndJsonStream(input, output); - const connection = new ClientSideConnection(() => client, stream); - - const init = await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientInfo: { name: "ade", title: "ADE", version: args.appVersion }, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: true, - }, - }); + const innerKey = internalPoolKey(args.poolKey); + const staleOuter = cursorPools.get(args.poolKey); + if (staleOuter && !hasActiveAcpCliPoolEntry(innerKey)) { + cursorPools.delete(args.poolKey); + } - const authMethods = init.authMethods ?? []; - const needsCursorLogin = authMethods.some((m) => "id" in m && m.id === "cursor_login"); - if (needsCursorLogin && !apiKey) { - await connection.authenticate({ methodId: "cursor_login" }).catch(() => { - // Interactive login may fail headless — user should run `agent login` - }); + const existing = cursorPools.get(args.poolKey); + if (existing && hasActiveAcpCliPoolEntry(innerKey)) { + await acquireAcpCliConnection(acpOptions); + existing.ref += 1; + return { pooled: existing.pooled, generation: existing.generation }; } - const terminalWorkLogBindings = new Map(); - const terminalOutputTimers = new Map>(); + // Existing entry is stale — clean its terminal timers and remove before creating a new one. + if (existing) { + clearCursorTerminalTimers(existing.pooled); + cursorPools.delete(args.poolKey); + } - const pooled: CursorAcpPooled = { - connection, - bridge, - terminals, - terminalWorkLogBindings, - terminalOutputTimers, - dispose: () => { - for (const termId of terminals.keys()) { - bridge.onTerminalDisposed?.(termId); - } - for (const t of terminals.values()) { - if (!t.exited) terminateProcessTree(t.proc, "SIGKILL"); - } - terminals.clear(); - terminateProcessTree(proc, "SIGTERM"); - }, - }; + let initOwner = false; + let init = pendingCursorInit.get(args.poolKey); + if (!init) { + initOwner = true; + init = (async () => { + const base = await acquireAcpCliConnection(acpOptions); + + const terminalWorkLogBindings = new Map(); + const terminalOutputTimers = new Map>(); + + const pooled: CursorAcpPooled = { + connection: base.connection, + bridge: base.bridge, + terminals: base.terminals, + terminalWorkLogBindings, + terminalOutputTimers, + dispose: base.dispose, + }; - proc.stderr?.on("data", () => { - // stderr noise — optional log - }); + const generation = ++cursorGenCounter; + cursorPools.set(args.poolKey, { ref: 1, generation, pooled }); + return pooled; + })().finally(() => { + pendingCursorInit.delete(args.poolKey); + }); + pendingCursorInit.set(args.poolKey, init); + } - pool.set(args.poolKey, { ref: 1, pooled }); - return pooled; + const pooled = await init; + // Pending-init waiters might race with the inner ACP pool being torn down + // (process exit) between the awaited init and this branch. Re-check the + // outer entry is still alive before attaching the ref. + if (!initOwner) { + const liveEntry = cursorPools.get(args.poolKey); + if (!liveEntry || !hasActiveAcpCliPoolEntry(innerKey) || liveEntry.pooled !== pooled) { + // Stale waiter: retry the full acquire to spawn a fresh entry. + return acquireCursorAcpConnection(args); + } + await acquireAcpCliConnection(acpOptions); + liveEntry.ref += 1; + } + const entry = cursorPools.get(args.poolKey); + return { pooled, generation: entry?.generation ?? 0 }; } -export function releaseCursorAcpConnection(poolKey: string): void { - const entry = pool.get(poolKey); +export function releaseCursorAcpConnection(poolKey: string, generation?: number): void { + const entry = cursorPools.get(poolKey); if (!entry) return; + if (generation !== undefined && entry.generation !== generation) return; entry.ref -= 1; + if (entry.ref < 0) entry.ref = 0; + releaseAcpCliConnection(internalPoolKey(poolKey)); if (entry.ref <= 0) { - entry.pooled.dispose(); - pool.delete(poolKey); + clearCursorTerminalTimers(entry.pooled); + cursorPools.delete(poolKey); } } diff --git a/apps/desktop/src/main/services/chat/droidAcpPool.ts b/apps/desktop/src/main/services/chat/droidAcpPool.ts new file mode 100644 index 000000000..51310149c --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidAcpPool.ts @@ -0,0 +1,153 @@ +import type { ClientSideConnection, InitializeResponse } from "@agentclientprotocol/sdk"; +import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; +import { acquireAcpCliConnection, hasActiveAcpCliPoolEntry, releaseAcpCliConnection } from "./acpCliPool"; + +export type DroidAcpBridge = AcpHostBridge; + +export type DroidAcpLaunchSettings = { + /** Maps ADE unified permission / plan mode to Droid exec autonomy. */ + autonomy: "none" | "low" | "medium" | "high"; +}; + +export type DroidTerminalWorkLogBinding = { + itemId: string; + turnId: string; + command: string; + cwd: string; +}; + +export type DroidAcpPooled = { + connection: ClientSideConnection; + bridge: DroidAcpBridge; + terminals: Map; + terminalWorkLogBindings: Map; + terminalOutputTimers: Map>; + dispose: () => void; +}; + +let droidGenCounter = 0; +const droidPools = new Map(); +const pendingDroidInit = new Map>(); + +function internalPoolKey(poolKey: string): string { + return `droid:${poolKey}`; +} + +function clearDroidTerminalTimers(pooled: DroidAcpPooled): void { + for (const h of pooled.terminalOutputTimers.values()) { + clearTimeout(h); + } + pooled.terminalOutputTimers.clear(); +} + +export async function acquireDroidAcpConnection(args: { + poolKey: string; + droidPath: string; + workspacePath: string; + modelId: string; + launchSettings: DroidAcpLaunchSettings; + appVersion: string; +}): Promise<{ pooled: DroidAcpPooled; generation: number }> { + const spawnArgs = [ + "exec", + "--output-format", + "acp", + "--cwd", + args.workspacePath, + "-m", + args.modelId, + ]; + if (args.launchSettings.autonomy !== "none") { + spawnArgs.push("--auto", args.launchSettings.autonomy); + } + + const acpOptions = { + poolKey: internalPoolKey(args.poolKey), + logPrefix: "[DroidAcpPool]", + appVersion: args.appVersion, + spawn: { + command: args.droidPath, + args: spawnArgs, + cwd: args.workspacePath, + env: { ...process.env } as NodeJS.ProcessEnv, + }, + afterInitialize: async (_args: { connection: ClientSideConnection; initResult: InitializeResponse }) => { + // Droid auth is typically via FACTORY_API_KEY or Factory CLI config — no ACP authenticate step today. + }, + }; + + const innerKey = internalPoolKey(args.poolKey); + const staleOuter = droidPools.get(args.poolKey); + if (staleOuter && !hasActiveAcpCliPoolEntry(innerKey)) { + droidPools.delete(args.poolKey); + } + + const existing = droidPools.get(args.poolKey); + if (existing && hasActiveAcpCliPoolEntry(innerKey)) { + await acquireAcpCliConnection(acpOptions); + existing.ref += 1; + return { pooled: existing.pooled, generation: existing.generation }; + } + + // Existing entry is stale — clean its terminal timers and remove before creating a new one. + if (existing) { + clearDroidTerminalTimers(existing.pooled); + droidPools.delete(args.poolKey); + } + + let initOwner = false; + let init = pendingDroidInit.get(args.poolKey); + if (!init) { + initOwner = true; + init = (async () => { + const base = await acquireAcpCliConnection(acpOptions); + + const terminalWorkLogBindings = new Map(); + const terminalOutputTimers = new Map>(); + + const pooled: DroidAcpPooled = { + connection: base.connection, + bridge: base.bridge, + terminals: base.terminals, + terminalWorkLogBindings, + terminalOutputTimers, + dispose: base.dispose, + }; + + const generation = ++droidGenCounter; + droidPools.set(args.poolKey, { ref: 1, generation, pooled }); + return pooled; + })().finally(() => { + pendingDroidInit.delete(args.poolKey); + }); + pendingDroidInit.set(args.poolKey, init); + } + + const pooled = await init; + // Pending-init waiters might race with the inner ACP pool being torn down + // between the awaited init and this branch. Verify the entry still matches + // the generation we awaited before attaching a ref. + if (!initOwner) { + const liveEntry = droidPools.get(args.poolKey); + if (!liveEntry || !hasActiveAcpCliPoolEntry(innerKey) || liveEntry.pooled !== pooled) { + return acquireDroidAcpConnection(args); + } + await acquireAcpCliConnection(acpOptions); + liveEntry.ref += 1; + } + const entry = droidPools.get(args.poolKey); + return { pooled, generation: entry?.generation ?? 0 }; +} + +export function releaseDroidAcpConnection(poolKey: string, generation?: number): void { + const entry = droidPools.get(poolKey); + if (!entry) return; + if (generation !== undefined && entry.generation !== generation) return; + entry.ref -= 1; + if (entry.ref < 0) entry.ref = 0; + releaseAcpCliConnection(internalPoolKey(poolKey)); + if (entry.ref <= 0) { + clearDroidTerminalTimers(entry.pooled); + droidPools.delete(poolKey); + } +} diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts new file mode 100644 index 000000000..710f251f8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseDroidExecHelpModelIds, parseDroidExecHelpModels } from "./droidModelsDiscovery"; + +describe("parseDroidExecHelpModelIds", () => { + it("parses built-in and custom models from droid exec help", () => { + const raw = [ + "Usage: droid exec [options] [prompt]", + "", + "Available Models:", + " claude-opus-4-6 Claude Opus 4.6 (default)", + " gpt-5.3-codex GPT-5.3-Codex", + "", + "Custom Models:", + " custom:claude-opus-4-6-thinking-32000 Claude Opus 4.6 (High)", + " custom:gpt-5.4(xhigh) GPT-5.4 (XHigh)", + "", + "Model details:", + " - Claude Opus 4.6: supports reasoning: Yes", + ].join("\n"); + + expect(parseDroidExecHelpModelIds(raw)).toEqual([ + "claude-opus-4-6", + "gpt-5.3-codex", + "custom:claude-opus-4-6-thinking-32000", + "custom:gpt-5.4(xhigh)", + ]); + }); +}); + +describe("parseDroidExecHelpModels", () => { + it("keeps the CLI display name for custom models", () => { + const raw = [ + "Usage: droid exec [options] [prompt]", + "", + "Custom Models:", + " custom:claude-sonnet-4-6-thinking-32000 Claude Sonnet 4.6 (High)", + " custom:gpt-5.4(xhigh) GPT-5.4 (XHigh)", + "", + "Model details:", + ].join("\n"); + + expect(parseDroidExecHelpModels(raw)).toEqual([ + { + id: "custom:claude-sonnet-4-6-thinking-32000", + displayName: "Claude Sonnet 4.6 (High)", + }, + { + id: "custom:gpt-5.4(xhigh)", + displayName: "GPT-5.4 (XHigh)", + }, + ]); + }); +}); diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts new file mode 100644 index 000000000..471d62fa0 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -0,0 +1,243 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { + createDynamicDroidCliModelDescriptor, + sortDroidCliDescriptorsForPicker, + type ModelDescriptor, +} from "../../../shared/modelRegistry"; +import { spawnAsync } from "../shared/utils"; + +/** Default catalog when `droid` does not expose a machine-readable model list. */ +export const DROID_DEFAULT_MODEL_IDS: string[] = [ + "claude-opus-4-6", + "claude-opus-4-6-fast", + "claude-opus-4-5-20251101", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-fast", + "gpt-5.4", + "gpt-5.4-fast", + "gpt-5.4-mini", + "gemini-3-pro-preview", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "glm-4.7", + "glm-5", + "glm-5.1", + "kimi-k2.5", + "minimax-m2.5", +]; + +export type DroidExecHelpModelRow = { + id: string; + displayName: string; + /** True when sourced from ~/.factory/config.json (vibeproxy / custom proxy). */ + customProxy?: boolean; +}; + +let cached: { at: number; models: DroidExecHelpModelRow[] } | null = null; +let inflight: Promise | null = null; +const TTL_MS = 120_000; + +export function parseDroidExecHelpModels(stdout: string): DroidExecHelpModelRow[] { + const lines = stdout.split(/\r?\n/); + const rows: DroidExecHelpModelRow[] = []; + const seen = new Set(); + let inModelSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (/^(Available Models|Custom Models):$/i.test(trimmed)) { + inModelSection = true; + continue; + } + if (!inModelSection) continue; + if (!trimmed.length) continue; + if ( + /^(Model details|Authentication|Examples|Autonomy Levels|Mission Mode|Session Flags|Tool Controls):$/i.test(trimmed) + || /^[-A-Z][\w -]+:$/i.test(trimmed) + ) { + break; + } + const match = line.match(/^\s{2,}([a-z0-9][\w.:()+-]*)\s{2,}(.+?)\s*$/i); + if (!match) continue; + const id = match[1].trim(); + const displayName = match[2].trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + rows.push({ id, displayName }); + } + + return rows; +} + +export function parseDroidExecHelpModelIds(stdout: string): string[] { + return parseDroidExecHelpModels(stdout).map((row) => row.id); +} + +/** + * Best-effort: ask the Droid CLI for models (flags vary by version). + */ +export async function listDroidModelIdsFromCli(droidPath: string): Promise { + return (await listDroidModelsFromCli(droidPath)).map((row) => row.id); +} + +async function listDroidModelsFromCli(droidPath: string): Promise { + const now = Date.now(); + if (cached && now - cached.at < TTL_MS) { + return cached.models; + } + if (inflight) { + return inflight; + } + inflight = listDroidModelsFromCliInner(droidPath).finally(() => { + inflight = null; + }); + return inflight; +} + +async function listDroidModelsFromCliInner(droidPath: string): Promise { + const now = Date.now(); + try { + const helpResult = await spawnAsync(droidPath, ["exec", "--help"], { timeout: 8_000, maxOutputBytes: 64_000 }); + if (helpResult.status === 0) { + const rows = parseDroidExecHelpModels(helpResult.stdout ?? ""); + if (rows.length) { + cached = { at: now, models: rows }; + return rows; + } + } + } catch { + // Fall through to legacy probes below. + } + + const probes: string[][] = [ + ["models", "--json"], + ["model", "list", "--json"], + ["models"], + ]; + + for (const args of probes) { + try { + const result = await spawnAsync(droidPath, args, { timeout: 2_500 }); + if (result.status !== 0) continue; + const stdout = (result.stdout ?? "").trim(); + if (!stdout) continue; + + try { + const parsed = JSON.parse(stdout) as unknown; + if (Array.isArray(parsed)) { + const rows: DroidExecHelpModelRow[] = []; + for (const row of parsed) { + if (typeof row === "string" && row.trim()) { + const id = row.trim(); + rows.push({ id, displayName: id }); + continue; + } + if (row && typeof row === "object") { + const r = row as Record; + const id = typeof r.id === "string" ? r.id.trim() : typeof r.model === "string" ? r.model.trim() : ""; + const displayName = typeof r.name === "string" && r.name.trim().length ? r.name.trim() : id; + if (id) rows.push({ id, displayName }); + } + } + if (rows.length) { + cached = { at: now, models: rows }; + return rows; + } + } + } catch { + // not JSON + } + + const lines = stdout + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !/^usage:/i.test(l) && !/^options:/i.test(l)); + const bare: DroidExecHelpModelRow[] = []; + const seen = new Set(); + for (const line of lines) { + const m = line.match(/^([a-z0-9][\w.-]*)$/i); + if (m && !seen.has(m[1])) { + seen.add(m[1]); + bare.push({ id: m[1], displayName: m[1] }); + } + } + if (bare.length >= 3) { + cached = { at: now, models: bare }; + return bare; + } + } catch { + // try next probe + } + } + + cached = { at: now, models: [] }; + return []; +} + +export function clearDroidCliModelsCache(): void { + cached = null; + inflight = null; +} + +/** + * Read custom models from `~/.factory/config.json`. + * + * Vibeproxy (and other tools) inject custom models into the Droid CLI by + * writing entries to this file. Each entry carries the raw model ID, a + * human-readable display name, and a `custom:` prefixed ID that the CLI + * uses internally. + */ +async function readFactoryConfigCustomModels(): Promise { + try { + const configPath = join(homedir(), ".factory", "config.json"); + const raw = await readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + const customModels = parsed.custom_models; + if (!Array.isArray(customModels)) return []; + const rows: DroidExecHelpModelRow[] = []; + for (const entry of customModels) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + const model = typeof e.model === "string" ? e.model.trim() : ""; + const displayName = typeof e.model_display_name === "string" ? e.model_display_name.trim() : ""; + if (!model) continue; + // The droid CLI wraps custom models with a "custom:" prefix. + const id = `custom:${model}`; + rows.push({ id, displayName: displayName || id, customProxy: true }); + } + return rows; + } catch { + return []; + } +} + +export async function discoverDroidCliModelDescriptors(droidPath: string): Promise { + const fromCli = await listDroidModelsFromCli(droidPath); + const baseRows: DroidExecHelpModelRow[] = fromCli.length + ? fromCli + : DROID_DEFAULT_MODEL_IDS.map((id) => ({ id, displayName: id })); + + // Merge custom models from ~/.factory/config.json so vibeproxy-injected + // models appear even when the CLI help output doesn't list them. + const customRows = await readFactoryConfigCustomModels(); + + const seen = new Set(); + const descriptors: ModelDescriptor[] = []; + for (const row of [...baseRows, ...customRows]) { + const trimmed = String(row.id ?? "").trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + descriptors.push(createDynamicDroidCliModelDescriptor(trimmed, row.displayName, { customProxy: row.customProxy })); + } + return sortDroidCliDescriptorsForPicker(descriptors); +} diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 823782b95..3a9154bc2 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1341,7 +1341,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const providersRaw = isRecord(permissionsRaw.providers) ? permissionsRaw.providers : null; if (providersRaw) { const providers: NonNullable["providers"]> = {}; - const providerMode = (key: "claude" | "codex" | "cursor" | "opencode") => { + const providerMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode") => { const mode = asString(providersRaw[key])?.trim(); if (mode === "default" || mode === "plan" || mode === "edit" || mode === "full-auto" || mode === "config-toml") { providers[key] = mode; @@ -1350,6 +1350,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { providerMode("claude"); providerMode("codex"); providerMode("cursor"); + providerMode("droid"); providerMode("opencode"); const codexSandbox = asString(providersRaw.codexSandbox)?.trim(); if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b99343b20..b1adc7799 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -888,11 +888,13 @@ function getUnavailableAiStatus(): AiSettingsStatus { claude: false, codex: false, cursor: false, + droid: false, }, models: { claude: [], codex: [], cursor: [], + droid: [], }, detectedAuth: [], providerConnections: { @@ -929,6 +931,17 @@ function getUnavailableAiStatus(): AiSettingsStatus { lastCheckedAt: new Date(0).toISOString(), sources: [], }, + droid: { + provider: "droid", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, }, features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ feature, @@ -1437,7 +1450,7 @@ function mapPrAiPermissionMode(mode: AiPermissionMode): AgentChatPermissionMode function mapPrAiPermissionModeToNativeFields( mode: AiPermissionMode, provider: string, -): Partial> { +): Partial> { const legacy = mapPrAiPermissionMode(mode); if (provider === "claude") { const map: Record = { @@ -1453,6 +1466,11 @@ function mapPrAiPermissionModeToNativeFields( if (legacy === "edit") return { codexApprovalPolicy: "on-request", codexSandbox: "workspace-write" }; return { codexApprovalPolicy: "on-request", codexSandbox: "read-only" }; } + if (provider === "droid") { + if (legacy === "full-auto") return { droidPermissionMode: "auto-high" }; + if (legacy === "edit") return { droidPermissionMode: "auto-low" }; + return { droidPermissionMode: "read-only" }; + } const umap: Record = { "full-auto": "full-auto", "edit": "edit", @@ -1462,7 +1480,7 @@ function mapPrAiPermissionModeToNativeFields( } function deriveAiPermissionModeFromSummary( - summary: Pick | null | undefined, + summary: Pick | null | undefined, ): AiPermissionMode | null { if (!summary) return null; if (summary.provider === "claude") { @@ -1478,6 +1496,12 @@ function deriveAiPermissionModeFromSummary( if (summary.codexSandbox === "read-only") return "read_only"; return null; } + if (summary.provider === "droid") { + if (summary.droidPermissionMode === "auto-high") return "full_edit"; + if (summary.droidPermissionMode === "auto-low" || summary.droidPermissionMode === "auto-medium") return "guarded_edit"; + if (summary.droidPermissionMode === "read-only") return "read_only"; + return null; + } if (summary.opencodePermissionMode === "full-auto") return "full_edit"; if (summary.opencodePermissionMode === "edit") return "guarded_edit"; if (summary.opencodePermissionMode === "plan") return "read_only"; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index c550559dd..84920933b 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -691,7 +691,7 @@ function createMockAiIntegrationService(overrides: { executeTask?: (...args: any[]) => Promise; } = {}) { return { - getAvailability: () => ({ claude: true, codex: true, cursor: false }), + getAvailability: () => ({ claude: true, codex: true, cursor: false, droid: false }), getMode: () => "subscription", getFeatureFlag: () => true, getDailyBudgetLimit: () => null, diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index d3d6aa904..1c258059c 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -5468,7 +5468,7 @@ Check all worker statuses and continue managing the mission from here. Read work "- add_step: Add a new corrective step (set newStep with stepKey, title, instructions, dependencyStepKeys, executorKind)", "- parallelize_steps: Remove a dependency from a step to unblock it (set targetStepKey + removeDependencyKey)", "- consolidate_steps: Merge two pending/blocked steps into one (set targetStepKey=keep, removeStepKey=discard, mergedInstructions)", - "- reassign_executor: Change executor kind for a pending/blocked step (set targetStepKey + newExecutorKind: 'claude'|'codex'|'cursor'|'opencode'|'manual')", + "- reassign_executor: Change executor kind for a pending/blocked step (set targetStepKey + newExecutorKind: 'claude'|'codex'|'cursor'|'droid'|'opencode'|'manual')", "- steer_worker: Send a message to a running worker with learnings from this completed step (set targetStepKey + steeringMessage)", "- no_change: Nothing to adjust", "", @@ -5494,7 +5494,7 @@ Check all worker statuses and continue managing the mission from here. Read work removeStepKey: { type: "string" }, mergedInstructions: { type: "string" }, // For reassign_executor - newExecutorKind: { type: "string", enum: ["claude", "codex", "cursor", "opencode", "manual"] }, + newExecutorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] }, // For steer_worker: message to send to running worker steeringMessage: { type: "string" }, // For add_step @@ -5505,7 +5505,7 @@ Check all worker statuses and continue managing the mission from here. Read work title: { type: "string" }, instructions: { type: "string" }, dependencyStepKeys: { type: "array", items: { type: "string" } }, - executorKind: { type: "string", enum: ["claude", "codex", "cursor", "opencode", "manual"] } + executorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] } } } }, @@ -5563,7 +5563,7 @@ Check all worker statuses and continue managing the mission from here. Read work const title = typeof newStep.title === "string" ? newStep.title : "AI-suggested corrective step"; const depKeys = Array.isArray(newStep.dependencyStepKeys) ? newStep.dependencyStepKeys.map(String) : []; const executorKind = typeof newStep.executorKind === "string" && - ["claude", "codex", "cursor", "opencode", "manual"].includes(newStep.executorKind) + ["claude", "codex", "cursor", "droid", "opencode", "manual"].includes(newStep.executorKind) ? (newStep.executorKind as OrchestratorExecutorKind) : ("opencode" as OrchestratorExecutorKind); try { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts index 0e8ca6b73..7897f6b8d 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts @@ -286,6 +286,7 @@ export function normalizeExecutorKind(value: string): OrchestratorExecutorKind { value === "claude" || value === "codex" || value === "cursor" + || value === "droid" || value === "opencode" || value === "shell" || value === "manual" diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 5917636e7..73f8833ed 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -211,7 +211,7 @@ function projectPermissionConfigFromRecord(permissions: Record const providers = asRecord(permissions.providers); if (providers) { const providerPerms: NonNullable = {}; - const maybeMode = (key: "claude" | "codex" | "cursor" | "opencode") => { + const maybeMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode") => { const value = typeof providers[key] === "string" ? providers[key].trim() : ""; if (VALID_PROJECT_PROVIDER_PERMISSION_MODES.has(value as AgentChatPermissionMode)) { providerPerms[key] = value as AgentChatPermissionMode; @@ -220,6 +220,7 @@ function projectPermissionConfigFromRecord(permissions: Record maybeMode("claude"); maybeMode("codex"); maybeMode("cursor"); + maybeMode("droid"); maybeMode("opencode"); const codexSandbox = typeof providers.codexSandbox === "string" ? providers.codexSandbox.trim() : ""; if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { @@ -900,7 +901,7 @@ export function createOrchestratorService({ workspaceRoot: projectRoot, agentChatService, }); - for (const kind of ["claude", "codex", "cursor", "opencode"] as const) { + for (const kind of ["claude", "codex", "cursor", "droid", "opencode"] as const) { adapters.set(kind, sharedAdapter); } const autopilotRunLocks = new Set(); @@ -3822,6 +3823,9 @@ export function createOrchestratorService({ }; const defaultAdapterFor = (kind: OrchestratorExecutorKind): OrchestratorExecutorAdapter | null => { + // The generic adapter below only knows how to launch claude/codex CLIs. + // Droid uses its own adapter registered elsewhere — falling back here would + // silently spawn a Claude worker for a Droid step. if (!["claude", "codex", "cursor", "opencode"].includes(kind)) return null; return { kind, @@ -5132,7 +5136,7 @@ export function createOrchestratorService({ const inferredRequiresPlanApproval = inferredPattern === "plan_then_implement" || stepType === "analysis"; const normalizedExecutor = normalizeExecutorKind(String(explicitExecutor ?? "manual")); - const isAiTeammate = ["claude", "codex", "cursor", "opencode"].includes(normalizedExecutor); + const isAiTeammate = ["claude", "codex", "cursor", "droid", "opencode"].includes(normalizedExecutor); const requiresPlanApproval = explicitRequiresPlanApproval != null ? explicitRequiresPlanApproval : runtimeConfig.teammatePlanMode === "required" && isAiTeammate @@ -6793,7 +6797,7 @@ export function createOrchestratorService({ } let providerStepModelId: string | null = null; - if (["claude", "codex", "cursor", "opencode"].includes(executorKind)) { + if (["claude", "codex", "cursor", "droid", "opencode"].includes(executorKind)) { const stepModelRaw = typeof step.metadata?.modelId === "string" ? step.metadata.modelId.trim() : ""; const phaseModel = asRecord(step.metadata?.phaseModel); const phaseModelIdRaw = typeof phaseModel?.modelId === "string" ? phaseModel.modelId.trim() : ""; diff --git a/apps/desktop/src/main/services/orchestrator/permissionMapping.ts b/apps/desktop/src/main/services/orchestrator/permissionMapping.ts index 6e830f98f..46b24a8f8 100644 --- a/apps/desktop/src/main/services/orchestrator/permissionMapping.ts +++ b/apps/desktop/src/main/services/orchestrator/permissionMapping.ts @@ -103,6 +103,7 @@ export function normalizeMissionPermissions(config: MissionPermissionConfig | un const result: MissionProviderPermissions = { claude: "full-auto", codex: "default", + droid: "full-auto", opencode: "full-auto", codexSandbox: "workspace-write", }; @@ -113,6 +114,7 @@ export function normalizeMissionPermissions(config: MissionPermissionConfig | un const asProvider = oldCliModeToProvider(cliMode); result.claude = asProvider; result.codex = asProvider; + result.droid = asProvider; if (config.cli.sandboxPermissions === "read-only" || config.cli.sandboxPermissions === "workspace-write" || config.cli.sandboxPermissions === "danger-full-access") { result.codexSandbox = config.cli.sandboxPermissions; } @@ -134,6 +136,7 @@ export function normalizeMissionPermissions(config: MissionPermissionConfig | un if (p.claude && VALID_PROVIDER_MODES.has(p.claude)) result.claude = p.claude; if (p.codex && VALID_PROVIDER_MODES.has(p.codex)) result.codex = p.codex; if (p.cursor && VALID_PROVIDER_MODES.has(p.cursor)) result.cursor = p.cursor; + if (p.droid && VALID_PROVIDER_MODES.has(p.droid)) result.droid = p.droid; if (p.opencode && VALID_PROVIDER_MODES.has(p.opencode)) result.opencode = p.opencode; if (p.codexSandbox === "read-only" || p.codexSandbox === "workspace-write" || p.codexSandbox === "danger-full-access") { result.codexSandbox = p.codexSandbox; @@ -187,6 +190,7 @@ export function mergeMissionPermissionConfig( const providerMode = oldCliModeToProvider(overrideCliMode); providerOverrides.claude = providerMode; providerOverrides.codex = providerMode; + providerOverrides.droid = providerMode; } const overrideInProcessMode = VALID_IN_PROCESS_MODES.has(override.inProcess?.mode ?? "") ? override.inProcess?.mode : undefined; if (overrideInProcessMode) { diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts index 2e92f65dd..f9c1efa45 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts @@ -314,7 +314,7 @@ function cleanupStaleWorkerRuntimeFiles(projectRoot: string): void { const VALID_PERMISSION_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); function resolveManagedPermissionMode(args: { - provider: "claude" | "codex" | "opencode" | "cursor"; + provider: "claude" | "codex" | "opencode" | "cursor" | "droid"; descriptor?: ModelDescriptor; permissionConfig: LegacyPermissionConfig | undefined; readOnlyExecution: boolean; @@ -322,8 +322,12 @@ function resolveManagedPermissionMode(args: { if (args.readOnlyExecution) return "plan"; const providers = args.permissionConfig?._providers; const candidate = - args.provider === "cursor" - ? ((providers?.cursor ?? providers?.opencode) as string | undefined) + args.provider === "cursor" || args.provider === "droid" + ? (( + args.provider === "droid" + ? (providers?.droid ?? providers?.cursor ?? providers?.opencode) + : (providers?.cursor ?? providers?.opencode) + ) as string | undefined) : (providers?.[args.provider] as string | undefined); const normalizedCandidate = typeof candidate === "string" && VALID_PERMISSION_MODES.has(candidate) ? candidate as AgentChatPermissionMode @@ -336,9 +340,9 @@ function resolveManagedPermissionMode(args: { } function mapPermissionModeToNativeFields( - provider: "claude" | "codex" | "opencode" | "cursor", + provider: "claude" | "codex" | "opencode" | "cursor" | "droid", mode: AgentChatPermissionMode | undefined, -): Partial> { +): Partial> { if (!mode) return {}; // "config-toml" means the worker should inherit permissions from the // provider/repo config (e.g. a .toml settings file). Don't rewrite it @@ -360,6 +364,12 @@ function mapPermissionModeToNativeFields( if (mode === "default") return { codexApprovalPolicy: "on-request", codexSandbox: "workspace-write" }; return { codexApprovalPolicy: "on-request", codexSandbox: "read-only" }; } + if (provider === "droid") { + if (mode === "full-auto") return { droidPermissionMode: "auto-high" }; + if (mode === "default") return { droidPermissionMode: "auto-medium" }; + if (mode === "edit") return { droidPermissionMode: "auto-low" }; + return { droidPermissionMode: "read-only" }; + } const umap: Record = { "full-auto": "full-auto", "edit": "edit", @@ -369,10 +379,10 @@ function mapPermissionModeToNativeFields( } function resolveManagedExecutionMode(args: { - provider: "claude" | "codex" | "opencode" | "cursor"; + provider: "claude" | "codex" | "opencode" | "cursor" | "droid"; teamRuntime?: TeamRuntimeConfig; }): AgentChatExecutionMode { - if (args.provider === "cursor") { + if (args.provider === "cursor" || args.provider === "droid") { return "focused"; } if (args.provider === "claude") { diff --git a/apps/desktop/src/main/services/orchestrator/recoveryService.ts b/apps/desktop/src/main/services/orchestrator/recoveryService.ts index 6766a9a0a..255b5bc8a 100644 --- a/apps/desktop/src/main/services/orchestrator/recoveryService.ts +++ b/apps/desktop/src/main/services/orchestrator/recoveryService.ts @@ -304,6 +304,7 @@ export function listRunningAttemptsForSession( executorKindRaw === "claude" || executorKindRaw === "codex" || executorKindRaw === "cursor" + || executorKindRaw === "droid" || executorKindRaw === "opencode" || executorKindRaw === "shell" || executorKindRaw === "manual" diff --git a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts b/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts index 35278d469..f017ba90a 100644 --- a/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts +++ b/apps/desktop/src/main/services/orchestrator/workerDeliveryService.ts @@ -446,6 +446,7 @@ export function resolveWorkerDeliveryContextCtx( executorKindRaw === "claude" || executorKindRaw === "codex" || executorKindRaw === "cursor" + || executorKindRaw === "droid" || executorKindRaw === "opencode" || executorKindRaw === "shell" || executorKindRaw === "manual" diff --git a/apps/desktop/src/main/services/orchestrator/workerTracking.ts b/apps/desktop/src/main/services/orchestrator/workerTracking.ts index 3970bbe8b..19f128cf1 100644 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ b/apps/desktop/src/main/services/orchestrator/workerTracking.ts @@ -1627,7 +1627,7 @@ export function updateWorkerStateFromEventCtx( properties: { title: { type: "string" }, instructions: { type: "string" }, - executorKind: { type: "string", enum: ["claude", "codex", "cursor", "opencode", "manual"] } + executorKind: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "manual"] } } }, downstreamGuidance: { type: "string" } @@ -1702,7 +1702,7 @@ export function updateWorkerStateFromEventCtx( dependencyStepKeys: [], executorKind: ( typeof ws.executorKind === "string" - && ["claude", "codex", "cursor", "opencode", "manual"].includes(ws.executorKind) + && ["claude", "codex", "cursor", "droid", "opencode", "manual"].includes(ws.executorKind) ? ws.executorKind : "opencode" ) as OrchestratorExecutorKind, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 3bff0d3d8..e920bd881 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1008,6 +1008,20 @@ describe("ptyService", () => { ); }); + it("preserves droid-chat as a known toolType", async () => { + const { service, sessionService } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Droid chat", + cols: 80, + rows: 24, + toolType: "droid-chat", + }); + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ toolType: "droid-chat" }), + ); + }); + it("normalizes unknown toolType to 'other'", async () => { const { service, sessionService } = createHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a7a9bf3f2..e25b8a73d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -199,6 +199,7 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { "claude-chat", "opencode-chat", "cursor", + "droid-chat", "aider", "continue", "other" diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index f06a36315..530898055 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -327,6 +327,69 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("preserves droid chat sessions as droid-chat instead of coercing them to other", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-3", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Droid chat", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: path.join(projectRoot, "session-3.chat.jsonl"), + toolType: "droid-chat", + resumeCommand: "chat:droid:session-3", + }); + + const session = service.get("session-3"); + expect(session?.toolType).toBe("droid-chat"); + expect(session?.resumeCommand).toBe("chat:droid:session-3"); + + const listed = service.list({ laneId: "lane-1" }); + expect(listed).toHaveLength(1); + expect(listed[0]?.toolType).toBe("droid-chat"); + expect(listed[0]?.resumeCommand).toBe("chat:droid:session-3"); + + activeDisposers.push(async () => db.close()); + }); + + it("repairs legacy droid chat rows from their resume command", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + db.run( + ` + insert into terminal_sessions( + id, lane_id, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at, + exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, + last_output_at, summary, resume_command + ) values (?, ?, null, 1, null, 'other', 0, ?, ?, null, null, ?, null, null, 'running', null, null, null, ?) + `, + [ + "session-legacy", + "lane-1", + "Droid chat", + "2026-03-17T00:10:00.000Z", + path.join(projectRoot, "session-legacy.chat.jsonl"), + "chat:droid:session-legacy", + ], + ); + + const session = service.get("session-legacy"); + expect(session?.toolType).toBe("droid-chat"); + expect(session?.resumeCommand).toBe("chat:droid:session-legacy"); + + activeDisposers.push(async () => db.close()); + }); + it("reconciles stale running chat sessions when no exclusions are provided", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index e72d249bc..88a59f781 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -187,6 +187,7 @@ export function createSessionService({ db }: { db: AdeDb }) { "claude-chat", "opencode-chat", "cursor", + "droid-chat", "aider", "continue", "other" @@ -194,8 +195,26 @@ export function createSessionService({ db }: { db: AdeDb }) { return (allowed as string[]).includes(value) ? (value as TerminalToolType) : "other"; }; + const inferToolTypeFromResumeCommand = ( + toolType: TerminalToolType | null, + resumeCommand: string | null, + ): TerminalToolType | null => { + if (toolType && toolType !== "other") return toolType; + const normalized = String(resumeCommand ?? "").trim().toLowerCase(); + if (!normalized) return toolType; + if (normalized.startsWith("chat:droid:")) return "droid-chat"; + if (normalized.startsWith("chat:cursor:")) return "cursor"; + if (normalized.startsWith("chat:unified:")) return "opencode-chat"; + if (normalized.startsWith("chat:claude:")) return "claude-chat"; + if (normalized === "chat:codex" || normalized.startsWith("chat:codex:")) return "codex-chat"; + return toolType; + }; + const mapRow = (row: SessionRow) => { - const toolType = normalizeToolType(row.toolType); + const toolType = inferToolTypeFromResumeCommand( + normalizeToolType(row.toolType), + row.resumeCommand ?? null, + ); let resumeMetadata: TerminalResumeMetadata | null = null; if (row.resumeMetadataJson) { try { diff --git a/apps/desktop/src/main/services/shared/utils.test.ts b/apps/desktop/src/main/services/shared/utils.test.ts index da086a5b6..36024a449 100644 --- a/apps/desktop/src/main/services/shared/utils.test.ts +++ b/apps/desktop/src/main/services/shared/utils.test.ts @@ -14,6 +14,7 @@ import { uniqueStrings, asArray, firstLine, + spawnAsync, parseDiffNameOnly, safeJsonParse, isWithinDir, @@ -177,6 +178,29 @@ describe("firstLine", () => { }); }); +describe("spawnAsync", () => { + it( + "kills a child process tree when the child ignores SIGTERM", + async () => { + const grandchildScript = "setInterval(() => {}, 1000);"; + const parentScript = ` + const { spawn } = require("node:child_process"); + spawn(process.execPath, ["-e", ${JSON.stringify(grandchildScript)}], { stdio: "inherit" }); + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1000); + `.trim(); + + const start = Date.now(); + const result = await spawnAsync(process.execPath, ["-e", parentScript], { timeout: 100 }); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(5_000); + expect(result.status).toBeNull(); + }, + 10_000, + ); +}); + describe("parseDiffNameOnly", () => { it("parses newline-separated file names", () => { expect(parseDiffNameOnly("a.ts\nb.ts\nc.ts\n")).toEqual(["a.ts", "b.ts", "c.ts"]); diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 5636a0091..5abf88be5 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -5,11 +5,11 @@ * Import from here instead of re-declaring locally. */ -import { spawn } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { resolveCliSpawnInvocation, terminateProcessTree } from "./processExecution"; +import { resolveCliSpawnInvocation } from "./processExecution"; // ── Type guards ───────────────────────────────────────────────────── @@ -76,6 +76,101 @@ export function firstLine(text: string): string { return text.split(/\r?\n/)[0]?.trim() ?? ""; } +type KillableChildProcess = Pick; + +function isValidPid(pid: number | null | undefined): pid is number { + return typeof pid === "number" && Number.isInteger(pid) && pid > 0; +} + +export function destroyChildProcessStreams(child: Pick | null | undefined): void { + if (!child) return; + try { + child.stdin?.destroy(); + } catch { + // ignore + } + try { + child.stdout?.destroy(); + } catch { + // ignore + } + try { + child.stderr?.destroy(); + } catch { + // ignore + } +} + +/** + * Signal a process tree instead of just the direct child. + * + * On Unix-like systems, the child is spawned into its own process group and we + * signal the entire group. On Windows, `taskkill /T` is the closest equivalent. + */ +export function signalChildProcessTree(child: KillableChildProcess, signal: NodeJS.Signals): boolean { + const pid = child.pid ?? null; + + if (process.platform === "win32") { + if (isValidPid(pid)) { + const taskkillArgs = ["/PID", String(pid), "/T"]; + if (signal === "SIGKILL") { + taskkillArgs.push("/F"); + } + try { + const result = spawnSync("taskkill", taskkillArgs, { stdio: "ignore" }); + if (result.status === 0) return true; + } catch { + // fall through to direct child signaling + } + } + try { + return child.kill(signal); + } catch { + return false; + } + } + + if (isValidPid(pid)) { + try { + process.kill(-pid, signal); + return true; + } catch { + // fall through to direct child signaling + } + } + + try { + if (child.kill(signal)) return true; + } catch { + // fall through to PID signaling below + } + + if (isValidPid(pid)) { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } + } + + return false; +} + +export function terminateChildProcessTree( + child: KillableChildProcess, + previousKillTimer: NodeJS.Timeout | null = null, + killAfterMs = 1_500, +): NodeJS.Timeout { + if (previousKillTimer) { + clearTimeout(previousKillTimer); + } + signalChildProcessTree(child, "SIGTERM"); + return setTimeout(() => { + signalChildProcessTree(child, "SIGKILL"); + }, killAfterMs); +} + /** Spawn a child process and collect stdout/stderr with a timeout. */ export function spawnAsync( command: string, @@ -87,28 +182,41 @@ export function spawnAsync( const invocation = resolveCliSpawnInvocation(command, args); const child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); - const timeout = setTimeout(() => { - terminateProcessTree(child); - }, opts?.timeout ?? 5_000); let stdout = ""; let stderr = ""; const limit = opts?.maxOutputBytes ?? 10_000; + const timeoutMs = opts?.timeout ?? 5_000; + let timeoutHandle: NodeJS.Timeout | null = null; + let hardResolveHandle: NodeJS.Timeout | null = null; + let killEscalationHandle: NodeJS.Timeout | null = null; + let settled = false; + const settle = (status: number | null): void => { + if (settled) return; + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + if (hardResolveHandle) clearTimeout(hardResolveHandle); + if (killEscalationHandle) clearTimeout(killEscalationHandle); + destroyChildProcessStreams(child); + resolve({ status, stdout, stderr }); + }; child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8").slice(0, Math.max(0, limit - stdout.length)); }); child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8").slice(0, Math.max(0, limit - stderr.length)); }); - child.on("error", () => { - clearTimeout(timeout); - resolve({ status: null, stdout, stderr }); - }); - child.on("close", (code) => { - clearTimeout(timeout); - resolve({ status: code, stdout, stderr }); - }); + child.once("error", () => settle(null)); + child.once("close", (code) => settle(code)); + timeoutHandle = setTimeout(() => { + if (settled) return; + // Retain the SIGKILL escalation timer so we can clear it if the + // SIGTERM induces a clean exit before the kill window elapses. + killEscalationHandle = terminateChildProcessTree(child, null, 1_500); + hardResolveHandle = setTimeout(() => settle(null), 5_000); + }, timeoutMs); } catch { resolve({ status: null, stdout: "", stderr: "" }); } diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index b82493bef..e9ff15530 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -282,6 +282,7 @@ async function summarizeChatSessionForRemote( ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), + ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), ...(session.cursorConfigValues ? { cursorConfigValues: session.cursorConfigValues } : {}), @@ -548,6 +549,7 @@ function parseAgentChatCreateArgs(value: Record): AgentChatCrea if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); if ("requestedCwd" in value) parsed.requestedCwd = value.requestedCwd == null ? undefined : requireString(value.requestedCwd, "chat.create requires a non-empty requestedCwd when provided."); @@ -672,6 +674,7 @@ function parseAgentChatUpdateSessionArgs(value: Record): AgentC if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; if ("cursorConfigValues" in value) { parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); diff --git a/apps/desktop/src/renderer/assets/provider-logos/README.md b/apps/desktop/src/renderer/assets/provider-logos/README.md index 446f138e0..4af058c30 100644 --- a/apps/desktop/src/renderer/assets/provider-logos/README.md +++ b/apps/desktop/src/renderer/assets/provider-logos/README.md @@ -5,3 +5,7 @@ Primary marks use **`@lobehub/icons`** (`ProviderLogos.tsx`, `ToolLogos.tsx`). R ## Cursor Cursor CLI / subscription rows use **`Cursor.Avatar`** from `@lobehub/icons`. This folder may keep `cursor.svg` for one-off experiments or future overrides; it is not imported by the app today. + +## Droid + +`droid.svg` is the local Factory Droid mark used for the Droid provider/runtime. Model rows still use the underlying model-family marks (Claude, OpenAI, Gemini, etc.) when the Droid model id reveals one. diff --git a/apps/desktop/src/renderer/assets/provider-logos/droid.svg b/apps/desktop/src/renderer/assets/provider-logos/droid.svg new file mode 100644 index 000000000..16b99b68b --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/droid.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 912ea51a7..c4c565a23 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -368,20 +368,23 @@ function isMockChatToolType(toolType: unknown): boolean { || normalized === "claude-chat" || normalized === "opencode-chat" || normalized === "cursor" + || normalized === "droid" + || normalized.startsWith("droid") || normalized.endsWith("-chat") ), ); } -function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "opencode" { +function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "droid" | "opencode" { const metadataProvider = String(session?.resumeMetadata?.provider ?? "").trim().toLowerCase(); - if (metadataProvider === "claude" || metadataProvider === "codex" || metadataProvider === "cursor" || metadataProvider === "opencode") { + if (metadataProvider === "claude" || metadataProvider === "codex" || metadataProvider === "cursor" || metadataProvider === "droid" || metadataProvider === "opencode") { return metadataProvider; } const toolType = String(session?.toolType ?? "").trim().toLowerCase(); if (toolType.startsWith("claude")) return "claude"; if (toolType.startsWith("codex")) return "codex"; if (toolType === "cursor" || toolType.startsWith("cursor")) return "cursor"; + if (toolType === "droid-chat" || toolType.startsWith("droid")) return "droid"; return "opencode"; } @@ -398,17 +401,19 @@ function latestMockDoneEvent(events: any[]): any | null { return null; } -function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | "opencode"): string { +function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { if (provider === "claude") return "sonnet"; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "auto"; + if (provider === "droid") return "claude-opus-4-6"; return "opencode/mock"; } -function fallbackMockModelIdForProvider(provider: "claude" | "codex" | "cursor" | "opencode"): string { +function fallbackMockModelIdForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { if (provider === "claude") return DEFAULT_BROWSER_MOCK_CLAUDE_MODEL; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "cursor/auto"; + if (provider === "droid") return "droid/claude-opus-4-6"; return "opencode/mock"; } @@ -450,6 +455,7 @@ function mockAgentChatSummaryFromSession(session: any): any | null { codexSandbox: session.resumeMetadata?.codexSandbox ?? undefined, codexConfigSource: session.resumeMetadata?.codexConfigSource ?? undefined, opencodePermissionMode: session.resumeMetadata?.opencodePermissionMode ?? undefined, + droidPermissionMode: session.resumeMetadata?.droidPermissionMode ?? undefined, cursorModeSnapshot: session.resumeMetadata?.cursorModeSnapshot ?? undefined, cursorModeId: session.resumeMetadata?.cursorModeId ?? null, cursorConfigValues: session.resumeMetadata?.cursorConfigValues ?? null, @@ -2008,7 +2014,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }; const BROWSER_MOCK_PROVIDER_CONNECTION = ( - provider: "claude" | "codex" | "cursor", + provider: "claude" | "codex" | "cursor" | "droid", ) => ({ provider, authAvailable: false, @@ -2023,13 +2029,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { const BROWSER_MOCK_AI_STATUS: any = { mode: "guest", - availableProviders: { claude: false, codex: false, cursor: false }, - models: { claude: [], codex: [], cursor: [] }, + availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, features: [], providerConnections: { claude: BROWSER_MOCK_PROVIDER_CONNECTION("claude"), codex: BROWSER_MOCK_PROVIDER_CONNECTION("codex"), cursor: BROWSER_MOCK_PROVIDER_CONNECTION("cursor"), + droid: BROWSER_MOCK_PROVIDER_CONNECTION("droid"), }, }; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 683fd552e..a342b6316 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -245,6 +245,30 @@ describe("AgentChatComposer", () => { }); }); + it("renders Droid autonomy controls without OpenCode permission labels", () => { + const onDroidPermissionModeChange = vi.fn(); + renderComposer({ + sessionProvider: "droid", + modelId: "droid/gpt-5.2", + availableModelIds: ["droid/gpt-5.2"], + droidPermissionMode: "auto-low", + onDroidPermissionModeChange, + }); + + const autonomySelect = screen.getByRole("combobox", { name: "Autonomy" }) as HTMLSelectElement; + expect(Array.from(autonomySelect.options).map((option) => option.value)).toEqual([ + "read-only", + "auto-low", + "auto-medium", + "auto-high", + ]); + expect(screen.queryByRole("combobox", { name: "Permissions" })).toBeNull(); + + fireEvent.change(autonomySelect, { target: { value: "auto-high" } }); + + expect(onDroidPermissionModeChange).toHaveBeenCalledWith("auto-high"); + }); + it("can hide native permission controls for fixed-mode surfaces", () => { renderComposer({ sessionProvider: "codex", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 8ae9142e9..f6f3b951a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -9,6 +9,7 @@ import { type AgentChatClaudePermissionMode, type AgentChatCursorConfigOption, type AgentChatCursorModeSnapshot, + type AgentChatDroidPermissionMode, type AgentChatCodexApprovalPolicy, type AgentChatCodexConfigSource, type AgentChatCodexSandbox, @@ -62,6 +63,7 @@ export type ParallelComposerControlSlot = { codexSandbox: AgentChatCodexSandbox; codexConfigSource: AgentChatCodexConfigSource; opencodePermissionMode: AgentChatOpenCodePermissionMode; + droidPermissionMode: AgentChatDroidPermissionMode; cursorModeSnapshot: AgentChatCursorModeSnapshot | null; onInteractionModeChange: (mode: AgentChatInteractionMode) => void; onClaudeModeChange: (mode: AgentChatClaudePermissionMode) => void; @@ -75,6 +77,7 @@ export type ParallelComposerControlSlot = { onCodexSandboxChange: (sandbox: AgentChatCodexSandbox) => void; onCodexConfigSourceChange: (source: AgentChatCodexConfigSource) => void; onOpenCodePermissionModeChange: (mode: AgentChatOpenCodePermissionMode) => void; + onDroidPermissionModeChange: (mode: AgentChatDroidPermissionMode) => void; onCursorModeChange: (modeId: string) => void; onCursorConfigChange: (configId: string, value: string | boolean) => void; }; @@ -195,6 +198,13 @@ const OPENCODE_PERMISSION_OPTIONS: Array<{ value: AgentChatOpenCodePermissionMod { value: "full-auto", label: "Full auto" }, ]; +const DROID_PERMISSION_OPTIONS: Array<{ value: AgentChatDroidPermissionMode; label: string; detail: string }> = [ + { value: "read-only", label: "Read-only", detail: "No auto flag. Droid stays in read-only mode for analysis and planning." }, + { value: "auto-low", label: "Auto low", detail: "Passes --auto low for safe file edits and low-risk operations." }, + { value: "auto-medium", label: "Auto medium", detail: "Passes --auto medium for local development operations such as builds, tests, and package installs." }, + { value: "auto-high", label: "Auto high", detail: "Passes --auto high for broad automation. Use only in trusted workspaces." }, +]; + function cursorModeLabel(modeId: string): string { const normalized = modeId.trim().toLowerCase(); if (!normalized.length) return "Agent"; @@ -382,6 +392,7 @@ export function AgentChatComposer({ codexSandbox, codexConfigSource, opencodePermissionMode, + droidPermissionMode, cursorModeSnapshot, executionMode, computerUseSnapshot, @@ -411,6 +422,7 @@ export function AgentChatComposer({ onCodexSandboxChange, onCodexConfigSourceChange, onOpenCodePermissionModeChange, + onDroidPermissionModeChange, onCursorModeChange, onCursorConfigChange, onToggleProof, @@ -464,6 +476,7 @@ export function AgentChatComposer({ codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeSnapshot?: AgentChatCursorModeSnapshot | null; executionMode?: AgentChatExecutionMode | null; computerUseSnapshot?: ComputerUseOwnerSnapshot | null; @@ -497,6 +510,7 @@ export function AgentChatComposer({ onCodexSandboxChange?: (sandbox: AgentChatCodexSandbox) => void; onCodexConfigSourceChange?: (source: AgentChatCodexConfigSource) => void; onOpenCodePermissionModeChange?: (mode: AgentChatOpenCodePermissionMode) => void; + onDroidPermissionModeChange?: (mode: AgentChatDroidPermissionMode) => void; onCursorModeChange?: (modeId: string) => void; onCursorConfigChange?: (configId: string, value: string | boolean) => void; onComputerUsePolicyChange?: (policy: unknown) => void; @@ -700,6 +714,7 @@ export function AgentChatComposer({ const csUse = slot?.codexSandbox ?? codexSandbox; const ccsUse = slot?.codexConfigSource ?? codexConfigSource; const opmUse = slot?.opencodePermissionMode ?? opencodePermissionMode; + const dpmUse = slot?.droidPermissionMode ?? droidPermissionMode ?? "auto-low"; const cmsUse = slot?.cursorModeSnapshot ?? cursorModeSnapshot; const claudeSelectionMode = cpmUse === "plan" || im === "plan" @@ -1131,6 +1146,37 @@ export function AgentChatComposer({ ); } + if (sp === "droid") { + return ( + + ); + } + const cursorModeOption = resolveCursorModeOption(cmsUse); const cursorExtraOptions = (cmsUse?.configOptions ?? []).filter((option) => { if (option.id === cmsUse?.modelConfigId) return false; @@ -1301,8 +1347,10 @@ export function AgentChatComposer({ onInteractionModeChange, onCursorConfigChange, onCursorModeChange, + onDroidPermissionModeChange, onOpenCodePermissionModeChange, cmsUse, + dpmUse, sp, opmUse, parallelControlSlot, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 645bea972..8612586c5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -48,6 +48,7 @@ import { chatChipToneClass } from "./chatSurfaceTheme"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; import { getToolMeta } from "./chatToolAppearance"; import { ClaudeLogo, CodexLogo, CursorAgentLogo } from "../terminals/ToolLogos"; +import { ModelRowLogo } from "../shared/ProviderLogos"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatWorkLogBlock } from "./ChatWorkLogBlock"; import { ChatStatusGlyph } from "./chatStatusVisuals"; @@ -1065,15 +1066,25 @@ function resolveModelLabel(modelId?: string, model?: string): string | null { return null; } -function resolveModelMeta(modelId?: string, model?: string): { label: string | null; family: string | null; cliCommand: string | null } { +function resolveModelMeta(modelId?: string, model?: string): { + label: string | null; + family: string | null; + cliCommand: string | null; + modelId: string | null; + providerModelId: string | null; +} { const key = modelId ?? model; const descriptor = key ? (getModelById(key) ?? resolveModelDescriptor(key)) : undefined; const idHint = String(modelId ?? model ?? "").trim(); const inferredCursor = !descriptor && idHint.startsWith("cursor/"); + const inferredDroid = !descriptor && idHint.startsWith("droid/"); return { label: resolveModelLabel(modelId, model), - family: descriptor?.family ?? (inferredCursor ? "cursor" : null), - cliCommand: descriptor?.cliCommand ?? (inferredCursor ? "cursor" : null), + family: descriptor?.family ?? (inferredCursor ? "cursor" : inferredDroid ? "factory" : null), + cliCommand: descriptor?.cliCommand ?? (inferredCursor ? "cursor" : inferredDroid ? "droid" : null), + modelId: descriptor?.id ?? (idHint || null), + providerModelId: descriptor?.providerModelId + ?? (inferredCursor ? idHint.slice("cursor/".length) : inferredDroid ? idHint.slice("droid/".length) : null), }; } @@ -1141,6 +1152,18 @@ function ModelGlyph({ if (meta.family === "cursor" || meta.cliCommand === "cursor") { return ; } + if (meta.family === "factory" || meta.cliCommand === "droid") { + return ( + + ); + } if (meta.family === "anthropic" || meta.cliCommand === "claude") { return ; } @@ -2454,6 +2477,9 @@ function renderEvent( /* ── Error ── */ if (event.type === "error") { + const errorCopyValue = event.detail?.trim().length + ? `${event.message}\n\n${event.detail}` + : event.message; return (
@@ -2469,10 +2495,15 @@ function renderEvent( ) : null}
- +
{event.message}
+ {event.detail?.trim().length ? ( +
+ {event.detail} +
+ ) : null} {event.errorInfo ? (
{typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c42419beb..462997158 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11,6 +11,7 @@ import { type AgentChatCodexConfigSource, type AgentChatCodexSandbox, type AgentChatCursorConfigValue, + type AgentChatDroidPermissionMode, type AgentChatExecutionMode, type AgentChatEventEnvelope, type AgentChatFileRef, @@ -209,6 +210,24 @@ function getExecutionModeOptions(model: ModelDescriptor | null | undefined): Exe }, ]; } + if (model.family === "factory") { + return [ + { + value: "focused", + label: "Focused", + summary: "Single thread", + helper: "Keep the turn in one Droid session unless the task clearly benefits from delegation.", + accent: "#A1A1AA", + }, + { + value: "parallel", + label: "Parallel", + summary: "Droid delegates", + helper: "Tell Droid to use available delegation or mission-style tools for independent subtasks, then reconcile the result.", + accent: "#10B981", + }, + ]; + } return []; } @@ -261,6 +280,7 @@ type NativeControlState = { codexSandbox: AgentChatCodexSandbox; codexConfigSource: AgentChatCodexConfigSource; opencodePermissionMode: AgentChatOpenCodePermissionMode; + droidPermissionMode: AgentChatDroidPermissionMode; cursorModeId: string | null; cursorConfigValues: Record; }; @@ -280,6 +300,7 @@ function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState codexSandbox: "danger-full-access", codexConfigSource: "flags", opencodePermissionMode: "full-auto", + droidPermissionMode: "auto-high", cursorModeId: "agent", cursorConfigValues: {}, }; @@ -291,12 +312,13 @@ function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState codexSandbox: "workspace-write", codexConfigSource: "flags", opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", cursorModeId: "agent", cursorConfigValues: {}, }; } -type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "opencode"; +type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode"; function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): ChatRuntimeProviderKey { return desc ? resolveProviderGroupForModel(desc) : "opencode"; @@ -304,7 +326,9 @@ function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): C function runtimeFacingModelId(desc: ModelDescriptor | null | undefined, registryModelId: string): string { if (!desc?.isCliWrapped) return registryModelId; - if (desc.family === "cursor" || desc.family === "openai") return desc.providerModelId || registryModelId; + if (desc.family === "cursor" || desc.family === "openai" || desc.family === "factory") { + return desc.providerModelId || registryModelId; + } return desc.shortId ?? registryModelId; } @@ -329,11 +353,11 @@ function cloneParallelSlotFromComposer(args: { } function summarizeNativeControls( - provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "opencode" | "cursor", + provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "opencode" | "cursor" | "droid", controls: NativeControlState, ): Pick< AgentChatSessionSummary, - "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "permissionMode" | "cursorModeId" + "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" | "permissionMode" | "cursorModeId" > { if (provider === "claude") { let permissionMode: AgentChatSessionSummary["permissionMode"]; @@ -381,12 +405,35 @@ function summarizeNativeControls( ...(controls.cursorModeId != null ? { cursorModeId: controls.cursorModeId } : {}), }; } + if (provider === "droid") { + return { + droidPermissionMode: controls.droidPermissionMode, + permissionMode: droidPermissionModeToLegacyPermissionMode(controls.droidPermissionMode), + }; + } return { opencodePermissionMode: controls.opencodePermissionMode, permissionMode: controls.opencodePermissionMode, }; } +function droidPermissionModeToLegacyPermissionMode(mode: AgentChatDroidPermissionMode): AgentChatPermissionMode { + if (mode === "read-only") return "plan"; + if (mode === "auto-low") return "edit"; + if (mode === "auto-medium") return "default"; + return "full-auto"; +} + +function legacyPermissionModeToDroidPermissionMode( + mode: AgentChatPermissionMode | undefined, +): AgentChatDroidPermissionMode | undefined { + if (mode === "plan") return "read-only"; + if (mode === "edit") return "auto-low"; + if (mode === "default") return "auto-medium"; + if (mode === "full-auto") return "auto-high"; + return undefined; +} + /** * Build a fallback CursorModeSnapshot when the Cursor ACP provider hasn't * reported its own snapshot yet. @@ -467,6 +514,13 @@ const HANDOFF_OPENCODE_MODES: Array<{ value: AgentChatOpenCodePermissionMode; la { value: "full-auto", label: "Full auto" }, ]; +const HANDOFF_DROID_MODES: Array<{ value: AgentChatDroidPermissionMode; label: string }> = [ + { value: "read-only", label: "Read-only" }, + { value: "auto-low", label: "Auto low" }, + { value: "auto-medium", label: "Auto medium" }, + { value: "auto-high", label: "Auto high" }, +]; + const handoffSelectCls = cn( "h-8 w-full min-w-0 rounded-md border border-white/[0.06] bg-white/[0.03] px-2 font-sans text-[10px] text-fg/70", "outline-none transition-colors duration-150 focus:border-violet-400/30", @@ -557,11 +611,13 @@ function resolveAssistantLabel( sessionProvider: string | null | undefined, ): string { if (model?.family === "cursor" || model?.cliCommand === "cursor") return "Cursor"; + if (model?.family === "factory" || model?.cliCommand === "droid") return "Droid"; if (model?.family === "anthropic" || model?.cliCommand === "claude") return "Claude"; if (model?.family === "openai" || model?.cliCommand === "codex") return "Codex"; if (sessionProvider === "claude") return "Claude"; if (sessionProvider === "codex") return "Codex"; if (sessionProvider === "cursor") return "Cursor"; + if (sessionProvider === "droid") return "Droid"; return "Assistant"; } @@ -666,7 +722,7 @@ function resolveRegistryModelId(value: string | null | undefined): string | null return match?.id ?? null; } -function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor", value: string | null | undefined): string | null { +function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor" | "droid", value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; if (provider === "cursor") { @@ -675,6 +731,12 @@ function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor", valu if (dynamic && dynamic.family === "cursor" && dynamic.isCliWrapped) return dynamic.id; return null; } + if (provider === "droid") { + const fullId = normalized.startsWith("droid/") ? normalized : `droid/${normalized}`; + const dynamic = getModelById(fullId) ?? resolveModelDescriptorForProvider(normalized.replace(/^droid\//, ""), "droid"); + if (dynamic && dynamic.family === "factory" && dynamic.isCliWrapped) return dynamic.id; + return null; + } const family = provider === "codex" ? "openai" : "anthropic"; const match = MODEL_REGISTRY.find( (model) => @@ -694,6 +756,7 @@ function chatToolTypeForProvider(provider: string | null | undefined): TerminalT case "codex": return "codex-chat"; case "claude": return "claude-chat"; case "cursor": return "cursor"; + case "droid": return "droid-chat"; default: return "opencode-chat"; } } @@ -1009,6 +1072,7 @@ export function AgentChatPane({ const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); const [codexConfigSource, setCodexConfigSource] = useState(initialNativeControls.codexConfigSource); const [opencodePermissionMode, setOpenCodePermissionMode] = useState(initialNativeControls.opencodePermissionMode); + const [droidPermissionMode, setDroidPermissionMode] = useState(initialNativeControls.droidPermissionMode); const prevModelDescRef = useRef(undefined); const [cursorModeId, setCursorModeId] = useState(initialNativeControls.cursorModeId); const [cursorConfigValues, setCursorConfigValues] = useState>(initialNativeControls.cursorConfigValues); @@ -1017,6 +1081,7 @@ export function AgentChatPane({ claude: AiProviderConnectionStatus | null; codex: AiProviderConnectionStatus | null; cursor: AiProviderConnectionStatus | null; + droid: AiProviderConnectionStatus | null; } | null>(null); const [attachments, setAttachments] = useState([]); const [sdkSlashCommands, setSdkSlashCommands] = useState([]); @@ -1056,6 +1121,9 @@ export function AgentChatPane({ const [handoffOpenCodePermissionMode, setHandoffOpenCodePermissionMode] = useState( initialNativeControls.opencodePermissionMode, ); + const [handoffDroidPermissionMode, setHandoffDroidPermissionMode] = useState( + initialNativeControls.droidPermissionMode, + ); const [handoffCursorModeId, setHandoffCursorModeId] = useState(initialNativeControls.cursorModeId); const [handoffCursorConfigValues, setHandoffCursorConfigValues] = useState>( () => ({ ...initialNativeControls.cursorConfigValues }), @@ -1273,6 +1341,8 @@ export function AgentChatPane({ ? (providerConnections?.codex ?? null) : selectedSession?.provider === "cursor" ? (providerConnections?.cursor ?? null) + : selectedSession?.provider === "droid" + ? (providerConnections?.droid ?? null) : null; const pendingApprovalIds = useMemo(() => { const ids = new Set(); @@ -1361,16 +1431,24 @@ export function AgentChatPane({ selectedSessionId && activeProviderConnection && !activeProviderConnection.runtimeAvailable - && (activeProviderConnection.blocker || activeProviderConnection.provider === "cursor"), + && ( + activeProviderConnection.blocker + || activeProviderConnection.provider === "cursor" + || activeProviderConnection.provider === "droid" + ), ); const cliRuntimeTitle = activeProviderConnection?.provider === "claude" ? "Claude runtime" : activeProviderConnection?.provider === "cursor" ? "Cursor runtime" + : activeProviderConnection?.provider === "droid" + ? "Droid runtime" : "Codex runtime"; const cliRuntimeBody = activeProviderConnection?.blocker - ?? (activeProviderConnection?.provider === "cursor" - ? "Cursor agent is not available. Ensure Cursor is installed and the agent is enabled." + ?? (activeProviderConnection?.provider === "droid" + ? "Droid is not available. Install the Factory CLI, ensure `droid` is on PATH, and configure Factory authentication." + : activeProviderConnection?.provider === "cursor" + ? "Cursor agent is not available. Ensure Cursor is installed and the agent is enabled." : null); const mergedRuntimeBanner = useMemo(() => { @@ -1483,6 +1561,7 @@ export function AgentChatPane({ codexSandbox: row.codexSandbox, codexConfigSource: row.codexConfigSource, opencodePermissionMode: row.opencodePermissionMode, + droidPermissionMode: row.droidPermissionMode, cursorModeSnapshot: parallelSlotCursorSnapshot, onInteractionModeChange: (mode) => patchParallelSlot(idx, { interactionMode: mode }), onClaudeModeChange: (mode) => patchParallelSlot(idx, { @@ -1499,6 +1578,7 @@ export function AgentChatPane({ onCodexSandboxChange: (sandbox) => patchParallelSlot(idx, { codexSandbox: sandbox }), onCodexConfigSourceChange: (source) => patchParallelSlot(idx, { codexConfigSource: source }), onOpenCodePermissionModeChange: (mode) => patchParallelSlot(idx, { opencodePermissionMode: mode }), + onDroidPermissionModeChange: (mode) => patchParallelSlot(idx, { droidPermissionMode: mode }), onCursorModeChange: (modeId) => patchParallelSlot(idx, { cursorModeId: modeId }), onCursorConfigChange: (configId, value) => patchParallelSlot(idx, { cursorConfigValues: { ...row.cursorConfigValues, [configId]: value }, @@ -1520,6 +1600,7 @@ export function AgentChatPane({ setCodexSandbox(initialNativeControls.codexSandbox); setCodexConfigSource(initialNativeControls.codexConfigSource); setOpenCodePermissionMode(initialNativeControls.opencodePermissionMode); + setDroidPermissionMode(initialNativeControls.droidPermissionMode); setCursorModeId(initialNativeControls.cursorModeId); setCursorConfigValues(initialNativeControls.cursorConfigValues); return; @@ -1536,6 +1617,11 @@ export function AgentChatPane({ setCodexSandbox(session.codexSandbox ?? initialNativeControls.codexSandbox); setCodexConfigSource(session.codexConfigSource ?? initialNativeControls.codexConfigSource); setOpenCodePermissionMode(session.opencodePermissionMode ?? initialNativeControls.opencodePermissionMode); + setDroidPermissionMode( + session.droidPermissionMode + ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) + ?? initialNativeControls.droidPermissionMode, + ); setCursorModeId(session.cursorModeId ?? session.cursorModeSnapshot?.currentModeId ?? initialNativeControls.cursorModeId); setCursorConfigValues( Object.fromEntries( @@ -1613,6 +1699,7 @@ export function AgentChatPane({ codexSandbox: handoffCodexSandbox, codexConfigSource: handoffCodexConfigSource, opencodePermissionMode: handoffOpenCodePermissionMode, + droidPermissionMode: handoffDroidPermissionMode, cursorModeId: handoffCursorModeId, cursorConfigValues: handoffCursorConfigValues, }), [ @@ -1622,6 +1709,7 @@ export function AgentChatPane({ handoffCodexSandbox, handoffCodexConfigSource, handoffOpenCodePermissionMode, + handoffDroidPermissionMode, handoffCursorModeId, handoffCursorConfigValues, ]); @@ -1664,8 +1752,9 @@ export function AgentChatPane({ claude: status.providerConnections?.claude ?? null, codex: status.providerConnections?.codex ?? null, cursor: status.providerConnections?.cursor ?? null, + droid: status.providerConnections?.droid ?? null, }); - const available = deriveConfiguredModelIds(status); + const available = deriveConfiguredModelIds(status, { includeDroid: true }); setAvailableModelIds(available); return available; } catch { @@ -1675,10 +1764,11 @@ export function AgentChatPane({ } try { - const [codexModels, claudeModels, cursorModels, openCodeModels] = await Promise.all([ + 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: "droid" }).catch(() => []), getAgentChatModelsCached({ projectRoot, provider: "opencode", @@ -1699,6 +1789,10 @@ export function AgentChatPane({ const resolved = resolveCliRegistryModelId("cursor", model.id); if (resolved) available.add(resolved); } + for (const model of droidModels) { + const resolved = resolveCliRegistryModelId("droid", model.id); + if (resolved) available.add(resolved); + } for (const model of openCodeModels) { const resolved = resolveRegistryModelId(model.id); if (resolved) { @@ -2110,6 +2204,7 @@ export function AgentChatPane({ selectedSession?.codexSandbox, selectedSession?.codexConfigSource, selectedSession?.opencodePermissionMode, + selectedSession?.droidPermissionMode, selectedSession?.permissionMode, selectedSession?.cursorModeId, selectedSession?.cursorModeSnapshot?.currentModeId, @@ -2244,6 +2339,7 @@ export function AgentChatPane({ setHandoffCodexSandbox(codexSandbox); setHandoffCodexConfigSource(codexConfigSource); setHandoffOpenCodePermissionMode(opencodePermissionMode); + setHandoffDroidPermissionMode(droidPermissionMode); setHandoffCursorModeId(cursorModeId); setHandoffCursorConfigValues({ ...cursorConfigValues }); } @@ -2603,6 +2699,7 @@ export function AgentChatPane({ codexSandbox, codexConfigSource, opencodePermissionMode, + droidPermissionMode, cursorModeId, cursorConfigValues, }), [ @@ -2612,6 +2709,7 @@ export function AgentChatPane({ codexSandbox, codexConfigSource, opencodePermissionMode, + droidPermissionMode, cursorModeId, cursorConfigValues, ]); @@ -2768,6 +2866,7 @@ export function AgentChatPane({ codexSandbox: handoffCodexSandbox, codexConfigSource: handoffCodexConfigSource, opencodePermissionMode: handoffOpenCodePermissionMode, + droidPermissionMode: handoffDroidPermissionMode, ...(resolvedHandoffPermissionMode != null ? { permissionMode: resolvedHandoffPermissionMode } : {}), cursorModeId: handoffCursorModeId, cursorConfigValues: handoffCursorConfigValues, @@ -2789,6 +2888,7 @@ export function AgentChatPane({ handoffCodexSandbox, handoffCursorConfigValues, handoffCursorModeId, + handoffDroidPermissionMode, handoffModelId, handoffNativePermissionMode, handoffOpenCodePermissionMode, @@ -3343,6 +3443,7 @@ export function AgentChatPane({ setCodexSandbox(nextControls.codexSandbox); setCodexConfigSource(nextControls.codexConfigSource); setOpenCodePermissionMode(nextControls.opencodePermissionMode); + setDroidPermissionMode(nextControls.droidPermissionMode); setCursorModeId(nextControls.cursorModeId); setCursorConfigValues(nextControls.cursorConfigValues); @@ -3383,6 +3484,7 @@ export function AgentChatPane({ codexSandbox: updatedSession.codexSandbox, codexConfigSource: updatedSession.codexConfigSource, opencodePermissionMode: updatedSession.opencodePermissionMode, + droidPermissionMode: updatedSession.droidPermissionMode, cursorModeId: updatedSession.cursorModeId, cursorModeSnapshot: updatedSession.cursorModeSnapshot, }); @@ -3632,6 +3734,20 @@ export function AgentChatPane({ ))} ) : null} + {handoffTargetProvider === "droid" ? ( + + ) : null} {handoffTargetProvider === "cursor" ? (