Skip to content

Commit 7bc0852

Browse files
arul28cursoragentclaude
authored
Droid Chat (#214)
* 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 <arul28@users.noreply.github.com> * 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 <arul28@users.noreply.github.com> * fix(acp): dedupe pool refs, pending init, process eviction, terminal safety - Serialize acquire per poolKey with shared pending init; fix double inner ref on cursor/droid fast path - Evict stale entries on child close/error; kill process and log stderr tail on init/handshake failure - Resolve ACP terminal cwd within lane root; cap waitForTerminalExit with SIGKILL + close wait Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * fix(droid): PR review — executable order, auth, interrupts, UI copy - Prefer DROID_EXECUTABLE/FACTORY_DROID_EXECUTABLE over synthetic auth paths - Do not treat droid --version as login; use FACTORY_API_KEY, ~/.factory/settings, or auth probes - Only inject FACTORY_API_KEY cli-subscription when binary resolves (not fallback-command) - Align droid blocker and settings login hint with actual auth steps - Honor interrupt during Droid runtime setup and before prompt dispatch; avoid Claude fallback on cancel mid-setup - Approval_request detail: cursorAcp only for Cursor; add acpHost source - Map unified edit mode to Droid --auto medium Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * fix(droid): auth probe verified flags; resolveDroid path; setup/turn interrupt events - inspectDroidCliPresence: STRONG_UNAUTH => verified true; inconclusive => verified false - detectCliAuthStatuses droid: use resolveDroidExecutable path for probes - ensureDroidRuntime: abort if managed.closed after acquire; release pool - runDroidTurn: emit interrupted status + terminal events on early exit and closed-during-setup Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * fix(acp): bounded acquire retries; clear waitForTerminalExit kill timer on close - Replace recursive acquireAcpCliConnection with retry loop (max attempts + backoff) - Clear SIGKILL timeout when process closes before WAIT_FOR_TERMINAL_EXIT_MAX_MS Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * fix: auth probe, pool generation guards, root path, double-release, and pool key normalization - authDetector: inspect stdout+stderr for auth patterns regardless of exit code - acpCliPool: getRootPath returns options.spawn.cwd instead of empty string - agentChatService: prevent double releaseDroidAcpConnection via released flag - agentChatService: droidPoolKeyFor uses resolved model ID, not raw session model - cursorAcpPool/droidAcpPool: add generation tokens to prevent stale-generation races — acquire returns { pooled, generation }, release validates generation - agentChatService: thread poolGeneration through CursorRuntime/DroidRuntime - Update test mocks to match new { pooled, generation } return shape * fix(droid): restore rebased chat surface updates * Deduplicate Droid chat and discover custom models * Add /shipLane command and portable ship-lane playbook Introduces an autonomous PR-to-merge driver that runs automate → finalize once, then polls CI and review comments on a self-paced 12-min cadence, fixing valid comments and failing tests in place. Prefers TeamCreate agent teams when available, falls back to parallel Agent calls otherwise. Opens the PR via `ade prs create` when possible so it shows up in ADE's PR tracking; falls back to `gh pr create` only after the agent has genuinely exhausted the ADE path via `--help`-driven discovery. Also narrows /automate to run only the new and affected tests (not the full suite), and makes /finalize's 8-shard parallel run explicit so shards don't get chained serially. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 68ef71a) * Drop trailing slash on node_modules gitignore pattern Lane worktrees use symlinks named node_modules pointing at the main checkout's installed copies. The previous `node_modules/` pattern only matched directories, so git saw the symlinks as untracked — which in turn blocked `ade prs create` preflight and forced /shipLane to fall back to `gh pr create`. Without the slash, the pattern matches both directories (main checkout) and symlinks (worktrees). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 07623ee) * Add Phase 3j cleanup of lingering worker processes to /finalize The 8-shard vitest run, parallel tsup builds, and typecheck fans sometimes leave worker pools behind after the phase exits. They don't fail CI but accumulate across runs and can hold file locks. New Phase 3j kills them scoped to apps/ paths so vitest instances the user may have running elsewhere aren't affected. Adds a Cleanup line to the Phase 4 summary and the completion checklist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 3a5fbe5) * ship: checkpoint before automate/finalize Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(droid): cover resolveDroidExecutable resolution priority Mirrors the codexExecutable test pattern: env override beats auth path, auth path beats PATH lookup, fallback returns the bare 'droid' command. * test(ai): seed droid provider connection in aiIntegrationService fixture aiIntegrationService.ts now reads providerConnections.droid.runtimeAvailable unconditionally, but the test helper only built {claude, codex, cursor}, producing TypeError on getStatus. Add droid to the helper, ServiceFactoryOptions availability, and the cli statuses / detectAllAuth mocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iteration 1 — fix test-desktop (5) authDetector droid probe, address 22 review comments CI: authDetector.test.ts 'treats droid exec list-tools as a valid authenticated probe' was failing because the test mocks did not cover the new droid resolution path; fixed mock setup so installed/verified resolve. Review: 22 actionable comments addressed across Greptile (3 P1/P2), Capy (1), and CodeRabbit (18) — covers async readFileSync, concurrency guard, dead code, ACP pool races, droid auth gating, permission propagation, terminal kill escalation, generic adapter fallback, browserMock helpers, AgentChatPane legacy autonomy, UsageGuardrails droid signal, providerModelSelectorGrouping factory metadata. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ccdab3 commit 7bc0852

