From 948202e320b32c15fa05bca7a4cb593eb731a991 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 20:16:58 +0000 Subject: [PATCH 1/7] refactor(chat): extract shared ACP host client for CLI pools Share createAcpHostClient and acquireAcpCliConnection between providers. Refactor Cursor ACP pool to delegate spawn/init and cursor_login handling. Co-authored-by: Arul Sharma --- .../src/main/services/chat/acpCliPool.ts | 135 +++++++ .../src/main/services/chat/acpHostClient.ts | 250 ++++++++++++ .../src/main/services/chat/cursorAcpPool.ts | 370 +++--------------- 3 files changed, 431 insertions(+), 324 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/acpCliPool.ts create mode 100644 apps/desktop/src/main/services/chat/acpHostClient.ts 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..eef5eeb76 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpCliPool.ts @@ -0,0 +1,135 @@ +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(); + +export async function acquireAcpCliConnection(options: AcpCliPoolOptions): Promise { + const existing = pools.get(options.poolKey); + if (existing) { + existing.ref += 1; + return existing.pooled; + } + + const 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", + }); + + proc.on("error", (err) => { + console.error(`${options.logPrefix} process error for poolKey=${options.poolKey}:`, err); + const entry = pools.get(options.poolKey); + if (entry) { + entry.pooled.dispose(); + pools.delete(options.poolKey); + } + }); + + const terminals = new Map(); + const bridge: AcpHostBridge = { + onPermission: null, + onSessionUpdate: null, + getRootPath: () => "", + 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 + } + }, + }; + + proc.stderr?.on("data", () => { + // stderr — optional log + }); + + pools.set(options.poolKey, { ref: 1, pooled }); + return pooled; +} + +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); + } +} 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..777f743ba --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHostClient.ts @@ -0,0 +1,250 @@ +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, 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; +}; + +/** + * 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 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(`${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) { + 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); + } + }, + }; +} diff --git a/apps/desktop/src/main/services/chat/cursorAcpPool.ts b/apps/desktop/src/main/services/chat/cursorAcpPool.ts index f7b907f1b..1a11610f9 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, 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,11 @@ export type CursorAcpLaunchSettings = { approveMcps: boolean; }; -const pool = new Map(); +const cursorPools = new Map(); + +function internalPoolKey(poolKey: string): string { + return `cursor:${poolKey}`; +} export async function acquireCursorAcpConnection(args: { poolKey: string; @@ -275,12 +42,6 @@ export async function acquireCursorAcpConnection(args: { launchSettings: CursorAcpLaunchSettings; appVersion: string; }): Promise { - const existing = pool.get(args.poolKey); - if (existing) { - existing.ref += 1; - return existing.pooled; - } - const spawnArgs = [ "acp", "--workspace", @@ -304,99 +65,60 @@ 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 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 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 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) { + existing.ref += 1; + await acquireAcpCliConnection(acpOptions); + return existing.pooled; } + const base = await acquireAcpCliConnection(acpOptions); + const terminalWorkLogBindings = new Map(); const terminalOutputTimers = new Map>(); const pooled: CursorAcpPooled = { - connection, - bridge, - terminals, + connection: base.connection, + bridge: base.bridge, + terminals: base.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 - } - }, + dispose: base.dispose, }; - proc.stderr?.on("data", () => { - // stderr noise — optional log - }); - - pool.set(args.poolKey, { ref: 1, pooled }); + cursorPools.set(args.poolKey, { ref: 1, pooled }); return pooled; } export function releaseCursorAcpConnection(poolKey: string): void { - const entry = pool.get(poolKey); + const entry = cursorPools.get(poolKey); if (!entry) return; entry.ref -= 1; + releaseAcpCliConnection(internalPoolKey(poolKey)); if (entry.ref <= 0) { - entry.pooled.dispose(); - pool.delete(poolKey); + cursorPools.delete(poolKey); } } From 2c05873b2642a8f8216ddab3220e2ca58c8f29e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 20:17:21 +0000 Subject: [PATCH 2/7] feat(chat): add Factory Droid ACP work chat surface - Spawn droid exec --output-format acp with pooled JSON-RPC like Cursor - Dynamic droid/* model descriptors, discovery + defaults, auth via CLI + FACTORY_API_KEY - Wire DroidRuntime through agent chat (turns, steer queue, interrupt, permissions, persistence) - Extend AI status, model picker grouping, providers settings, missions permissions for droid Co-authored-by: Arul Sharma --- .../src/main/services/ai/agentExecutor.ts | 2 +- .../main/services/ai/aiIntegrationService.ts | 48 +- .../src/main/services/ai/authDetector.ts | 63 +- .../src/main/services/ai/droidExecutable.ts | 43 + .../services/ai/providerConnectionStatus.ts | 58 +- .../src/main/services/ai/providerResolver.ts | 6 + .../main/services/chat/agentChatService.ts | 769 ++++++++++++++++-- .../src/main/services/chat/droidAcpPool.ts | 103 +++ .../services/chat/droidModelsDiscovery.ts | 120 +++ .../src/main/services/ipc/registerIpc.ts | 19 +- .../aiOrchestratorService.test.ts | 2 +- .../orchestrator/orchestratorSmoke.test.ts | 4 +- .../unifiedOrchestratorAdapter.ts | 16 +- .../renderer/components/app/AppShell.test.tsx | 2 + .../components/chat/AgentChatComposer.tsx | 2 +- .../components/chat/AgentChatMessageList.tsx | 12 +- .../components/chat/AgentChatPane.tsx | 49 +- .../missions/CreateMissionDialog.tsx | 4 +- .../missions/WorkerPermissionsEditor.tsx | 14 +- .../settings/ProvidersSection.test.tsx | 13 + .../components/settings/ProvidersSection.tsx | 11 +- .../components/shared/ProviderLogos.tsx | 6 + .../shared/UnifiedModelSelector.tsx | 23 + .../shared/permissionOptions.test.ts | 7 + .../components/shared/permissionOptions.ts | 39 +- .../shared/unifiedModelSelectorGrouping.ts | 20 + .../src/renderer/lib/modelOptions.test.ts | 4 +- apps/desktop/src/renderer/lib/modelOptions.ts | 18 +- apps/desktop/src/renderer/lib/sessions.ts | 4 + apps/desktop/src/shared/modelRegistry.ts | 120 ++- apps/desktop/src/shared/types/chat.ts | 2 +- apps/desktop/src/shared/types/config.ts | 16 +- apps/desktop/src/shared/types/missions.ts | 2 + apps/desktop/src/shared/types/sessions.ts | 1 + 34 files changed, 1487 insertions(+), 135 deletions(-) create mode 100644 apps/desktop/src/main/services/ai/droidExecutable.ts create mode 100644 apps/desktop/src/main/services/chat/droidAcpPool.ts create mode 100644 apps/desktop/src/main/services/chat/droidModelsDiscovery.ts 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..1dbeb9499 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -8,7 +8,7 @@ import { resolveExecutableFromKnownLocations, } from "./cliExecutableResolver"; -type CliName = "claude" | "codex" | "cursor"; +type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; @@ -34,7 +34,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 +62,16 @@ 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,28 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: false, verified: false, paidPlan: false }; } +async function inspectDroidCliPresence(command: string): Promise<{ + authenticated: boolean; + verified: boolean; +}> { + const probes = CLI_AUTH_PROBES.droid ?? []; + 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) { + return { authenticated: true, verified: true }; + } + if (result.status === 0) { + return { authenticated: true, verified: false }; + } + } catch { + // try next probe + } + } + return { authenticated: false, verified: false }; +} + const ENV_KEY_MAP: Record = { ANTHROPIC_API_KEY: "anthropic", OPENAI_API_KEY: "openai", @@ -701,7 +729,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 +758,16 @@ export async function detectCliAuthStatuses(options?: { force?: boolean }): Prom paidPlan: auth.paidPlan, }; } + if (cli === "droid") { + const auth = await inspectDroidCliPresence(cmd); + return { + cli, + installed, + path, + authenticated: auth.authenticated, + verified: auth.verified, + }; + } const auth = await inspectCliAuthentication(cli, cmd); return { cli, @@ -754,7 +792,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 +821,21 @@ 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 = resolveExecutableFromKnownLocations("droid"); + results.push({ + type: "cli-subscription", + cli: "droid", + path: resolved?.path ?? "droid", + 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..26b10fd52 --- /dev/null +++ b/apps/desktop/src/main/services/ai/droidExecutable.ts @@ -0,0 +1,43 @@ +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 authPath = findDroidAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + + const envPath = env.DROID_EXECUTABLE?.trim() || env.FACTORY_DROID_EXECUTABLE?.trim(); + if (envPath) { + return { path: envPath, source: "path" }; + } + + 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..a06643797 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,57 @@ 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 ADE could not verify it. Set FACTORY_API_KEY or run `droid --version` to confirm install."; + } 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/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c18c3d12d..baf40e5d1 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, @@ -387,7 +396,20 @@ type CursorRuntime = { configOptions: AgentChatCursorConfigOption[]; }; -type ChatRuntime = CodexRuntime | ClaudeRuntime | UnifiedRuntime | CursorRuntime; +type DroidRuntime = { + kind: "droid"; + poolKey: string; + 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 +558,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 +576,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 +805,7 @@ type PreparedSendMessage = { onDispatched?: () => void; turnId?: string; optimisticCursorTurnStart?: boolean; + optimisticAcpTurnStart?: boolean; }; type ResolvedAgentChatFileRef = AgentChatFileRef & { @@ -810,10 +833,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 +948,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 +1038,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 +1052,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 +1060,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 +1172,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 +1187,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 +1248,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 +1269,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 +1322,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 +2141,34 @@ 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" }; + } + return { autonomy: "low" }; +} + function normalizeCursorReportedModelId( modelId: string | null | undefined, availableModelIds: readonly string[] = [], @@ -2223,7 +2300,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 +2447,9 @@ 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(); const sessionTurnCollectors = new Map(); const subagentStates = new Map>(); const AUTO_MEMORY_CATEGORY_ALLOWLIST = new Set([ @@ -4472,7 +4556,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 +4615,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 +4674,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,7 +5406,7 @@ 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) { @@ -5331,6 +5416,19 @@ export function createAgentChatService(args: { if (rt.pooled) releaseCursorAcpConnection(rt.poolKey); 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) releaseDroidAcpConnection(rt.poolKey); + managed.runtime = null; + } managed.runtimeInvalidated = true; clearLaneDirectiveKey(managed); }; @@ -5592,7 +5690,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 +9666,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 +10266,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 +10282,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 +10310,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 +10372,13 @@ export function createAgentChatService(args: { : {}), }; } + if (effectiveProvider === "droid") { + return { + unifiedPermissionMode: requestedUnifiedPermissionMode + ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) + ?? chatConfig.unifiedPermissionMode, + }; + } return { unifiedPermissionMode: requestedUnifiedPermissionMode ?? legacyPermissionModeToUnifiedPermissionMode(effectivePermissionMode) @@ -10542,7 +10655,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 +10745,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 +10831,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 +10881,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 +10929,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 +11186,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 +11211,8 @@ export function createAgentChatService(args: { }); }; - const scheduleCursorTerminalEmit = ( - pooled: CursorAcpPooled, + const scheduleAcpHostTerminalEmit = ( + pooled: CursorAcpPooled | DroidAcpPooled, terminalId: string, acpSessionId: string, ): void => { @@ -11077,38 +11223,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 +11289,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 +11297,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,27 +11308,28 @@ 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, + turnId: acpRt.activeTurnId ?? undefined, detail: { cursorAcp: true, request, toolCall: req.toolCall, options: req.options }, }); }); @@ -11196,7 +11348,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 { @@ -11211,7 +11363,7 @@ export function createAgentChatService(args: { 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; @@ -11228,7 +11380,7 @@ export function createAgentChatService(args: { launchSettings: resolveCursorAcpLaunchSettings(managed.session), appVersion, }); - wireCursorAcpBridgeHandlers(pooled); + wireAcpHostBridgeHandlers(pooled); pooled.bridge.getRootPath = () => managed.laneWorktreePath; pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; @@ -11280,7 +11432,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 +11539,7 @@ export function createAgentChatService(args: { syncCursorSessionDescriptor(managed, runtime.currentModelId); } syncCursorModeSnapshot(managed, runtime); - cursorAcpSessionOwners.set(sid, managed); + acpHostSessionOwners.set(sid, managed); persistChatState(managed); } @@ -11526,6 +11678,311 @@ export function createAgentChatService(args: { } }; + const droidPoolKeyFor = (managed: ManagedChatSession): string => { + const launch = resolveDroidAcpLaunchSettings(managed.session); + return [ + managed.session.laneId, + managed.laneWorktreePath, + managed.session.model, + launch.autonomy, + ].join(":"); + }; + + const ensureDroidRuntime = async (managed: ManagedChatSession): Promise => { + const poolKey = droidPoolKeyFor(managed); + const launchModelId = resolveDroidRuntimeModelId(managed.session); + 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); + managed.runtime = null; + } else { + if (!existing.pooled) throw new Error("Droid ACP connection not available"); + wireAcpHostBridgeHandlers(existing.pooled); + existing.pooled.bridge.getRootPath = () => managed.laneWorktreePath; + existing.pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; + return existing; + } + } else if (managed.runtime) { + teardownRuntime(managed); + } + + const auth = await detectAuth(); + const pooled = await acquireDroidAcpConnection({ + poolKey, + droidPath: resolveDroidExecutable({ auth }).path, + workspacePath: managed.laneWorktreePath, + modelId: launchModelId, + launchSettings: resolveDroidAcpLaunchSettings(managed.session), + appVersion, + }); + wireAcpHostBridgeHandlers(pooled); + pooled.bridge.getRootPath = () => managed.laneWorktreePath; + pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; + + const rt: DroidRuntime = { + kind: "droid", + poolKey, + pooled, + acpSessionId: null, + activeTurnId: null, + busy: false, + interrupted: false, + modelId: launchModelId, + pendingSteers: [], + permissionWaiters: new Map(), + }; + managed.runtime = rt; + + 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 + } + } + + return rt; + }; + + 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 runtime = await ensureDroidRuntime(managed); + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) { + throw new Error(validation.reason); + } + + const turnId = args.turnId ?? randomUUID(); + runtime.interrupted = false; + runtime.busy = true; + runtime.activeTurnId = turnId; + managed.session.status = "active"; + + const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText; + if (!args.optimisticDroidTurnStart) { + emitPreparedUserMessage(managed, { + text: displayText, + attachments: args.attachments, + turnId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + } + emitChatEvent(managed, { + type: "activity", + ...initialTurnActivity(managed.session), + turnId, + }); + + 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, + }); + } + + 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}`; + } + + 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); + } + + 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); + + for (const [, w] of runtime.permissionWaiters) { + w.resolve({ outcome: { outcome: "cancelled" } }); + } + runtime.permissionWaiters.clear(); + + cancelQueuedSteers(managed, runtime, runtime.interrupted ? "interrupted" : "failed"); + + if (runtime.interrupted) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + for (const ev of mapStopReasonToTerminalEvents({ + stopReason: "cancelled", + turnId, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + })) { + 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, @@ -11539,6 +11996,7 @@ export function createAgentChatService(args: { onDispatched, turnId, optimisticCursorTurnStart, + optimisticAcpTurnStart, } = prepared; // Unified runtime dispatch @@ -11597,6 +12055,26 @@ export function createAgentChatService(args: { 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)); @@ -11746,6 +12224,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 +12328,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 +12502,24 @@ 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 === "codex") { const runtime = await ensureCodexSessionRuntime(managed); await runtime.collaborationModesReady?.catch(() => {}); @@ -12098,6 +12652,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 +12742,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 +13145,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 +13231,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 +13317,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 +13578,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 +13647,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 +13684,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/droidAcpPool.ts b/apps/desktop/src/main/services/chat/droidAcpPool.ts new file mode 100644 index 000000000..e9a5e99c7 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidAcpPool.ts @@ -0,0 +1,103 @@ +import type { ClientSideConnection, InitializeResponse } from "@agentclientprotocol/sdk"; +import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; +import { acquireAcpCliConnection, 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; +}; + +const droidPools = new Map(); + +function internalPoolKey(poolKey: string): string { + return `droid:${poolKey}`; +} + +export async function acquireDroidAcpConnection(args: { + poolKey: string; + droidPath: string; + workspacePath: string; + modelId: string; + launchSettings: DroidAcpLaunchSettings; + appVersion: string; +}): Promise { + 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 existing = droidPools.get(args.poolKey); + if (existing) { + existing.ref += 1; + await acquireAcpCliConnection(acpOptions); + return existing.pooled; + } + + 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, + }; + + droidPools.set(args.poolKey, { ref: 1, pooled }); + return pooled; +} + +export function releaseDroidAcpConnection(poolKey: string): void { + const entry = droidPools.get(poolKey); + if (!entry) return; + entry.ref -= 1; + releaseAcpCliConnection(internalPoolKey(poolKey)); + if (entry.ref <= 0) { + 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 (