Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5290824
refactor(chat): extract shared ACP host client for CLI pools
cursoragent Apr 3, 2026
685d018
feat(chat): add Factory Droid ACP work chat surface
cursoragent Apr 3, 2026
7d9fcda
fix(acp): dedupe pool refs, pending init, process eviction, terminal …
cursoragent Apr 6, 2026
af919cf
fix(droid): PR review — executable order, auth, interrupts, UI copy
cursoragent Apr 6, 2026
928e73f
fix(droid): auth probe verified flags; resolveDroid path; setup/turn …
cursoragent Apr 6, 2026
8ece358
fix(acp): bounded acquire retries; clear waitForTerminalExit kill tim…
cursoragent Apr 6, 2026
2f4a16e
fix: auth probe, pool generation guards, root path, double-release, a…
arul28 Apr 6, 2026
31a88f3
fix(droid): restore rebased chat surface updates
arul28 Apr 9, 2026
c2a3f67
Deduplicate Droid chat and discover custom models
arul28 Apr 17, 2026
1875c80
Add /shipLane command and portable ship-lane playbook
arul28 Apr 23, 2026
22db68e
Drop trailing slash on node_modules gitignore pattern
arul28 Apr 23, 2026
7c587b1
Add Phase 3j cleanup of lingering worker processes to /finalize
arul28 Apr 23, 2026
e115731
Merge origin/main into cursor/droid-ai-chat-surface-6b3b
arul28 Apr 28, 2026
194ec34
ship: checkpoint before automate/finalize
arul28 Apr 28, 2026
d84812d
test(droid): cover resolveDroidExecutable resolution priority
arul28 Apr 28, 2026
12b3ab6
test(ai): seed droid provider connection in aiIntegrationService fixture
arul28 Apr 28, 2026
74779a0
ship: iteration 1 — fix test-desktop (5) authDetector droid probe, ad…
arul28 Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,7 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [
modelId: { type: "string" },
reasoningEffort: { type: "string" },
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"] },
droidPermissionMode: { type: "string", enum: ["read-only", "auto-low", "auto-medium", "auto-high"] },
title: { type: "string" },
initialPrompt: { type: "string" },
openInUi: { type: "boolean" }
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
const sessionService = createSessionService({ db });
sessionService.reconcileStaleRunningSessions({
status: "disposed",
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"],
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"],
});

const projectConfigService = createProjectConfigService({
Expand Down
8 changes: 5 additions & 3 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,7 @@ function buildChatPlan(args: string[]): CliPlan {
const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(sessionId ? { sessionId } : {}) });
if (sub === "list" || sub === "ls") return { kind: "execute", label: "chat list", steps: [actionStep("result", "chat", "listSessions", collectGenericObjectArgs(args))] };
if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionStep("result", "chat", "getSessionSummary", withSession())] };
if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), surface: readValue(args, ["--surface"]) ?? "work" }))] };
if (sub === "create" || sub === "spawn") return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), modelId: readValue(args, ["--model", "--model-id"]), permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), surface: readValue(args, ["--surface"]) ?? "work" }))] };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] };
if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] };
if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] };
Expand Down Expand Up @@ -2045,12 +2045,14 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
"-b", "-m", "-q", "-t",
"--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value",
"--arg-value-json", "--args-list-json", "--attempt", "--attempt-id",
"--automation", "--base", "--base-branch", "--base-ref", "--body", "--branch",
"--automation", "--autonomy",
"--base", "--base-branch", "--base-ref", "--body", "--branch",
"--branch-name", "--branch-ref", "--category", "--color", "--cols",
"--command", "--comment", "--comment-id", "--commit", "--compare-ref",
"--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data",
"--depth", "--desc",
"--description", "--domain", "--duration-sec", "--enabled", "--event",
"--description", "--domain", "--droid-autonomy", "--droid-permission-mode",
"--duration-sec", "--enabled", "--event",
"--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id",
"--input", "--input-json", "--instructions",
"--json-input", "--lane", "--lane-id", "--limit", "--max-bytes",
Expand Down
16 changes: 8 additions & 8 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"version:release": "node ./scripts/set-release-version.mjs"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@agentclientprotocol/sdk": "^0.20.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.119",
"@floating-ui/react": "^0.27.19",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
Expand All @@ -54,7 +54,7 @@
"@lobehub/icons": "^5.2.0",
"@lobehub/icons-static-svg": "^1.84.0",
"@lobehub/ui": "^5.6.3",
"@opencode-ai/sdk": "^1.3.17",
"@opencode-ai/sdk": "^1.4.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { createJobEngine } from "./services/jobs/jobEngine";
import { createAiIntegrationService } from "./services/ai/aiIntegrationService";
import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver";
import { createAgentChatService } from "./services/chat/agentChatService";
import { shutdownAcpCliConnections } from "./services/chat/acpCliPool";
import { createGithubService } from "./services/github/githubService";
import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService";
import { createPrService } from "./services/prs/prService";
Expand Down Expand Up @@ -4413,6 +4414,11 @@ app.whenReady().then(async () => {
}

shutdownOpenCodeServersBestEffort();
try {
shutdownAcpCliConnections();
} catch {
// ignore
}
};