71 files changed

Lines changed: 5156 additions & 799 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ade-cli/src/adeRpcServer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,7 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [
14601460
modelId: { type: "string" },
14611461
reasoningEffort: { type: "string" },
14621462
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"] },
1463+
droidPermissionMode: { type: "string", enum: ["read-only", "auto-low", "auto-medium", "auto-high"] },
14631464
title: { type: "string" },
14641465
initialPrompt: { type: "string" },
14651466
openInUi: { type: "boolean" }

apps/ade-cli/src/bootstrap.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
251251
const sessionService = createSessionService({ db });
252252
sessionService.reconcileStaleRunningSessions({
253253
status: "disposed",
254-
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"],
254+
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"],
255255
});
256256

257257
const projectConfigService = createProjectConfigService({

apps/ade-cli/src/cli.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,7 +1513,7 @@ function buildChatPlan(args: string[]): CliPlan {
15131513
const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(sessionId ? { sessionId } : {}) });
15141514
if (sub === "list" || sub === "ls") return { kind: "execute", label: "chat list", steps: [actionStep("result", "chat", "listSessions", collectGenericObjectArgs(args))] };
15151515
if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionStep("result", "chat", "getSessionSummary", withSession())] };
1516-
if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), surface: readValue(args, ["--surface"]) ?? "work" }))] };
1516+
if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), surface: readValue(args, ["--surface"]) ?? "work" }))] };
15171517
if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] };
15181518
if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] };
15191519
if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] };
@@ -2045,12 +2045,14 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
20452045
"-b", "-m", "-q", "-t",
20462046
"--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value",
20472047
"--arg-value-json", "--args-list-json", "--attempt", "--attempt-id",
2048-
"--automation", "--base", "--base-branch", "--base-ref", "--body", "--branch",
2048+
"--automation", "--autonomy",
2049+
"--base", "--base-branch", "--base-ref", "--body", "--branch",
20492050
"--branch-name", "--branch-ref", "--category", "--color", "--cols",
20502051
"--command", "--comment", "--comment-id", "--commit", "--compare-ref",
20512052
"--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data",
20522053
"--depth", "--desc",
2053-
"--description", "--domain", "--duration-sec", "--enabled", "--event",
2054+
"--description", "--domain", "--droid-autonomy", "--droid-permission-mode",
2055+
"--duration-sec", "--enabled", "--event",
20542056
"--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id",
20552057
"--input", "--input-json", "--instructions",
20562058
"--json-input", "--lane", "--lane-id", "--limit", "--max-bytes",

