diff --git a/apps/desktop/src/main/services/ai/agentExecutor.ts b/apps/desktop/src/main/services/ai/agentExecutor.ts index b1cf7569a..a6562d70e 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"; +export type AgentProvider = "claude" | "codex" | "cursor" | "droid"; export type AgentPermissionMode = "read-only" | "edit" | "full-auto"; diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index e512bee52..5c08443c7 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -24,7 +24,9 @@ import type { createMemoryService } from "../memory/memoryService"; import type { CompactionFlushService } from "../memory/compactionFlushService"; import { discoverLocalModels } 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"; @@ -61,15 +63,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"; path?: string; @@ -259,11 +263,17 @@ function extractConfiguredApiKeys(snapshot: ReturnType 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"), }; } @@ -386,7 +396,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"; } @@ -408,10 +419,16 @@ 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"); + const factoryKey = Boolean(process.env.FACTORY_API_KEY?.trim()); 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( + factoryKey + || (droid?.installed && (droid.authenticated || !droid.verified)), + ), }; }; @@ -447,6 +464,25 @@ export function createAiIntegrationService(args: { } } + const hasDroidCliAuth = auth.some( + (entry) => + entry.type === "cli-subscription" + && entry.cli === "droid" + && entry.authenticated !== false, + ); + if (hasDroidCliAuth) { + 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 + } + } + const discoveredLocalModels = await discoverLocalModels(auth); if (discoveredLocalModels.length === 0) { return available; @@ -834,6 +870,8 @@ export function createAiIntegrationService(args: { family = "openai"; } else if (provider === "cursor") { family = "cursor"; + } else if (provider === "droid") { + family = "factory"; } else { family = "anthropic"; } @@ -903,6 +941,7 @@ export function createAiIntegrationService(args: { resetProviderRuntimeHealth(); resetClaudeRuntimeProbeCache(); clearCursorCliModelsCache(); + clearDroidCliModelsCache(); modelListCache.clear(); runtimeHealthVersion = getProviderRuntimeHealthVersion(); } @@ -925,12 +964,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; }); const result: AiIntegrationStatus = { @@ -940,6 +981,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.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 177e4eaf6..f612c23dc 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -2,13 +2,17 @@ // 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, resolveExecutableFromKnownLocations, } from "./cliExecutableResolver"; +import { resolveDroidExecutable } from "./droidExecutable"; -type CliName = "claude" | "codex" | "cursor"; +type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; @@ -34,7 +38,7 @@ export type CliAuthStatus = { export type DetectedAuth = | { type: "cli-subscription"; - cli: CliName; + cli: "claude" | "codex" | "cursor" | "droid"; path: string; authenticated: boolean; verified: boolean; @@ -62,10 +66,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 = [ @@ -356,6 +362,76 @@ 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 }); + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (result.status === 0 && combined.length > 0) { + sawVersionOk = true; + break; + } + 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 + } + + 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", @@ -701,7 +777,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( @@ -730,6 +806,26 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom paidPlan: auth.paidPlan, }; } + if (cli === "droid") { + const resolved = resolveDroidExecutable({ env: process.env }); + if (resolved.source === "fallback-command") { + return { + cli, + installed: false, + path: null, + authenticated: false, + verified: false, + }; + } + const auth = await inspectDroidCliPresence(resolved.path); + return { + cli, + installed: auth.installed, + path: resolved.path, + authenticated: auth.authenticated, + verified: auth.verified, + }; + } const auth = await inspectCliAuthentication(cli, cmd); return { cli, @@ -754,7 +850,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({ @@ -783,6 +879,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.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/ai/providerResolver.ts b/apps/desktop/src/main/services/ai/providerResolver.ts index c61d79711..1bbe842bc 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.ts @@ -311,6 +311,12 @@ async function resolveCliWrapped( ); } + if (cli === "droid") { + throw new Error( + "Droid models run in the work chat via the Factory CLI (ACP), not through this API path. Pick Droid in the work tab chat provider.", + ); + } + throw new Error(`Unknown CLI command "${cli}" for model "${descriptor.id}".`); } 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..67f527385 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpCliPool.ts @@ -0,0 +1,245 @@ +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"; + +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 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 { + proc.kill("SIGKILL"); + } catch { + // ignore + } + try { + proc.stdin?.destroy(); + proc.stdout?.destroy(); + proc.stderr?.destroy(); + } catch { + // ignore + } +} + +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; + + 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 { + 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", + }); + + 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) t.proc.kill("SIGKILL"); + } catch { + // ignore + } + } + terminals.clear(); + try { + proc?.kill("SIGTERM"); + } catch { + // ignore + } + }, + }; + + 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(() => { + 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); +} 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..69e07a7fc --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHostClient.ts @@ -0,0 +1,290 @@ +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, +} from "../shared/utils"; + +/** 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 proc = spawn(params.command, params.args ?? [], { + cwd, + env: mergeEnvVars(process.env, params.env ?? undefined), + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + 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) t.proc.kill("SIGKILL"); + } catch { + // ignore + } + console.warn( + `${logPrefix} waitForTerminalExit exceeded ${WAIT_FOR_TERMINAL_EXIT_MAX_MS}ms; sent SIGKILL`, + ); + 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 { + t.proc.kill("SIGTERM"); + } catch { + // ignore + } + } + }, + + async releaseTerminal(params: ReleaseTerminalRequest): Promise { + const t = terminals.get(params.terminalId); + if (t) { + try { + if (!t.exited) t.proc.kill("SIGKILL"); + } catch { + // ignore + } + 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 7f038978f..3401c70c6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -242,40 +242,43 @@ 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(), @@ -5301,38 +5304,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; }); @@ -5412,57 +5418,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; }); @@ -5500,47 +5509,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; }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c18c3d12d..93dde6f18 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -122,6 +122,7 @@ import { listModelDescriptorsForProvider, MODEL_REGISTRY, pickDefaultCursorDescriptorFromCliList, + pickDefaultDroidDescriptorFromCliList, resolveModelAlias, resolveModelDescriptorForProvider, resolveProviderGroupForModel, @@ -165,6 +166,7 @@ import { StdioClientTransport as McpStdioTransport } from "@modelcontextprotocol import type { McpServer, PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; import type { ExternalMcpServerConfig } from "../../../shared/types/externalMcp"; import { resolveCursorAgentExecutable } from "../ai/cursorAgentExecutable"; +import { resolveDroidExecutable } from "../ai/droidExecutable"; import { externalMcpConfigsToAcpStdio } from "./cursorAcpMcp"; import { acquireCursorAcpConnection, @@ -172,7 +174,14 @@ import { 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, @@ -369,6 +378,7 @@ type CursorPermissionWaiter = { type CursorRuntime = { kind: "cursor"; poolKey: string; + poolGeneration: number; pooled: CursorAcpPooled | null; acpSessionId: string | null; activeTurnId: string | null; @@ -387,7 +397,21 @@ type CursorRuntime = { configOptions: AgentChatCursorConfigOption[]; }; -type ChatRuntime = CodexRuntime | ClaudeRuntime | UnifiedRuntime | CursorRuntime; +type DroidRuntime = { + kind: "droid"; + poolKey: string; + poolGeneration: number; + pooled: DroidAcpPooled | null; + acpSessionId: string | null; + activeTurnId: string | null; + busy: boolean; + interrupted: boolean; + modelId: string; + pendingSteers: Array<{ steerId: string; text: string }>; + permissionWaiters: Map; +}; + +type ChatRuntime = CodexRuntime | ClaudeRuntime | UnifiedRuntime | CursorRuntime | DroidRuntime; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -536,11 +560,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 === "unified" || rt.kind === "claude" || rt.kind === "cursor") && rt.busy) { + if ((rt.kind === "unified" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid") && rt.busy) { return { ready: false, reason: "Turn already active" }; } if (rt.kind === "unified" && 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 }; @@ -554,7 +578,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 === "unified") 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; } @@ -783,6 +807,7 @@ type PreparedSendMessage = { onDispatched?: () => void; turnId?: string; optimisticCursorTurnStart?: boolean; + optimisticAcpTurnStart?: boolean; }; type ResolvedAgentChatFileRef = AgentChatFileRef & { @@ -810,10 +835,12 @@ const DEFAULT_CODEX_DESCRIPTOR = getDefaultModelDescriptor("codex"); const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); const DEFAULT_UNIFIED_DESCRIPTOR = getDefaultModelDescriptor("unified"); const DEFAULT_CURSOR_DESCRIPTOR = getDefaultModelDescriptor("cursor"); +const DEFAULT_DROID_DESCRIPTOR = getDefaultModelDescriptor("droid"); const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.sdkModelId ?? "gpt-5.4"; const DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_DESCRIPTOR?.sdkModelId ?? DEFAULT_CLAUDE_DESCRIPTOR?.shortId ?? "sonnet"; const DEFAULT_UNIFIED_MODEL_ID = DEFAULT_UNIFIED_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-4-6-api"; const DEFAULT_CURSOR_MODEL = DEFAULT_CURSOR_DESCRIPTOR?.sdkModelId ?? "auto"; +const DEFAULT_DROID_MODEL = DEFAULT_DROID_DESCRIPTOR?.sdkModelId ?? "claude-sonnet-4-5-20250929"; const DEFAULT_REASONING_EFFORT = "medium"; const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5-api"; const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024; @@ -923,6 +950,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; } @@ -998,12 +1040,13 @@ function describeCodexModel(value: string): string | null { function isChatToolType( toolType: TerminalToolType | null | undefined, -): toolType is "codex-chat" | "claude-chat" | "ai-chat" | "cursor" { +): toolType is "codex-chat" | "claude-chat" | "ai-chat" | "cursor" | "droid-chat" { return ( toolType === "codex-chat" || toolType === "claude-chat" || toolType === "ai-chat" || toolType === "cursor" + || toolType === "droid-chat" ); } @@ -1011,6 +1054,7 @@ function providerFromToolType(toolType: TerminalToolType | null | undefined): Ag if (toolType === "ai-chat") return "unified"; if (toolType === "claude-chat") return "claude"; if (toolType === "cursor") return "cursor"; + if (toolType === "droid-chat") return "droid"; return "codex"; } @@ -1018,6 +1062,7 @@ function toolTypeFromProvider(provider: AgentChatProvider): TerminalToolType { if (provider === "unified") return "ai-chat"; if (provider === "claude") return "claude-chat"; if (provider === "cursor") return "cursor"; + if (provider === "droid") return "droid-chat"; return "codex-chat"; } @@ -1129,10 +1174,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(); @@ -1143,6 +1189,7 @@ function resumeCommandForProvider(provider: AgentChatProvider, sessionId: string if (provider === "codex") return "chat:codex"; if (provider === "unified") return `chat:unified:${sessionId}`; if (provider === "cursor") return `chat:cursor:${sessionId}`; + if (provider === "droid") return `chat:droid:${sessionId}`; return `chat:claude:${sessionId}`; } @@ -1203,6 +1250,7 @@ function resolveModelIdFromStoredValue( if (providerHint === "claude" && !(aliasMatch.family === "anthropic" && aliasMatch.isCliWrapped)) return undefined; if (providerHint === "unified" && aliasMatch.isCliWrapped) return undefined; if (providerHint === "cursor" && aliasMatch.family !== "cursor") return undefined; + if (providerHint === "droid" && aliasMatch.family !== "factory") return undefined; return aliasMatch.id; } @@ -1223,6 +1271,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; @@ -1274,6 +1324,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_UNIFIED_MODEL_ID; } @@ -2092,6 +2143,37 @@ 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.sdkModelId; + } + const rawModel = String(session.model ?? "").trim(); + if (rawModel.length) { + const resolved = getModelById(`droid/${rawModel}`) ?? resolveModelDescriptorForProvider(rawModel, "droid"); + if (resolved?.family === "factory") { + return resolved.sdkModelId; + } + } + return DEFAULT_DROID_MODEL; +} + +function resolveDroidAcpLaunchSettings( + session: Pick, +): DroidAcpLaunchSettings { + const mode = resolveSessionUnifiedPermissionMode(session, "edit"); + if (mode === "plan") { + return { autonomy: "none" }; + } + if (mode === "full-auto") { + return { autonomy: "high" }; + } + if (mode === "edit") { + return { autonomy: "medium" }; + } + return { autonomy: "low" }; +} + function normalizeCursorReportedModelId( modelId: string | null | undefined, availableModelIds: readonly string[] = [], @@ -2223,7 +2305,13 @@ function normalizeSessionProfile(value: unknown): "light" | "workflow" | undefin } function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { - return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "unified" ? "full_mcp" : "fallback"; + return provider === "codex" + || provider === "claude" + || provider === "cursor" + || provider === "droid" + || provider === "unified" + ? "full_mcp" + : "fallback"; } function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { @@ -2364,8 +2452,11 @@ export function createAgentChatService(args: { }; const managedSessions = new Map(); - const cursorAcpSessionOwners = new Map(); - const cursorAcpBridgeWired = new WeakSet(); + /** ACP session id → owner for Cursor and Droid CLI hosts */ + const acpHostSessionOwners = new Map(); + const acpHostBridgeWired = new WeakSet(); + /** 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([ @@ -4472,7 +4563,8 @@ export function createAgentChatService(args: { && (managed.runtime?.kind === "claude" || managed.runtime?.kind === "codex" || managed.runtime?.kind === "unified" - || managed.runtime?.kind === "cursor") + || managed.runtime?.kind === "cursor" + || managed.runtime?.kind === "droid") ) { teardownRuntime(managed); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); @@ -4530,7 +4622,7 @@ export function createAgentChatService(args: { ...(managed.session.computerUse ? { computerUse: managed.session.computerUse } : {}), ...(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" @@ -4589,7 +4681,7 @@ export function createAgentChatService(args: { const record = parsed as Partial; if (record.version !== 1 && record.version !== 2) return null; const provider = record.provider; - if (provider !== "codex" && provider !== "claude" && provider !== "unified" && provider !== "cursor") { + if (provider !== "codex" && provider !== "claude" && provider !== "unified" && provider !== "cursor" && provider !== "droid") { return null; } const laneId = String(record.laneId ?? "").trim(); @@ -5321,14 +5413,27 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "cursor") { const rt = managed.runtime; if (rt.acpSessionId) { - cursorAcpSessionOwners.delete(rt.acpSessionId); + acpHostSessionOwners.delete(rt.acpSessionId); + void rt.pooled?.connection.unstable_closeSession?.({ sessionId: rt.acpSessionId }).catch(() => {}); + } + for (const [, w] of rt.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); + } + rt.permissionWaiters.clear(); + if (rt.pooled) releaseCursorAcpConnection(rt.poolKey, rt.poolGeneration); + managed.runtime = null; + } + if (managed.runtime?.kind === "droid") { + const rt = managed.runtime; + if (rt.acpSessionId) { + acpHostSessionOwners.delete(rt.acpSessionId); void rt.pooled?.connection.unstable_closeSession?.({ sessionId: 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) releaseDroidAcpConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } managed.runtimeInvalidated = true; @@ -5592,7 +5697,9 @@ export function createAgentChatService(args: { ? DEFAULT_UNIFIED_MODEL_ID : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id - : undefined); + : provider === "droid" + ? DEFAULT_DROID_DESCRIPTOR?.id + : undefined); const model = provider === "unified" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); @@ -9566,7 +9673,7 @@ export function createAgentChatService(args: { const cancelQueuedSteers = ( managed: ManagedChatSession, - runtime: Pick, + runtime: Pick, reason: "interrupted" | "failed" | "disposed", ): void => { const cancelled = runtime.pendingSteers.splice(0); @@ -10166,7 +10273,9 @@ export function createAgentChatService(args: { ? DEFAULT_CLAUDE_MODEL : provider === "cursor" ? DEFAULT_CURSOR_MODEL - : ""); + : provider === "droid" + ? DEFAULT_DROID_MODEL + : ""); // Resolve modelId from registry if provided const resolvedModelId = modelId && getModelById(modelId) ? modelId @@ -10180,6 +10289,10 @@ export function createAgentChatService(args: { 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 = resolvedModelId ? getModelById(resolvedModelId) : undefined; if (resolvedModelId && !resolvedDescriptor) { throw new Error(`Unknown model '${resolvedModelId}'.`); @@ -10204,7 +10317,7 @@ export function createAgentChatService(args: { : normalizeReasoningEffort(reasoningEffort); const normalizedReasoningEffort = effectiveProvider === "unified" ? rawEffort - : effectiveProvider === "cursor" + : effectiveProvider === "cursor" || effectiveProvider === "droid" ? null : validateReasoningEffort(effectiveProvider === "claude" ? "claude" : "codex", rawEffort); const normalizedCursorModeId = typeof requestedCursorModeId === "string" @@ -10266,6 +10379,13 @@ export function createAgentChatService(args: { : {}), }; } + if (effectiveProvider === "droid") { + return { + unifiedPermissionMode: requestedUnifiedPermissionMode + ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) + ?? chatConfig.unifiedPermissionMode, + }; + } return { unifiedPermissionMode: requestedUnifiedPermissionMode ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) @@ -10542,7 +10662,10 @@ export function createAgentChatService(args: { refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); } - if (managed.session.provider === "cursor" && managed.session.status === "active") { + if ( + (managed.session.provider === "cursor" || managed.session.provider === "droid") + && managed.session.status === "active" + ) { throw new Error("Turn is already active."); } @@ -10629,7 +10752,7 @@ export function createAgentChatService(args: { managed.runtime.activeTurnId = null; managed.runtime.activeQuery = null; } - if (managed.runtime?.kind === "cursor" && !isBusyError) { + if ((managed.runtime?.kind === "cursor" || managed.runtime?.kind === "droid") && !isBusyError) { managed.runtime.busy = false; managed.runtime.activeTurnId = null; } @@ -10715,17 +10838,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, @@ -10763,6 +10888,27 @@ export function createAgentChatService(args: { } }; + const syncDroidSessionDescriptor = ( + managed: ManagedChatSession, + modelId: string, + ): void => { + const trimmed = modelId.trim(); + if (!trimmed.length) return; + managed.session.model = trimmed; + const descriptor = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); + if (descriptor) { + managed.session.modelId = descriptor.id; + if (managed.runtime?.kind === "droid") { + managed.runtime.modelId = descriptor.sdkModelId; + } + return; + } + delete managed.session.modelId; + if (managed.runtime?.kind === "droid") { + managed.runtime.modelId = trimmed; + } + }; + const applyCursorConfigSnapshot = ( managed: ManagedChatSession, runtime: CursorRuntime, @@ -10790,6 +10936,13 @@ export function createAgentChatService(args: { syncCursorModeSnapshot(managed, runtime); }; + const ensureDroidSessionState = async ( + _managed: ManagedChatSession, + _runtime: DroidRuntime, + ): Promise => { + // Factory Droid over ACP does not mirror Cursor's mode/model config RPCs today. + }; + const ensureCursorSessionState = async ( managed: ManagedChatSession, runtime: CursorRuntime, @@ -11040,13 +11193,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); @@ -11065,8 +11218,8 @@ export function createAgentChatService(args: { }); }; - const scheduleCursorTerminalEmit = ( - pooled: CursorAcpPooled, + const scheduleAcpHostTerminalEmit = ( + pooled: CursorAcpPooled | DroidAcpPooled, terminalId: string, acpSessionId: string, ): void => { @@ -11077,38 +11230,43 @@ 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.unifiedPermissionMode = "plan"; - } else if (!owner.session.unifiedPermissionMode || owner.session.unifiedPermissionMode === "plan") { - owner.session.unifiedPermissionMode = "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; + + 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.unifiedPermissionMode = "plan"; + } else if (!owner.session.unifiedPermissionMode || owner.session.unifiedPermissionMode === "plan") { + owner.session.unifiedPermissionMode = "edit"; + } + syncCursorModeSnapshot(owner, rt); + persistChatState(owner); + } else if (note.update.sessionUpdate === "config_option_update") { + applyCursorConfigSnapshot(owner, rt, readCursorAcpConfigSnapshot(note.update.configOptions)); + persistChatState(owner); + } + } + + const turnId = rt.activeTurnId ?? ""; const resolveTerminal = (tid: string) => { const t = pooled.terminals.get(tid); if (!t) return null; @@ -11138,7 +11296,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); @@ -11146,7 +11304,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); @@ -11157,28 +11315,35 @@ 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; 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, + }, }); }); }; @@ -11196,7 +11361,7 @@ 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 }); } catch { @@ -11207,11 +11372,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; @@ -11220,7 +11385,7 @@ export function createAgentChatService(args: { teardownRuntime(managed); } - const pooled = await acquireCursorAcpConnection({ + const acquired = await acquireCursorAcpConnection({ poolKey, agentPath: resolveCursorAgentExecutable().path, workspacePath: managed.laneWorktreePath, @@ -11228,13 +11393,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, @@ -11280,7 +11448,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 } @@ -11387,7 +11555,7 @@ export function createAgentChatService(args: { syncCursorSessionDescriptor(managed, runtime.currentModelId); } syncCursorModeSnapshot(managed, runtime); - cursorAcpSessionOwners.set(sid, managed); + acpHostSessionOwners.set(sid, managed); persistChatState(managed); } @@ -11526,93 +11694,502 @@ export function createAgentChatService(args: { } }; - const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => { - const { - sessionId, - managed, - promptText, - visibleText, - attachments, - resolvedAttachments, - reasoningEffort, - laneDirectiveKey, - 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(":"); + }; - // Unified runtime dispatch - if (managed.session.provider === "unified") { - if (!managed.runtime || managed.runtime.kind !== "unified") { - const restarted = await startUnifiedSession(managed); - if (restarted !== "handled" || !managed.runtime) { - throw new Error(`Unified 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 existing.pooled?.connection.unstable_closeSession?.({ sessionId: existing.acpSessionId }); + } catch { + // ignore + } + } + 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; + return existing; } - if (reasoningEffort) { - managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); + } else if (managed.runtime) { + teardownRuntime(managed); + } + + 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; + 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, + pendingSteers: [], + permissionWaiters: new Map(), + }; + + const persistedAcp = readPersistedState(managed.session.id)?.acpSessionId?.trim(); + if (persistedAcp && typeof pooled.connection.unstable_resumeSession === "function") { + try { + const resumed = await pooled.connection.unstable_resumeSession({ + sessionId: persistedAcp, + cwd: managed.laneWorktreePath, + mcpServers: buildCursorAcpMcpServers(managed), + }); + rt.acpSessionId = persistedAcp; + acpHostSessionOwners.set(persistedAcp, managed); + void resumed; + } catch { + // stale session id — create a new ACP session on first prompt + } } - // Re-sync permission mode so mid-session changes take effect on this turn. - if (managed.runtime?.kind === "unified") { - const chatConfig = resolveChatConfig(); - const previousPermissionMode = managed.runtime.permissionMode; - managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( - managed.session, - chatConfig.unifiedPermissionMode, - ); - // When permission mode becomes stricter, clear accept_for_session approvals - // so old overrides cannot auto-approve actions under the new policy. - if (managed.runtime.permissionMode !== previousPermissionMode) { - managed.runtime.approvalOverrides = new Set(); + + throwIfDroidSetupInterrupted(); + let released = false; + if (managed.closed) { + releaseDroidAcpConnection(poolKey, poolGeneration); + released = true; + droidRuntimeSetupInterruptRequested.delete(managed); + throw new Error("Droid session closed during setup."); + } + managed.runtime = rt; + 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.") { + 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; } - await runTurn(managed, { - promptText, - displayText: visibleText, - attachments, - resolvedAttachments, - laneDirectiveKey, - onDispatched, - }); - return; + throw e; + } + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) { + throw new Error(validation.reason); } + runtime.interrupted = false; + runtime.busy = true; + runtime.activeTurnId = turnId; + managed.session.status = "active"; - if (managed.session.provider === "cursor") { - const chatConfig = resolveChatConfig(); - managed.session.unifiedPermissionMode = resolveSessionUnifiedPermissionMode( - managed.session, - chatConfig.unifiedPermissionMode, - ); - managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; - await runCursorTurn(managed, { - promptText, - displayText: visibleText, - attachments, - resolvedAttachments, - laneDirectiveKey, + const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText; + if (!args.optimisticDroidTurnStart) { + emitPreparedUserMessage(managed, { + text: displayText, + attachments: args.attachments, turnId, - optimisticCursorTurnStart, - onDispatched, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, }); - return; + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); } + emitChatEvent(managed, { + type: "activity", + ...initialTurnActivity(managed.session), + turnId, + }); - if (managed.session.provider === "codex") { - const runtime = await ensureCodexSessionRuntime(managed); - const nextReasoningEffort = validateReasoningEffort("codex", normalizeReasoningEffort(reasoningEffort)); - if (nextReasoningEffort) { - managed.session.reasoningEffort = nextReasoningEffort; - } else if (!managed.session.reasoningEffort) { - managed.session.reasoningEffort = DEFAULT_REASONING_EFFORT; + const turnStartedAt = Date.now(); + 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, + }); } - // Re-sync codex approval policy so mid-session changes take effect on this turn. - if (runtime.threadResumed) { - const prevApproval = managed.session.codexApprovalPolicy; - const prevSandbox = managed.session.codexSandbox; - resolveCodexThreadParams(managed); - if ( - managed.session.codexApprovalPolicy !== prevApproval + 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({ + cwd: managed.laneWorktreePath, + mcpServers: buildCursorAcpMcpServers(managed), + }); + const sid = created.sessionId; + runtime.acpSessionId = sid; + acpHostSessionOwners.set(sid, managed); + persistChatState(managed); + } + + 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, + }); + + 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; + + if (runtime.interrupted || promptRes.stopReason === "cancelled") { + 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 } : {}), + usage, + })) { + emitChatEvent(managed, ev); + } + } else { + managed.session.status = "idle"; + 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); + } + } + + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: "Droid agent turn completed.", + }); + persistChatState(managed); + + if (!managed.closed && runtime.pendingSteers.length) { + const nextSteer = runtime.pendingSteers.shift(); + const steerText = nextSteer?.text ?? ""; + if (steerText.trim().length) { + const preparedSteer = prepareSendMessage({ + sessionId: managed.session.id, + text: steerText, + displayText: steerText, + attachments: [], + }); + if (preparedSteer) await executePreparedSendMessage(preparedSteer); + } + } + } catch (error) { + managed.session.status = "idle"; + const msg = error instanceof Error ? error.message : String(error); + 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"); + + 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, 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") { + managed.session.status = "idle"; + } + } + }; + + const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => { + const { + sessionId, + managed, + promptText, + visibleText, + attachments, + resolvedAttachments, + reasoningEffort, + laneDirectiveKey, + onDispatched, + turnId, + optimisticCursorTurnStart, + optimisticAcpTurnStart, + } = prepared; + + // Unified runtime dispatch + if (managed.session.provider === "unified") { + if (!managed.runtime || managed.runtime.kind !== "unified") { + const restarted = await startUnifiedSession(managed); + if (restarted !== "handled" || !managed.runtime) { + throw new Error(`Unified 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 === "unified") { + const chatConfig = resolveChatConfig(); + const previousPermissionMode = managed.runtime.permissionMode; + managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( + managed.session, + chatConfig.unifiedPermissionMode, + ); + // When permission mode becomes stricter, clear accept_for_session approvals + // so old overrides cannot auto-approve actions under the new policy. + if (managed.runtime.permissionMode !== previousPermissionMode) { + managed.runtime.approvalOverrides = new Set(); + } + } + await runTurn(managed, { + promptText, + displayText: visibleText, + attachments, + resolvedAttachments, + laneDirectiveKey, + onDispatched, + }); + return; + } + + if (managed.session.provider === "cursor") { + const chatConfig = resolveChatConfig(); + managed.session.unifiedPermissionMode = resolveSessionUnifiedPermissionMode( + managed.session, + chatConfig.unifiedPermissionMode, + ); + 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.unifiedPermissionMode = resolveSessionUnifiedPermissionMode( + managed.session, + chatConfig.unifiedPermissionMode, + ); + 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) { + managed.session.reasoningEffort = nextReasoningEffort; + } else if (!managed.session.reasoningEffort) { + managed.session.reasoningEffort = DEFAULT_REASONING_EFFORT; + } + + // Re-sync codex approval policy so mid-session changes take effect on this turn. + if (runtime.threadResumed) { + const prevApproval = managed.session.codexApprovalPolicy; + const prevSandbox = managed.session.codexSandbox; + resolveCodexThreadParams(managed); + if ( + managed.session.codexApprovalPolicy !== prevApproval || managed.session.codexSandbox !== prevSandbox ) { // Policy drifted — force a re-resume so the codex server picks up the new settings. @@ -11746,6 +12323,21 @@ export function createAgentChatService(args: { // acknowledged the prompt. } + if (prepared.managed.session.provider === "droid") { + const turnId = randomUUID(); + prepared.turnId = turnId; + prepared.optimisticAcpTurnStart = true; + emitChatEvent(prepared.managed, { + type: "user_message", + text: prepared.visibleText, + attachments: prepared.attachments, + turnId, + }); + emitChatEvent(prepared.managed, { type: "status", turnStatus: "started", turnId }); + prepared.managed.session.status = "active"; + persistChatState(prepared.managed); + } + logger.info("agent_chat.turn_dispatch_ack", { sessionId: prepared.sessionId, provider: prepared.managed.session.provider, @@ -11835,6 +12427,49 @@ export function createAgentChatService(args: { return; } + 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; + } + const steerId = randomUUID(); + rt.pendingSteers.push({ steerId, text: trimmed }); + 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; + } + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments: [], + }); + if (!preparedSteer) return; + await executePreparedSendMessage(preparedSteer); + return; + } + if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); await runtime.collaborationModesReady?.catch(() => {}); @@ -11966,6 +12601,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(() => {}); @@ -12098,6 +12758,11 @@ export function createAgentChatService(args: { managed.session.unifiedPermissionMode = persisted?.unifiedPermissionMode ?? managed.session.unifiedPermissionMode; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; sessionService.setResumeCommand(sessionId, `chat:cursor:${sessionId}`); + } else if (managed.session.provider === "droid") { + await ensureDroidRuntime(managed); + managed.session.unifiedPermissionMode = persisted?.unifiedPermissionMode ?? managed.session.unifiedPermissionMode; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + sessionService.setResumeCommand(sessionId, `chat:droid:${sessionId}`); } else if (managed.runtime?.kind === "unified" || (managed.session.modelId && !providerResolver.isModelCliWrapped(managed.session.modelId))) { // Unified runtime resume — re-resolve the model const result = await startUnifiedSession(managed); @@ -12183,7 +12848,9 @@ export function createAgentChatService(args: { ? DEFAULT_UNIFIED_MODEL_ID : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id - : undefined); + : provider === "droid" + ? DEFAULT_DROID_DESCRIPTOR?.id + : undefined); const model = provider === "unified" ? (hydratedModelId ?? fallbackModel) : fallbackModel; return { sessionId: row.id, @@ -12584,7 +13251,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 @@ -12670,6 +13337,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 []; + } + } + // For unified/non-CLI providers: return all models with valid auth. try { const auth = await detectAuth(); @@ -12735,6 +13423,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 === "unified") { managed.runtime.interrupted = true; @@ -12984,6 +13684,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 (computerUse !== undefined) { @@ -13050,8 +13753,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; @@ -13086,13 +13790,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({ + cwd: managed.laneWorktreePath, + mcpServers: buildCursorAcpMcpServers(managed), + }); + const sid = created.sessionId; + runtime.acpSessionId = sid; + 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 f7b907f1b..1528ebfb7 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpPool.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpPool.ts @@ -1,245 +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 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 proc = spawn(params.command, params.args ?? [], { - cwd, - env: mergeEnvVars(process.env, params.env ?? undefined), - shell: process.platform === "win32", - stdio: ["pipe", "pipe", "pipe"], - }); - 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) { - try { - t.proc.kill("SIGTERM"); - } catch { - // ignore - } - } - }, - - async releaseTerminal(params: ReleaseTerminalRequest): Promise { - const t = terminals.get(params.terminalId); - if (t) { - try { - if (!t.exited) t.proc.kill("SIGKILL"); - } catch { - // ignore - } - 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; @@ -251,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>; @@ -265,7 +28,20 @@ export type CursorAcpLaunchSettings = { approveMcps: 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; @@ -274,13 +50,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", @@ -304,99 +74,94 @@ export async function acquireCursorAcpConnection(args: { spawnArgs.push("--api-key", apiKey); } - const proc = spawn(args.agentPath, spawnArgs, { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env }, - cwd: args.workspacePath, - detached: process.platform !== "win32", - }); - - 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 it up before creating a new one + if (existing) { + 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()) { - try { - if (!t.exited) t.proc.kill("SIGKILL"); - } catch { - // ignore - } - } - terminals.clear(); - try { - proc.kill("SIGTERM"); - } catch { - // ignore - } - }, - }; + 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; + if (!initOwner) { + await acquireAcpCliConnection(acpOptions); + const entry = cursorPools.get(args.poolKey); + if (entry) entry.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..18eb04988 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidAcpPool.ts @@ -0,0 +1,146 @@ +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 it up before creating a new one + if (existing) { + 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; + if (!initOwner) { + await acquireAcpCliConnection(acpOptions); + const entry = droidPools.get(args.poolKey); + if (entry) entry.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.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts new file mode 100644 index 000000000..9660ac77b --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -0,0 +1,120 @@ +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-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", + "gemini-3-pro-preview", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "glm-4.7", + "glm-5", + "kimi-k2.5", + "minimax-m2.5", +]; + +let cached: { at: number; models: string[] } | null = null; +const TTL_MS = 120_000; + +/** + * Best-effort: ask the Droid CLI for models (flags vary by version). + */ +export async function listDroidModelIdsFromCli(droidPath: string): Promise { + const now = Date.now(); + if (cached && now - cached.at < TTL_MS && cached.models.length) { + return cached.models; + } + + const probes: string[][] = [ + ["models", "--json"], + ["model", "list", "--json"], + ["models"], + ]; + + for (const args of probes) { + try { + const result = await spawnAsync(droidPath, args, { timeout: 15_000 }); + 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 ids: string[] = []; + for (const row of parsed) { + if (typeof row === "string" && row.trim()) { + ids.push(row.trim()); + 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() : ""; + if (id) ids.push(id); + } + } + if (ids.length) { + cached = { at: now, models: ids }; + return ids; + } + } + } 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: string[] = []; + 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(m[1]); + } + } + if (bare.length >= 3) { + cached = { at: now, models: bare }; + return bare; + } + } catch { + // try next probe + } + } + + return []; +} + +export function clearDroidCliModelsCache(): void { + cached = null; +} + +export async function discoverDroidCliModelDescriptors(droidPath: string): Promise { + const fromCli = await listDroidModelIdsFromCli(droidPath); + const ids = fromCli.length ? fromCli : DROID_DEFAULT_MODEL_IDS; + const seen = new Set(); + const descriptors: ModelDescriptor[] = []; + for (const id of ids) { + const trimmed = String(id ?? "").trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + descriptors.push(createDynamicDroidCliModelDescriptor(trimmed)); + } + return sortDroidCliDescriptorsForPicker(descriptors); +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 447438cfb..7abc3d2e8 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -837,11 +837,13 @@ function getUnavailableAiStatus(): AiSettingsStatus { claude: false, codex: false, cursor: false, + droid: false, }, models: { claude: [], codex: [], cursor: [], + droid: [], }, detectedAuth: [], providerConnections: { @@ -878,6 +880,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, @@ -1326,7 +1339,11 @@ function summarizeProjectScan(result: OnboardingDetectionResult | null): Partial function isChatToolType(toolType: string | null | undefined): boolean { - return toolType === "codex-chat" || toolType === "claude-chat" || toolType === "ai-chat" || toolType === "cursor"; + return toolType === "codex-chat" + || toolType === "claude-chat" + || toolType === "ai-chat" + || toolType === "cursor" + || toolType === "droid-chat"; } function inferPrAiProvider(modelId: string): "codex" | "claude" { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index ca682c91d..d31486f0e 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -683,7 +683,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/orchestratorSmoke.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts index 8ba7bfd0d..27379d8da 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts @@ -133,7 +133,7 @@ function buildDecisionStructuredOutput(prompt: string): Record function createMockAiIntegrationService() { return { - getAvailability: () => ({ claude: true, codex: true, cursor: false }), + getAvailability: () => ({ claude: true, codex: true, cursor: false, droid: false }), getMode: () => "subscription", getFeatureFlag: () => true, getDailyBudgetLimit: () => null, @@ -888,7 +888,7 @@ describe("orchestrator smoke", () => { }; const aiIntegrationService = { - 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/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index cf6b072ef..c3fbc3c4a 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -304,15 +304,19 @@ function cleanupStaleMcpConfigFiles(projectRoot: string): void { const VALID_PERMISSION_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); function resolveManagedPermissionMode(args: { - provider: "claude" | "codex" | "unified" | "cursor"; + provider: "claude" | "codex" | "unified" | "cursor" | "droid"; permissionConfig: LegacyPermissionConfig | undefined; readOnlyExecution: boolean; }): AgentChatPermissionMode | undefined { if (args.readOnlyExecution) return "plan"; const providers = args.permissionConfig?._providers; const candidate = - args.provider === "cursor" - ? ((providers?.cursor ?? providers?.unified) as string | undefined) + args.provider === "cursor" || args.provider === "droid" + ? (( + args.provider === "droid" + ? (providers?.droid ?? providers?.cursor ?? providers?.unified) + : (providers?.cursor ?? providers?.unified) + ) as string | undefined) : (providers?.[args.provider] as string | undefined); return typeof candidate === "string" && VALID_PERMISSION_MODES.has(candidate) ? candidate as AgentChatPermissionMode @@ -320,7 +324,7 @@ function resolveManagedPermissionMode(args: { } function mapPermissionModeToNativeFields( - provider: "claude" | "codex" | "unified" | "cursor", + provider: "claude" | "codex" | "unified" | "cursor" | "droid", mode: AgentChatPermissionMode | undefined, ): Partial> { if (!mode) return {}; @@ -352,10 +356,10 @@ function mapPermissionModeToNativeFields( } function resolveManagedExecutionMode(args: { - provider: "claude" | "codex" | "unified" | "cursor"; + provider: "claude" | "codex" | "unified" | "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/renderer/components/app/AppShell.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.test.tsx index 97ae7544c..fb320c7f4 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.test.tsx @@ -234,11 +234,13 @@ describe("AppShell", () => { claude: { authAvailable: false }, codex: { authAvailable: false }, cursor: { authAvailable: false }, + droid: { authAvailable: false }, }, availableProviders: { claude: false, codex: false, cursor: false, + droid: false, }, })) as any; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ca4429849..949155306 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -813,7 +813,7 @@ export function AgentChatComposer({ ); } - const runtimeLabel = sessionProvider === "cursor" ? "Cursor" : "ADE"; + const runtimeLabel = sessionProvider === "cursor" ? "Cursor" : sessionProvider === "droid" ? "Droid" : "ADE"; return (