const finalizeAppExit = (exitCode: number): void => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/ai/agentExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AgentProvider = "claude" | "codex" | "cursor" | "opencode";
export type AgentProvider = "claude" | "codex" | "cursor" | "droid" | "opencode";

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

Expand Down
25 changes: 23 additions & 2 deletions apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ import { createAiIntegrationService } from "./aiIntegrationService";
type ServiceFactoryOptions = {
aiConfig?: Record<string, unknown>;
dailyUsageCount?: number;
availability?: { claude: boolean; codex: boolean; cursor?: boolean };
availability?: { claude: boolean; codex: boolean; cursor?: boolean; droid?: boolean };
providerMode?: "guest" | "subscription";
};

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

function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean }) {
function makeProviderConnections(availability: { claude: boolean; codex: boolean; cursor: boolean; droid: boolean }) {
const checkedAt = "2025-01-01T00:00:00.000Z";
return {
claude: {
Expand Down Expand Up @@ -112,6 +112,16 @@ function makeProviderConnections(availability: { claude: boolean; codex: boolean
blocker: availability.cursor ? null : "Cursor unavailable",
lastCheckedAt: checkedAt,
},
droid: {
provider: "droid",
authAvailable: availability.droid,
runtimeDetected: availability.droid,
runtimeAvailable: availability.droid,
sources: [],
path: availability.droid ? "/usr/local/bin/droid" : null,
blocker: availability.droid ? null : "Droid unavailable",
lastCheckedAt: checkedAt,
},
};
}

Expand Down Expand Up @@ -153,6 +163,7 @@ function makeService(options: ServiceFactoryOptions = {}) {
claude: true,
codex: true,
cursor: false,
droid: false,
...(options.availability ?? {}),
};
const statuses = [
Expand All @@ -177,6 +188,13 @@ function makeService(options: ServiceFactoryOptions = {}) {
authenticated: availability.cursor,
verified: true,
},
{
cli: "droid",
installed: availability.droid,
path: availability.droid ? "/usr/local/bin/droid" : null,
authenticated: availability.droid,
verified: true,
},
];
mockState.getCachedCliAuthStatuses.mockReturnValue(statuses);
mockState.detectAllAuth.mockResolvedValue([
Expand All @@ -189,6 +207,9 @@ function makeService(options: ServiceFactoryOptions = {}) {
...(availability.cursor
? [{ type: "cli-subscription", cli: "cursor", path: "/usr/local/bin/agent", authenticated: true, verified: true }]
: []),
...(availability.droid
? [{ type: "cli-subscription", cli: "droid", path: "/usr/local/bin/droid", authenticated: true, verified: true }]
: []),
]);
mockState.buildProviderConnections.mockResolvedValue(makeProviderConnections(availability));

Expand Down
47 changes: 43 additions & 4 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
peekOpenCodeInventoryCache,
probeOpenCodeProviderInventory,
} from "../opencode/openCodeInventory";
import { resolveOpenCodeExecutablePath, type DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime";
import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime";
import { resolveOpenCodeBinary, type OpenCodeBinarySource } from "../opencode/openCodeBinaryManager";
import { initialize as initModelsDevService } from "./modelsDevService";
import { updateModelPricing } from "../../../shared/modelProfiles";
Expand All @@ -48,7 +48,9 @@ import { getApiKeyStoreStatus } from "./apiKeyStore";
import type { createMemoryService } from "../memory/memoryService";
import { inspectLocalProvider } from "./localModelDiscovery";
import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery";
import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery";
import { resolveCursorAgentExecutable } from "./cursorAgentExecutable";
import { resolveDroidExecutable } from "./droidExecutable";
import { buildProviderConnections } from "./providerConnectionStatus";
import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth";
import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe";
Expand Down Expand Up @@ -91,15 +93,17 @@ export type AiIntegrationStatus = {
claude: boolean;
codex: boolean;
cursor: boolean;
droid: boolean;
};
models: {
claude: AgentModelDescriptor[];
codex: AgentModelDescriptor[];
cursor: AgentModelDescriptor[];
droid: AgentModelDescriptor[];
};
detectedAuth?: Array<{
type: "cli-subscription" | "api-key" | "openrouter" | "local";
cli?: "claude" | "codex" | "cursor";
cli?: "claude" | "codex" | "cursor" | "droid";
provider?: string;
source?: "config" | "env" | "store";
endpointSource?: "auto" | "config";
Expand Down Expand Up @@ -327,11 +331,17 @@ function extractConfiguredLocalProviders(
return out;
}

function toCliAvailability(auth: DetectedAuth[]): { claude: boolean; codex: boolean; cursor: boolean } {
function toCliAvailability(auth: DetectedAuth[]): {
claude: boolean;
codex: boolean;
cursor: boolean;
droid: boolean;
} {
return {
claude: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "claude"),
codex: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "codex"),
cursor: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "cursor"),
droid: auth.some((entry) => entry.type === "cli-subscription" && entry.cli === "droid"),
};
}

Expand Down Expand Up @@ -730,7 +740,8 @@ export function createAiIntegrationService(args: {
args.providerConnections
&& (args.providerConnections.claude.authAvailable
|| args.providerConnections.codex.authAvailable
|| args.providerConnections.cursor.authAvailable)
|| args.providerConnections.cursor.authAvailable
|| args.providerConnections.droid.authAvailable)
) {
return "subscription";
}
Expand All @@ -752,10 +763,12 @@ export function createAiIntegrationService(args: {
const claude = statuses.find((entry) => entry.cli === "claude");
const codex = statuses.find((entry) => entry.cli === "codex");
const cursor = statuses.find((entry) => entry.cli === "cursor");
const droid = statuses.find((entry) => entry.cli === "droid");
return {
claude: Boolean(claude?.installed && (claude.authenticated || !claude.verified)),
codex: Boolean(codex?.installed && (codex.authenticated || !codex.verified)),
cursor: Boolean(cursor?.installed && (cursor.authenticated || !cursor.verified)),
droid: Boolean(droid?.installed && (droid.authenticated || !droid.verified)),
};
};

Expand Down Expand Up @@ -794,6 +807,26 @@ export function createAiIntegrationService(args: {
}
}

const hasDroidCliAuth = auth.some(
(entry) =>
entry.type === "cli-subscription"
&& entry.cli === "droid"
&& entry.authenticated !== false,
);
const hasDroidApiKey = Boolean(process.env.FACTORY_API_KEY?.trim());
if (hasDroidCliAuth || hasDroidApiKey) {
try {
const { path: droidPath } = resolveDroidExecutable({ auth });
const droidModels = await discoverDroidCliModelDescriptors(droidPath);
available = [
...available.filter((descriptor) => !(descriptor.family === "factory" && descriptor.isCliWrapped)),
...droidModels,
];
} catch {
// Droid CLI missing or model discovery failed — omit dynamic Droid list
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return available;
};

Expand Down Expand Up @@ -1129,6 +1162,8 @@ export function createAiIntegrationService(args: {
family = "openai";
} else if (provider === "cursor") {
family = "cursor";
} else if (provider === "droid") {
family = "factory";
} else {
family = "anthropic";
}
Expand Down Expand Up @@ -1203,6 +1238,7 @@ export function createAiIntegrationService(args: {
resetClaudeRuntimeProbeCache();
resetLocalProviderDetectionCache();
clearCursorCliModelsCache();
clearDroidCliModelsCache();
modelListCache.clear();
runtimeHealthVersion = getProviderRuntimeHealthVersion();
}
Expand Down Expand Up @@ -1242,12 +1278,14 @@ export function createAiIntegrationService(args: {
claude: providerConnections.claude.runtimeAvailable,
codex: providerConnections.codex.runtimeAvailable,
cursor: providerConnections.cursor.runtimeAvailable,
droid: providerConnections.droid.runtimeAvailable,
};
const runtimeFilteredAvailable = available.filter((descriptor) => {
if (!descriptor.isCliWrapped) return true;
if (descriptor.family === "anthropic") return providerConnections.claude.runtimeAvailable;
if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable;
if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable;
if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable;
return true;
});

Expand Down Expand Up @@ -1309,6 +1347,7 @@ export function createAiIntegrationService(args: {
claude: availability.claude ? await listModels("claude") : [],
codex: availability.codex ? await listModels("codex") : [],
cursor: availability.cursor ? await listModels("cursor") : [],
droid: availability.droid ? await listModels("droid") : [],
},
detectedAuth: redactDetectedAuth(auth, cliStatuses),
providerConnections,
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/services/ai/authDetector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
}

vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");

Check warning on line 39 in apps/desktop/src/main/services/ai/authDetector.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
return {
...actual,
spawn: (...args: unknown[]) => spawnMock(...args),
Expand All @@ -49,9 +49,9 @@
}));

// Import AFTER mocks are set up — must re-import to reset the module-level cache.
let detectAllAuth: typeof import("./authDetector").detectAllAuth;

Check warning on line 52 in apps/desktop/src/main/services/ai/authDetector.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
let detectCliAuthStatuses: typeof import("./authDetector").detectCliAuthStatuses;

Check warning on line 53 in apps/desktop/src/main/services/ai/authDetector.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
let verifyProviderApiKey: typeof import("./authDetector").verifyProviderApiKey;

Check warning on line 54 in apps/desktop/src/main/services/ai/authDetector.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
const originalPlatform = process.platform;

function setPlatform(value: NodeJS.Platform): void {
Expand Down Expand Up @@ -219,6 +219,53 @@
);
});

it("treats droid exec list-tools as a valid authenticated probe", async () => {
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-"));
process.env.HOME = tempHomeDir;
// Create a fake droid binary in a known bin dir so resolveDroidExecutable
// (which uses fs.statSync against real paths) finds it without falling
// through to the real CI PATH.
const droidBinDir = path.join(tempHomeDir, ".local", "bin");
fs.mkdirSync(droidBinDir, { recursive: true });
const fakeDroidPath = path.join(droidBinDir, "droid");
fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
// Strip PATH so resolveExecutableFromKnownLocations skips real binaries
// on the CI runner and uses the temp home's known dirs.
process.env.PATH = "";

spawnMock.mockImplementation((command: string, args: string[] = []) => {
if (args[0] === "--version") {
if (command === "droid" || command.endsWith("/droid")) return fakeChild({ status: 0, stdout: "0.70.0\n" });
return fakeError();
}
if (command === "which") {
if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` });
return fakeChild({ status: 1 });
}
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "exec" && args[1] === "--list-tools") {
return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" });
}
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "account") {
return fakeChild({ status: 1, stderr: "unknown command 'account'\n" });
}
if ((command === "droid" || command.endsWith("/droid")) && args[0] === "whoami") {
return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" });
}
return fakeChild({ status: 1 });
});

const statuses = await detectCliAuthStatuses();
const droid = statuses.find((entry) => entry.cli === "droid");

expect(droid).toEqual({
cli: "droid",
installed: true,
path: fakeDroidPath,
authenticated: true,
verified: true,
});
});

it("does not report openai-compatible local providers when no models are loaded", async () => {
vi.stubGlobal(
"fetch",
Expand Down
Loading
Loading