apps/desktop/package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"version:release": "node ./scripts/set-release-version.mjs"
4545
},
4646
"dependencies": {
47-
"@agentclientprotocol/sdk": "^0.17.1",
47+
"@agentclientprotocol/sdk": "^0.20.0",
4848
"@anthropic-ai/claude-agent-sdk": "^0.2.119",
4949
"@floating-ui/react": "^0.27.19",
5050
"@fontsource-variable/jetbrains-mono": "^5.2.8",
@@ -54,7 +54,7 @@
5454
"@lobehub/icons": "^5.2.0",
5555
"@lobehub/icons-static-svg": "^1.84.0",
5656
"@lobehub/ui": "^5.6.3",
57-
"@opencode-ai/sdk": "^1.3.17",
57+
"@opencode-ai/sdk": "^1.4.2",
5858
"@phosphor-icons/react": "^2.1.10",
5959
"@radix-ui/react-dialog": "^1.1.15",
6060
"@radix-ui/react-dropdown-menu": "^2.1.16",

apps/desktop/src/main/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { createJobEngine } from "./services/jobs/jobEngine";
3636
import { createAiIntegrationService } from "./services/ai/aiIntegrationService";
3737
import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver";
3838
import { createAgentChatService } from "./services/chat/agentChatService";
39+
import { shutdownAcpCliConnections } from "./services/chat/acpCliPool";
3940
import { createGithubService } from "./services/github/githubService";
4041
import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService";
4142
import { createPrService } from "./services/prs/prService";
@@ -4413,6 +4414,11 @@ app.whenReady().then(async () => {
44134414
}
44144415

44154416
shutdownOpenCodeServersBestEffort();
4417+
try {
4418+
shutdownAcpCliConnections();
4419+
} catch {
4420+
// ignore
4421+
}
44164422
};
44174423

44184424
const finalizeAppExit = (exitCode: number): void => {

apps/desktop/src/main/services/ai/agentExecutor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type AgentProvider = "claude" | "codex" | "cursor" | "opencode";
1+
export type AgentProvider = "claude" | "codex" | "cursor" | "droid" | "opencode";
22

33
export type AgentPermissionMode = "read-only" | "edit" | "full-auto";
44

apps/desktop/src/main/services/ai/aiIntegrationService.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ import { createAiIntegrationService } from "./aiIntegrationService";
7373
type ServiceFactoryOptions = {
7474
aiConfig?: Record<string, unknown>;
7575
dailyUsageCount?: number;
76-
availability?: { claude: boolean; codex: boolean; cursor?: boolean };
76+
availability?: { claude: boolean; codex: boolean; cursor?: boolean; droid?: boolean };
7777
providerMode?: "guest" | "subscription";
7878
};
7979

8080
type DbRunCall = { sql: string; params: unknown[] };
8181

82-
function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean }) {
82+
function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean; droid: boolean }) {
8383
const checkedAt = "2025-01-01T00:00:00.000Z";
8484
return {
8585
claude: {
@@ -112,6 +112,16 @@ function makeProviderConnections(availability: { claude: boolean; codex: boolean
112112
blocker: availability.cursor ? null : "Cursor unavailable",
113113
lastCheckedAt: checkedAt,
114114
},
115+
droid: {
116+
provider: "droid",
117+
authAvailable: availability.droid,
118+
runtimeDetected: availability.droid,
119+
runtimeAvailable: availability.droid,
120+
sources: [],
121+
path: availability.droid ? "/usr/local/bin/droid" : null,
122+
blocker: availability.droid ? null : "Droid unavailable",
123+
lastCheckedAt: checkedAt,
124+
},
115125
};
116126
}
117127

@@ -153,6 +163,7 @@ function makeService(options: ServiceFactoryOptions = {}) {
153163
claude: true,
154164
codex: true,
155165
cursor: false,
166+
droid: false,
156167
...(options.availability ?? {}),
157168
};
158169
const statuses = [
@@ -177,6 +188,13 @@ function makeService(options: ServiceFactoryOptions = {}) {
177188
authenticated: availability.cursor,
178189
verified: true,
179190
},
191+
{
192+
cli: "droid",
193+
installed: availability.droid,
194+
path: availability.droid ? "/usr/local/bin/droid" : null,
195+
authenticated: availability.droid,
196+
verified: true,
197+
},
180198
];
181199
mockState.getCachedCliAuthStatuses.mockReturnValue(statuses);
182200
mockState.detectAllAuth.mockResolvedValue([
@@ -189,6 +207,9 @@ function makeService(options: ServiceFactoryOptions = {}) {
189207
...(availability.cursor
190208
? [{ type: "cli-subscription", cli: "cursor", path: "/usr/local/bin/agent", authenticated: true, verified: true }]
191209
: []),
210+
...(availability.droid
211+
? [{ type: "cli-subscription", cli: "droid", path: "/usr/local/bin/droid", authenticated: true, verified: true }]
212+
: []),
192213
]);
193214
mockState.buildProviderConnections.mockResolvedValue(makeProviderConnections(availability));
194215

apps/desktop/src/main/services/ai/aiIntegrationService.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
peekOpenCodeInventoryCache,
3939
probeOpenCodeProviderInventory,
4040
} from "../opencode/openCodeInventory";
41-
import { resolveOpenCodeExecutablePath, type DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime";
41+
import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime";
4242
import { resolveOpenCodeBinary, type OpenCodeBinarySource } from "../opencode/openCodeBinaryManager";
4343
import { initialize as initModelsDevService } from "./modelsDevService";
4444
import { updateModelPricing } from "../../../shared/modelProfiles";
@@ -48,7 +48,9 @@ import { getApiKeyStoreStatus } from "./apiKeyStore";
4848
import type { createMemoryService } from "../memory/memoryService";
4949
import { inspectLocalProvider } from "./localModelDiscovery";
5050
import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery";
51+
import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery";
5152
import { resolveCursorAgentExecutable } from "./cursorAgentExecutable";
53+
import { resolveDroidExecutable } from "./droidExecutable";
5254
import { buildProviderConnections } from "./providerConnectionStatus";
5355
import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth";
5456
import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe";
@@ -91,15 +93,17 @@ export type AiIntegrationStatus = {
9193
claude: boolean;
9294
codex: boolean;
9395
cursor: boolean;
96+
droid: boolean;
9497
};
9598
models: {
9699
claude: AgentModelDescriptor[];
97100
codex: AgentModelDescriptor[];
98101
cursor: AgentModelDescriptor[];
102+
droid: AgentModelDescriptor[];
99103
};
100104
detectedAuth?: Array<{
101105
type: "cli-subscription" | "api-key" | "openrouter" | "local";
102-
cli?: "claude" | "codex" | "cursor";
106+
cli?: "claude" | "codex" | "cursor" | "droid";
103107
provider?: string;
104108
source?: "config" | "env" | "store";
105109
endpointSource?: "auto" | "config";
@@ -327,11 +331,17 @@ function extractConfiguredLocalProviders(
327331
return out;
328332
}
329333

330-
function toCliAvailability(auth: DetectedAuth[]): { claude: boolean; codex: boolean; cursor: boolean } {
334+
function toCliAvailability(auth: DetectedAuth[]): {
335+
claude: boolean;
336+
codex: boolean;
337+
cursor: boolean;
338+
droid: boolean;
339+
} {
331340
return {
332341
claude: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "claude"),
333342
codex: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "codex"),
334343
cursor: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "cursor"),
344+
droid: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "droid"),
335345
};
336346
}
337347

@@ -730,7 +740,8 @@ export function createAiIntegrationService(args: {
730740
args.providerConnections
731741
&& (args.providerConnections.claude.authAvailable
732742
|| args.providerConnections.codex.authAvailable
733-
|| args.providerConnections.cursor.authAvailable)
743+
|| args.providerConnections.cursor.authAvailable
744+
|| args.providerConnections.droid.authAvailable)
734745
) {
735746
return "subscription";
736747
}
@@ -752,10 +763,12 @@ export function createAiIntegrationService(args: {
752763
const claude = statuses.find((entry) => entry.cli === "claude");
753764
const codex = statuses.find((entry) => entry.cli === "codex");
754765
const cursor = statuses.find((entry) => entry.cli === "cursor");
766+
const droid = statuses.find((entry) => entry.cli === "droid");
755767
return {
756768
claude: Boolean(claude?.installed && (claude.authenticated || !claude.verified)),
757769
codex: Boolean(codex?.installed && (codex.authenticated || !codex.verified)),
758770
cursor: Boolean(cursor?.installed && (cursor.authenticated || !cursor.verified)),
771+
droid: Boolean(droid?.installed && (droid.authenticated || !droid.verified)),
759772
};
760773
};
761774

@@ -794,6 +807,26 @@ export function createAiIntegrationService(args: {
794807
}
795808
}
796809

810+
const hasDroidCliAuth = auth.some(
811+
(entry) =>
812+
entry.type === "cli-subscription"
813+
&& entry.cli === "droid"
814+
&& entry.authenticated !== false,
815+
);
816+
const hasDroidApiKey = Boolean(process.env.FACTORY_API_KEY?.trim());
817+
if (hasDroidCliAuth || hasDroidApiKey) {
818+
try {
819+
const { path: droidPath } = resolveDroidExecutable({ auth });
820+
const droidModels = await discoverDroidCliModelDescriptors(droidPath);
821+
available = [
822+
...available.filter((descriptor) => !(descriptor.family === "factory" && descriptor.isCliWrapped)),
823+
...droidModels,
824+
];
825+
} catch {
826+
// Droid CLI missing or model discovery failed — omit dynamic Droid list
827+
}
828+
}
829+
797830
return available;
798831
};
799832

@@ -1129,6 +1162,8 @@ export function createAiIntegrationService(args: {
11291162
family = "openai";
11301163
} else if (provider === "cursor") {
11311164
family = "cursor";
1165+
} else if (provider === "droid") {
1166+
family = "factory";
11321167
} else {
11331168
family = "anthropic";
11341169
}
@@ -1203,6 +1238,7 @@ export function createAiIntegrationService(args: {
12031238
resetClaudeRuntimeProbeCache();
12041239
resetLocalProviderDetectionCache();
12051240
clearCursorCliModelsCache();
1241+
clearDroidCliModelsCache();
12061242
modelListCache.clear();
12071243
runtimeHealthVersion = getProviderRuntimeHealthVersion();
12081244
}
@@ -1242,12 +1278,14 @@ export function createAiIntegrationService(args: {
12421278
claude: providerConnections.claude.runtimeAvailable,
12431279
codex: providerConnections.codex.runtimeAvailable,
12441280
cursor: providerConnections.cursor.runtimeAvailable,
1281+
droid: providerConnections.droid.runtimeAvailable,
12451282
};
12461283
const runtimeFilteredAvailable = available.filter((descriptor) => {
12471284
if (!descriptor.isCliWrapped) return true;
12481285
if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable;
12491286
if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable;
12501287
if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable;
1288+
if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable;
12511289
return true;
12521290
});
12531291

@@ -1309,6 +1347,7 @@ export function createAiIntegrationService(args: {
13091347
claude: availability.claude ? await listModels("claude") : [],
13101348
codex: availability.codex ? await listModels("codex") : [],
13111349
cursor: availability.cursor ? await listModels("cursor") : [],
1350+
droid: availability.droid ? await listModels("droid") : [],
13121351
},
13131352
detectedAuth: redactDetectedAuth(auth, cliStatuses),
13141353
providerConnections,

apps/desktop/src/main/services/ai/authDetector.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,53 @@ describe("authDetector", () => {
219219
);
220220
});
221221

222+
it("treats droid exec list-tools as a valid authenticated probe", async () => {
223+
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-"));
224+
process.env.HOME = tempHomeDir;
225+
// Create a fake droid binary in a known bin dir so resolveDroidExecutable
226+
// (which uses fs.statSync against real paths) finds it without falling
227+
// through to the real CI PATH.
228+
const droidBinDir = path.join(tempHomeDir, ".local", "bin");
229+
fs.mkdirSync(droidBinDir, { recursive: true });
230+
const fakeDroidPath = path.join(droidBinDir, "droid");
231+
fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
232+
// Strip PATH so resolveExecutableFromKnownLocations skips real binaries
233+
// on the CI runner and uses the temp home's known dirs.
234+
process.env.PATH = "";
235+
236+
spawnMock.mockImplementation((command: string, args: string[] = []) => {
237+
if (args[0] === "--version") {
238+
if (command === "droid" || command.endsWith("/droid")) return fakeChild({ status: 0, stdout: "0.70.0\n" });
239+
return fakeError();
240+
}
241+
if (command === "which") {
242+
if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` });
243+
return fakeChild({ status: 1 });
244+
}
245+
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "exec" && args[1] === "--list-tools") {
246+
return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" });
247+
}
248+
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "account") {
249+
return fakeChild({ status: 1, stderr: "unknown command 'account'\n" });
250+
}
251+
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "whoami") {
252+
return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" });
253+
}
254+
return fakeChild({ status: 1 });
255+
});
256+
257+
const statuses = await detectCliAuthStatuses();
258+
const droid = statuses.find((entry) => entry.cli === "droid");
259+
260+
expect(droid).toEqual({
261+
cli: "droid",
262+
installed: true,
263+
path: fakeDroidPath,
264+
authenticated: true,
265+
verified: true,
266+
});
267+
});
268+
222269
it("does not report openai-compatible local providers when no models are loaded", async () => {
223270
vi.stubGlobal(
224271
"fetch",

0 commit comments

Comments
 (0)