diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e678e12cb9..d5721107c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,10 @@ jobs: packages/chat-ui/node_modules key: nm-v3-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/webhook-relay/package-lock.json','apps/push-relay/package-lock.json','packages/sdk/package-lock.json','packages/chat-ui/package-lock.json') }} - run: cd apps/desktop && npm run typecheck + env: + # tsc on the desktop codebase sits near Node's default ~4GB heap; + # the lint script already needs --max-old-space-size=8192. + NODE_OPTIONS: --max-old-space-size=8192 typecheck-ade-cli: needs: install diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 56220d4cf6..2c565e9536 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -404,7 +404,7 @@ const TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "pi", "qwen", "kimi", "grok", "copilot", "shell"] }, + provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "pi", "qwen", "kimi", "grok", "copilot", "devin", "shell"] }, permissionMode: { type: "string", enum: [...AGENT_CHAT_PERMISSION_MODE_VALUES], default: "default" }, droidPermissionMode: { type: "string", enum: [...AGENT_CHAT_DROID_PERMISSION_MODE_VALUES] }, title: { type: "string" }, diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 63cc5caf14..01ad1bea65 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -113,6 +113,8 @@ import { createLinearAccessTokenGetter, createLinearIngressService } from "../.. import { buildLinearAutomationDispatches } from "../../desktop/src/main/services/automations/linearAutomationDispatch"; import { createCursorCloudIngressService } from "../../desktop/src/main/services/automations/cursorCloudIngressService"; import { createCursorCloudFleetService } from "../../desktop/src/main/services/chat/cursorCloudFleetService"; +import { createCloudAgentsServiceFromHost, type CloudAgentsService } from "../../desktop/src/main/services/chat/cloudAgentsService"; +import { resolveDevinCloudBinary } from "../../desktop/src/main/services/chat/devinCloudBinary"; import { buildCursorCloudAutomationDispatches } from "../../desktop/src/main/services/automations/cursorCloudAutomationDispatch"; import { openCursorCloudCredentialStore } from "../../desktop/src/main/services/chat/cursorCloudCreateOptions"; import { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; @@ -389,6 +391,7 @@ export type AdeRuntime = { agentChatService?: ReturnType | null; chatLaunchService?: ChatLaunchService | null; cursorCloudFleetService?: ReturnType | null; + cloudAgentsService?: CloudAgentsService | null; prService?: ReturnType; prSummaryService?: ReturnType | null; fileService?: ReturnType | null; @@ -2316,6 +2319,17 @@ export async function createAdeRuntime(args: { return { state: status.state, lastEventAt: status.lastEventAt }; }, }); + const cloudAgentsService = createCloudAgentsServiceFromHost({ + projectRoot, + logger, + laneService, + getAgentChatService: () => agentChatService ?? null, + cursorFleet: cursorCloudFleetService, + archiveCursorAgent: (agentId) => aiIntegrationService.archiveCursorCloudAgent(agentId), + unarchiveCursorAgent: (agentId) => aiIntegrationService.unarchiveCursorCloudAgent(agentId), + cursorCreateRun: (args) => aiIntegrationService.createCursorCloudRun({ ...args, workOnCurrentBranch: true }), + resolveDevinBinary: resolveDevinCloudBinary, + }); const configReloadService = createConfigReloadService({ paths: { sharedPath: adeProjectService.paths.sharedConfigPath, @@ -2757,6 +2771,7 @@ export async function createAdeRuntime(args: { agentChatService, chatLaunchService, cursorCloudFleetService, + cloudAgentsService, pushPublisherService, ctoStateService, ctoMemoryService, @@ -2934,6 +2949,7 @@ export async function createAdeRuntime(args: { agentChatService, chatLaunchService, cursorCloudFleetService, + cloudAgentsService, ctoStateService, ctoMemoryService, ctoVoiceCallService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 64203d2b76..32c1c7c458 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3951,7 +3951,7 @@ describe("ADE CLI", () => { "shell", "--print-config", ]), - ).toThrow(/provider must be one of claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, or copilot/); + ).toThrow(/provider must be one of claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, copilot, or devin/); }); it("accepts a mis-cased provider when starting a CLI session", () => { diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index 8410fc3ebf..810ba39a71 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -243,6 +243,22 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ /\bgh[_ ]token\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, ], }, + { + agent: "devin", + displayName: "Devin CLI", + binaryNames: ["devin"], + installCommand: "curl -fsSL https://cli.devin.ai/install.sh | bash", + authCommand: "devin auth login", + missingErrorPatterns: [ + /\bdevin\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+devin\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bdevin\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|no credentials|sign\s*in)\b/i, + /\brun\s+[`'"]?devin\s+auth\s+login[`'"]?/i, + /\bwindsurf[_ ]api[_ ]key\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, ]; function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 370183460c..6792d0f7f7 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -6480,6 +6480,10 @@ describe("CTO-gated Linear sync commands", () => { // both are host state mutations refused to read-only viewers. "ai.cursorCloudResolveLane", "ai.cursorCloudPullIntoLane", + "ai.openCloudAgent", + "ai.stopCloudAgent", + "ai.archiveCloudAgent", + "ai.launchCloudAgent", // Resuming spends a provider turn, so it is a host mutation a // read-only viewer never gets to make. "chat.resumeUsageLimitNow", @@ -6506,6 +6510,10 @@ describe("CTO-gated Linear sync commands", () => { "ai.cursorCloudResolveLane", "ai.cursorCloudPullIntoLane", "ai.cursorCloudStopRun", + "ai.openCloudAgent", + "ai.stopCloudAgent", + "ai.archiveCloudAgent", + "ai.launchCloudAgent", "macDesktop.start", "macDesktop.stop", "macDesktop.takeControl", diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index fead7642b4..b012604191 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -155,6 +155,7 @@ import type { AccountAttestationConfig } from "../account/sharedAccountAuthServi import { verifyClerkAccountAttestation } from "../account/accountAttestationVerifier"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; import type { createCursorCloudFleetService } from "../../../../desktop/src/main/services/chat/cursorCloudFleetService"; +import type { CloudAgentsService } from "../../../../desktop/src/main/services/chat/cloudAgentsService"; import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; @@ -1138,6 +1139,7 @@ type SyncHostServiceArgs = { agentChatService?: ReturnType; chatLaunchService?: ChatLaunchService | null; cursorCloudFleetService?: ReturnType | null; + cloudAgentsService?: CloudAgentsService | null; personalChatScope?: Pick< PersonalChatScopeContract, "call" | "streamEvents" | "transcriptPath" | "isTurnActive" @@ -2403,6 +2405,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { agentChatService: args.agentChatService, chatLaunchService: args.chatLaunchService, cursorCloudFleetService: args.cursorCloudFleetService, + cloudAgentsService: args.cloudAgentsService, personalChatScope: args.personalChatScope, aiIntegrationService: args.aiIntegrationService, accountSettingsStore: args.accountSettingsStore, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 0e924b71bf..8ac7ac76b5 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -291,6 +291,7 @@ import { buildAiSettingsStatus, getUnavailableAiStatus, isDatabaseClosedError } import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; import type { createCursorCloudFleetService } from "../../../../desktop/src/main/services/chat/cursorCloudFleetService"; +import type { CloudAgentsService } from "../../../../desktop/src/main/services/chat/cloudAgentsService"; import { resolveSmartLinkPreview } from "../../../../desktop/src/main/services/chat/smartLinkPreviewService"; import { getSourceFaviconService } from "../../../../desktop/src/main/services/chat/sourceFaviconService"; import { @@ -551,6 +552,7 @@ type SyncRemoteCommandServiceArgs = { agentChatService?: ReturnType; chatLaunchService?: ChatLaunchService | null; cursorCloudFleetService?: ReturnType | null; + cloudAgentsService?: CloudAgentsService | null; personalChatScope?: Pick; ctoStateService?: ReturnType | null; ctoMemoryService?: CtoMemoryService | null; @@ -3397,6 +3399,7 @@ const MODEL_CATALOG_REFRESH_PROVIDERS = new Set): AgentChatModelCatalogArgs { @@ -3907,6 +3910,39 @@ async function unarchiveLaneWithRuntimeSetup( return { ok: true }; } +async function resolveChatCreateArgs( + service: ReturnType, + payload: T, +): Promise { + if (payload.model.trim().length > 0) return payload; + const available = await service.getAvailableModels({ + provider: payload.provider, + // ACP providers are here for the same reason as OpenCode/Pi: their model + // rows are gated on a CLI auth pass, and `activateRuntime` refreshes that + // pass. It does not spawn an agent for them. + ...( + payload.provider === "opencode" + || payload.provider === "pi" + || payload.provider === "qwen" + || payload.provider === "kimi" + || payload.provider === "grok" + || payload.provider === "copilot" + || payload.provider === "devin" + ? { activateRuntime: true } + : {} + ), + }); + const chosen = available[0]; + if (!chosen) { + throw new Error(`No configured ${payload.provider} chat model is available on the host.`); + } + return { + ...payload, + model: chosen.id, + ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), + }; +} + function sessionStatusBucket(argsIn: { status: string; runtimeState?: string | null; @@ -6529,6 +6565,61 @@ function registerMiscRemoteCommands({ args, register }: RemoteCommandRegistratio requireString(payload.agentId, "ai.cursorCloudStopRun requires agentId."), ); }); + + // Cloud agents (Devin Cloud and Cursor Cloud): one contract for both + // providers, the same one the desktop reaches over `ade.cloudAgents.*`. The + // list is read-only; open can create a lane and a chat, and stop, archive + // and launch act on the provider, so those are refused for viewers and run + // now rather than queue. + const cloudAgents = () => requireService(args.cloudAgentsService, "Cloud agents are not available."); + register("ai.listCloudAgents", { viewerAllowed: true }, async (payload) => + cloudAgents().list({ + provider: requireCloudAgentProvider(payload.provider, "ai.listCloudAgents"), + ...(payload.force === true ? { force: true } : {}), + })); + register("ai.openCloudAgent", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => + cloudAgents().open({ + provider: requireCloudAgentProvider(payload.provider, "ai.openCloudAgent"), + id: requireCloudAgentId(payload.id, "ai.openCloudAgent"), + })); + register("ai.stopCloudAgent", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => + cloudAgents().stop({ + provider: requireCloudAgentProvider(payload.provider, "ai.stopCloudAgent"), + id: requireCloudAgentId(payload.id, "ai.stopCloudAgent"), + })); + register("ai.archiveCloudAgent", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => + cloudAgents().archive({ + provider: requireCloudAgentProvider(payload.provider, "ai.archiveCloudAgent"), + id: requireCloudAgentId(payload.id, "ai.archiveCloudAgent"), + archived: payload.archived !== false, + })); + register("ai.launchCloudAgent", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + const prompt = requireString(payload.prompt, "ai.launchCloudAgent requires prompt."); + if (prompt.length > 100_000) throw new Error("ai.launchCloudAgent prompt is too long."); + const shortOptional = (value: unknown, name: string): string | null => { + if (typeof value !== "string" || !value.trim()) return null; + if (value.length > 256) throw new Error(`ai.launchCloudAgent ${name} is too long.`); + return value.trim(); + }; + return cloudAgents().launch({ + provider: requireCloudAgentProvider(payload.provider, "ai.launchCloudAgent"), + prompt, + model: shortOptional(payload.model, "model"), + platform: shortOptional(payload.platform, "platform"), + laneId: shortOptional(payload.laneId, "laneId"), + }); + }); +} + +function requireCloudAgentProvider(value: unknown, action: string): "devin" | "cursor" { + if (value === "devin" || value === "cursor") return value; + throw new Error(`${action} requires provider 'devin' or 'cursor'.`); +} + +function requireCloudAgentId(value: unknown, action: string): string { + const id = requireString(value, `${action} requires id.`).trim(); + if (id.length > 256) throw new Error(`${action} id is too long.`); + return id; } export type PrRefreshSnapshotScope = "all" | "active" | "none"; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 0dcf5e5a7e..815c28ca25 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -24,6 +24,7 @@ import { import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; import type { createCursorCloudFleetService } from "../../../../desktop/src/main/services/chat/cursorCloudFleetService"; +import type { CloudAgentsService } from "../../../../desktop/src/main/services/chat/cloudAgentsService"; import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; @@ -155,6 +156,7 @@ type SyncServiceArgs = { agentChatService: ReturnType; chatLaunchService?: ChatLaunchService | null; cursorCloudFleetService?: ReturnType | null; + cloudAgentsService?: CloudAgentsService | null; personalChatScope?: PersonalChatScopeContract; /** Brain→push-relay publisher; threaded to the runtime remote-command service. */ pushPublisherService?: PushPublisherService | null; @@ -803,6 +805,7 @@ export function createSyncService(args: SyncServiceArgs) { agentChatService: args.agentChatService, chatLaunchService: args.chatLaunchService, cursorCloudFleetService: args.cursorCloudFleetService, + cloudAgentsService: args.cloudAgentsService, personalChatScope: args.personalChatScope, pushPublisherService: args.pushPublisherService, ctoStateService: args.ctoStateService, @@ -954,6 +957,7 @@ export function createSyncService(args: SyncServiceArgs) { agentChatService: args.agentChatService, chatLaunchService: args.chatLaunchService, cursorCloudFleetService: args.cursorCloudFleetService, + cloudAgentsService: args.cloudAgentsService, aiIntegrationService: args.aiIntegrationService, accountSettingsStore: args.accountSettingsStore, pushPublisherService: args.pushPublisherService, diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index ac76835fdc..e954865ec0 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -460,6 +460,7 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "kimi-chat", "grok-chat", "copilot-chat", + "devin-chat", ]); const TRACKED_CLI_PROVIDERS = new Set([ @@ -555,7 +556,7 @@ export async function signalTerminal( /** Provider CLIs the TUI can launch as tracked terminal sessions. */ export type CliTerminalProvider = Extract< AdeCodeProvider, - "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "qwen" | "kimi" | "grok" | "copilot" + "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | "devin" >; export type StartCliTerminalSessionResult = { diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts index df27d683b0..657b983765 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -40,6 +40,7 @@ describe("buildModelPickerLayout", () => { "claude", "codex", "cursor", + "devin", "opencode", "pi", "copilot", diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 8730732aa5..9d0675234a 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -31,16 +31,17 @@ export const PROVIDER_ORDER: readonly AdeCodeProvider[] = MODEL_PICKER_PROVIDER_ const RAIL_PROVIDER_ORDER: readonly AdeCodeProvider[] = PROVIDER_ORDER; /** - * The four ACP providers. They report through the same optional + * The ACP providers. They report through the same optional * `availableProviders` / `providerConnections` / `models` slots, so one list * drives the greying arm instead of four copies of it. Mirrors * `ACP_PICKER_FAMILIES` in desktop's useProviderAuthStatus. */ -const ACP_PROVIDERS: readonly Extract[] = [ +const ACP_PROVIDERS: readonly Extract[] = [ "qwen", "kimi", "grok", "copilot", + "devin", ]; function isAcpProvider(provider: AdeCodeProvider): provider is (typeof ACP_PROVIDERS)[number] { @@ -168,6 +169,7 @@ const PROVIDER_BY_CATALOG_GROUP: Record = { kimi: "kimi", grok: "grok", copilot: "copilot", + devin: "devin", opencode: "opencode", ollama: "ollama", lmstudio: "lmstudio", diff --git a/apps/ade-cli/src/tuiClient/providerMetadata.ts b/apps/ade-cli/src/tuiClient/providerMetadata.ts index 57999cfbbc..b0698208e8 100644 --- a/apps/ade-cli/src/tuiClient/providerMetadata.ts +++ b/apps/ade-cli/src/tuiClient/providerMetadata.ts @@ -14,6 +14,7 @@ const TUI_PROVIDER_LABELS: Record = { droid: "Droid", kimi: "Kimi", qwen: "Qwen", + devin: "Devin", ollama: "Ollama", lmstudio: "LM Studio", }; @@ -34,6 +35,7 @@ const PROVIDER_FAMILY_LABELS: Record = { kimi: "Moonshot", grok: "xAI", copilot: "GitHub Copilot", + devin: "Devin", ollama: "Ollama", lmstudio: "LM Studio", }; @@ -57,6 +59,7 @@ export const PROVIDER_TOKEN_LABELS: Record = { droid: "Droid", factory: "Droid", cursor: "Cursor", + devin: "Devin", qwen: "Qwen", copilot: "GitHub Copilot", githubcopilot: "GitHub Copilot", @@ -141,6 +144,7 @@ const REFRESH_PROVIDERS: Record = { kimi: { glyph: "◐", wordmark: "Kimi", color: KIMI, label: "Kimi" }, grok: { glyph: "✧", wordmark: "Grok", color: GROK, label: "Grok" }, copilot: { glyph: "⌬", wordmark: "Copilot", color: COPILOT, label: "GitHub Copilot" }, + devin: { glyph: "◆", wordmark: "Devin", color: DEVIN, label: "Devin" }, ollama: { glyph: "◕", wordmark: "Ollama", color: OLLAMA, label: "Ollama" }, lmstudio: { glyph: "≋", wordmark: "LM Studio", color: LMSTUDIO, label: "LM Studio" }, }; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index d6053ec17a..70a0d6aba9 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -107,6 +107,7 @@ export type AdeCodeProvider = AgentChatProvider, "codex" | "claude" | "opencode" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" > + | "devin" | "ollama" | "lmstudio"; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 28d9aff938..e8599190ba 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -326,6 +326,8 @@ import { createLinearAccessTokenGetter, createLinearIngressService } from "./ser import { buildLinearAutomationDispatches } from "./services/automations/linearAutomationDispatch"; import { createCursorCloudIngressService } from "./services/automations/cursorCloudIngressService"; import { createCursorCloudFleetService } from "./services/chat/cursorCloudFleetService"; +import { createCloudAgentsServiceFromHost } from "./services/chat/cloudAgentsService"; +import { resolveDevinCloudBinary } from "./services/chat/devinCloudBinary"; import { buildCursorCloudAutomationDispatches } from "./services/automations/cursorCloudAutomationDispatch"; import { openCursorCloudCredentialStore } from "./services/chat/cursorCloudCreateOptions"; import { createGithubPollingService } from "./services/automations/githubPollingService"; @@ -4579,6 +4581,18 @@ app.whenReady().then(async () => { return { state: status.state, lastEventAt: status.lastEventAt }; }, }); + + const cloudAgentsService = createCloudAgentsServiceFromHost({ + projectRoot, + logger, + laneService, + getAgentChatService: () => agentChatService, + cursorFleet: cursorCloudFleetService, + archiveCursorAgent: (agentId) => aiIntegrationService.archiveCursorCloudAgent(agentId), + unarchiveCursorAgent: (agentId) => aiIntegrationService.unarchiveCursorCloudAgent(agentId), + cursorCreateRun: (args) => aiIntegrationService.createCursorCloudRun({ ...args, workOnCurrentBranch: true }), + resolveDevinBinary: resolveDevinCloudBinary, + }); automationService?.setCursorCloudIngressAvailable(() => { const status = cursorCloudIngressService.getStatus(); return status.state === "ready" || Boolean(status.webhookId && !status.lastError); @@ -5018,6 +5032,7 @@ app.whenReady().then(async () => { getAppleRemoteBitrateKbpsCap: () => DEFAULT_APPLE_REMOTE_BITRATE_KBPS, agentChatService, cursorCloudFleetService, + cloudAgentsService, ctoStateService, linearCredentialService, getLinearIssueTracker: () => linearIssueTracker, @@ -5507,6 +5522,7 @@ app.whenReady().then(async () => { linearIngressService, cursorCloudIngressService, cursorCloudFleetService, + cloudAgentsService, feedbackReporterService, usageTrackingService, storageInsightsService, diff --git a/apps/desktop/src/main/services/adeActions/actionArgs.ts b/apps/desktop/src/main/services/adeActions/actionArgs.ts index 0a06b19d55..e31256b40c 100644 --- a/apps/desktop/src/main/services/adeActions/actionArgs.ts +++ b/apps/desktop/src/main/services/adeActions/actionArgs.ts @@ -156,3 +156,8 @@ export function readStringActionArg(value: unknown, field: string): string { } return requireNonEmptyString(asActionRecord(value)[field], field); } + +export function requireCloudAgentProvider(value: unknown): "devin" | "cursor" { + if (value === "devin" || value === "cursor") return value; + throw new Error("provider must be 'devin' or 'cursor'."); +} diff --git a/apps/desktop/src/main/services/adeActions/actionPolicy.ts b/apps/desktop/src/main/services/adeActions/actionPolicy.ts index 371302109f..c432a7f7fb 100644 --- a/apps/desktop/src/main/services/adeActions/actionPolicy.ts +++ b/apps/desktop/src/main/services/adeActions/actionPolicy.ts @@ -767,6 +767,11 @@ export const ADE_ACTION_ALLOWLIST: Partial { const provider = args?.provider; - if (!isAcpChatProvider(provider)) throw new Error("provider must be one of qwen, kimi, grok, copilot."); + if (!isAcpChatProvider(provider)) throw new Error("provider must be one of qwen, kimi, grok, copilot, devin."); return collectAcpProviderDiagnostics({ provider, cwd: runtime.projectRoot, @@ -2101,7 +2102,7 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { }, acpProviderUpdate: (args?: { provider?: string }) => { const provider = args?.provider; - if (!isAcpChatProvider(provider)) throw new Error("provider must be one of qwen, kimi, grok, copilot."); + if (!isAcpChatProvider(provider)) throw new Error("provider must be one of qwen, kimi, grok, copilot, devin."); return runAcpProviderUpdate({ provider, cwd: runtime.projectRoot }); }, piLoginProviders: () => listPiLoginProviders(), @@ -2366,6 +2367,35 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { requireService(runtime.cursorCloudFleetService, "Cursor Cloud fleet not available.").stopAgentRun( requireNonEmptyString(args?.agentId, "agentId"), ), + listCloudAgents: (args?: { provider?: string; force?: boolean }) => + requireService(runtime.cloudAgentsService, "Cloud agents are not available.").list({ + provider: requireCloudAgentProvider(args?.provider), + ...(args?.force !== undefined ? { force: args.force === true } : {}), + }), + openCloudAgent: (args?: { provider?: string; id?: string }) => + requireService(runtime.cloudAgentsService, "Cloud agents are not available.").open({ + provider: requireCloudAgentProvider(args?.provider), + id: requireNonEmptyString(args?.id, "id"), + }), + stopCloudAgent: (args?: { provider?: string; id?: string }) => + requireService(runtime.cloudAgentsService, "Cloud agents are not available.").stop({ + provider: requireCloudAgentProvider(args?.provider), + id: requireNonEmptyString(args?.id, "id"), + }), + launchCloudAgent: (args?: { provider?: string; prompt?: string; model?: string | null; platform?: string | null; laneId?: string | null }) => + requireService(runtime.cloudAgentsService, "Cloud agents are not available.").launch({ + provider: requireCloudAgentProvider(args?.provider), + prompt: requireNonEmptyString(args?.prompt, "prompt"), + model: typeof args?.model === "string" ? args.model : null, + platform: typeof args?.platform === "string" ? args.platform : null, + laneId: typeof args?.laneId === "string" && args.laneId.trim() ? args.laneId.trim() : null, + }), + archiveCloudAgent: (args?: { provider?: string; id?: string; archived?: boolean }) => + requireService(runtime.cloudAgentsService, "Cloud agents are not available.").archive({ + provider: requireCloudAgentProvider(args?.provider), + id: requireNonEmptyString(args?.id, "id"), + archived: args?.archived !== false, + }), }; } diff --git a/apps/desktop/src/main/services/agentTools/agentToolsService.ts b/apps/desktop/src/main/services/agentTools/agentToolsService.ts index 6670091495..71224e9e51 100644 --- a/apps/desktop/src/main/services/agentTools/agentToolsService.ts +++ b/apps/desktop/src/main/services/agentTools/agentToolsService.ts @@ -8,6 +8,7 @@ const TOOL_SPECS: ToolSpec[] = [ { id: "claude", label: "Claude Code", command: "claude", versionArgs: ["--version"] }, { id: "codex", label: "Codex", command: "codex", versionArgs: ["--version"] }, { id: "cursor", label: "Cursor", command: "cursor", versionArgs: ["--version"] }, + { id: "devin", label: "Devin", command: "devin", versionArgs: ["--version"] }, { id: "aider", label: "Aider", command: "aider", versionArgs: ["--version"] }, { id: "continue", label: "Continue", command: "continue", versionArgs: ["--version"] } ]; diff --git a/apps/desktop/src/main/services/ai/acpAuthProbe.ts b/apps/desktop/src/main/services/ai/acpAuthProbe.ts index 3568acd8f2..525c763c21 100644 --- a/apps/desktop/src/main/services/ai/acpAuthProbe.ts +++ b/apps/desktop/src/main/services/ai/acpAuthProbe.ts @@ -80,6 +80,10 @@ export function acpProbeConfigHome( return copilotConfigHome({ env }); case "grok": return grokConfigHome({ env }); + // Devin reads `~/.config/devin` (%APPDATA%\devin on Windows) and honors + // no override env var, so ADE sets nothing — same posture as Grok. + case "devin": + return null; } } diff --git a/apps/desktop/src/main/services/ai/acpExecutables.ts b/apps/desktop/src/main/services/ai/acpExecutables.ts index 0f4852e064..1f2c8ce904 100644 --- a/apps/desktop/src/main/services/ai/acpExecutables.ts +++ b/apps/desktop/src/main/services/ai/acpExecutables.ts @@ -32,6 +32,7 @@ const ACP_EXECUTABLE_ENV_KEYS: Record = { kimi: ["KIMI_EXECUTABLE", "KIMI_CODE_EXECUTABLE"], grok: ["GROK_EXECUTABLE", "XAI_GROK_EXECUTABLE"], copilot: ["COPILOT_EXECUTABLE", "GITHUB_COPILOT_EXECUTABLE"], + devin: ["DEVIN_EXECUTABLE", "DEVIN_CLI_EXECUTABLE"], }; /** The command name each provider installs. */ @@ -40,6 +41,7 @@ const ACP_EXECUTABLE_COMMANDS: Record = { kimi: "kimi", grok: "grok", copilot: "copilot", + devin: "devin", }; function findAcpAuthPath(provider: AcpChatProvider, auth?: DetectedAuth[]): string | null { diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index b5f701b024..5d69d85ff4 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -46,6 +46,7 @@ import { } from "../../../shared/modelRegistry"; import { disabledProviderSet } from "../../../shared/providerEnablement"; import { presetSourceLabel } from "../../../shared/harnessPresets"; +import { getCachedDevinModels, warmDevinModels } from "./devinModelsDiscovery"; import { readHarnessPresetsOrEmpty } from "../chat/harnessPresetSettings"; import { getMachineProviderInstanceStore } from "../../../../../ade-cli/src/services/providerInstances/providerInstanceStore"; import { resolveMachineAdeDir } from "../../../../../ade-cli/src/services/projects/machineLayout"; @@ -81,6 +82,7 @@ import { parseStructuredOutput } from "./utils"; import { deleteApiKey as deleteStoredApiKey, getAllApiKeys, + getApiKey as getStoredApiKey, getApiKeyStoreStatus, listStoredProviders, storeApiKey as storeStoredApiKey, @@ -162,6 +164,7 @@ export type AiIntegrationStatus = { kimi?: boolean; grok?: boolean; copilot?: boolean; + devin?: boolean; }; models: { claude: AgentModelDescriptor[]; @@ -172,10 +175,11 @@ export type AiIntegrationStatus = { kimi?: AgentModelDescriptor[]; grok?: AgentModelDescriptor[]; copilot?: AgentModelDescriptor[]; + devin?: AgentModelDescriptor[]; }; detectedAuth?: Array<{ type: "cli-subscription" | "api-key" | "oauth" | "openrouter" | "local"; - cli?: "claude" | "codex" | "cursor" | "droid" | "qwen" | "kimi" | "grok" | "copilot"; + cli?: "claude" | "codex" | "cursor" | "droid" | "qwen" | "kimi" | "grok" | "copilot" | "devin"; provider?: string; source?: "config" | "env" | "store" | "file"; endpointSource?: "auto" | "config"; @@ -1008,6 +1012,7 @@ export const ACP_STATUS_FAMILIES = { kimi: "moonshot", grok: "xai", copilot: "github-copilot", + devin: "devin", } as const; function buildStatusModelLists( @@ -1027,6 +1032,7 @@ function buildStatusModelLists( kimi: availability.kimi ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.kimi) : [], grok: availability.grok ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.grok) : [], copilot: availability.copilot ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.copilot) : [], + devin: availability.devin ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.devin) : [], }; } @@ -1153,6 +1159,7 @@ export function createAiIntegrationService(args: { ["kimi", ACP_STATUS_FAMILIES.kimi], ["grok", ACP_STATUS_FAMILIES.grok], ["copilot", ACP_STATUS_FAMILIES.copilot], + ["devin", ACP_STATUS_FAMILIES.devin], ] as const; for (const [provider, family] of acpModelFamilies) { const health = getProviderRuntimeHealth(provider); @@ -1165,6 +1172,19 @@ export function createAiIntegrationService(args: { !(descriptor.isCliWrapped && descriptor.family === family) ); if (!hasAuth) continue; + if (provider === "devin") { + // `devin models list` is the account's real catalog, ahead of the + // curated family picks. Serve the cached rows; warm once in the + // background when cold so this read never blocks on a subprocess. + const discovered = getCachedDevinModels(); + if (discovered?.length) { + mergeDynamicAcpModelDescriptors("devin", discovered); + } else { + void warmDevinModels().then((rows) => { + if (rows.length) mergeDynamicAcpModelDescriptors("devin", rows); + }); + } + } available.push(...listAcpModelDescriptorsForProvider(provider, { ...(provider === "qwen" && qwenSettings.models.length ? { configuredModelIds: qwenSettings.models.map((model) => model.id) } @@ -2071,7 +2091,7 @@ export function createAiIntegrationService(args: { // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and // populated the cache, so this reads instantly from cache: const cliStatuses = timeSyncPhase("read_cli_auth_cache", () => getCachedCliAuthStatuses()); - const installedAcp = ( ["qwen", "kimi", "grok", "copilot"] as const) + const installedAcp = ( ["qwen", "kimi", "grok", "copilot", "devin"] as const) .filter((provider) => cliStatuses.some((status) => status.cli === provider && status.installed)); // The disk heuristic proves only that a provider left credentials on // disk. A forced Settings refresh must wait for the ACP handshake so @@ -2087,7 +2107,7 @@ export function createAiIntegrationService(args: { })) : {}; const runtimeReadyAcpProviders = new Set(); - for (const provider of ["qwen", "kimi", "grok", "copilot"] as const) { + for (const provider of ["qwen", "kimi", "grok", "copilot", "devin"] as const) { const probe = acpProbeResults[provider]; const health = getProviderRuntimeHealth(provider); if (probe?.state === "ready" || health?.state === "ready") { @@ -2101,7 +2121,7 @@ export function createAiIntegrationService(args: { const cli = cliStatuses.find((status) => status.cli === provider); authForModels.push({ type: "cli-subscription", - cli: provider as "qwen" | "kimi" | "grok" | "copilot", + cli: provider as "qwen" | "kimi" | "grok" | "copilot" | "devin", path: cli?.path ?? provider, authenticated: true, verified: true, @@ -2148,6 +2168,7 @@ export function createAiIntegrationService(args: { kimi: enabled("kimi") && Boolean(providerConnections.kimi?.runtimeAvailable), grok: enabled("grok") && Boolean(providerConnections.grok?.runtimeAvailable), copilot: enabled("copilot") && Boolean(providerConnections.copilot?.runtimeAvailable), + devin: enabled("devin") && Boolean(providerConnections.devin?.runtimeAvailable), }; const runtimeFilteredAvailable = timeSyncPhase("filter_available_models", () => available.filter((descriptor) => { // API/local rows are not owned by any one provider tile (they reach @@ -2162,6 +2183,7 @@ export function createAiIntegrationService(args: { if (descriptor.family === ACP_STATUS_FAMILIES.kimi) return availability.kimi === true; if (descriptor.family === ACP_STATUS_FAMILIES.grok) return availability.grok === true; if (descriptor.family === ACP_STATUS_FAMILIES.copilot) return availability.copilot === true; + if (descriptor.family === ACP_STATUS_FAMILIES.devin) return availability.devin === true; return true; })); @@ -2349,6 +2371,7 @@ export function createAiIntegrationService(args: { listCursorCloudArtifacts, downloadCursorCloudArtifact, + getAvailabilityAsync, resolveModelForTask, getConfiguredFeatureModel, diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index a0b3c3e784..17859ab0f3 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -17,7 +17,7 @@ import type { AiLocalProviderConfigs } from "../../../shared/types"; import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; import { loadQwenUserSettings } from "./qwenUserSettings"; -import { grokConfigHome } from "../shared/providerConfigHomes"; +import { devinCredentialFiles, grokConfigHome } from "../shared/providerConfigHomes"; import { reportProviderRuntimeAuthFailure, reportProviderRuntimeFailure, @@ -33,13 +33,14 @@ type CliName = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; /** * CLIs ADE reaches over the Agent Client Protocol. Their auth state is read * from disk, not from a spawn: see `inspectAcpCliCredentials`. */ -const ACP_CLI_NAMES = ["qwen", "kimi", "grok", "copilot"] as const; +const ACP_CLI_NAMES = ["qwen", "kimi", "grok", "copilot", "devin"] as const; type AcpCliName = (typeof ACP_CLI_NAMES)[number]; function isAcpCliName(cli: CliName): cli is AcpCliName { @@ -119,6 +120,9 @@ const CLI_AUTH_PROBES: Record = { kimi: [], grok: [], copilot: [], + // `devin auth status` is a real, non-interactive subcommand; the + // protocol-level handshake is still authoritative. + devin: [["auth", "status"], ["--version"]], }; /** @@ -164,6 +168,28 @@ async function inspectAcpCliCredentials( return { authenticated: await fileExists(path.join(root, "config.toml")), verified: false }; } + if (cli === "devin") { + // Devin reads WINDSURF_API_KEY first, then the login `devin auth login` + // leaves in `credentials.toml` under the XDG data dir — the same list + // the ACP account reader checks. A Windsurf key saved on the Devin + // provider page counts too: the ACP spawn exports it to the CLI. + if (env.WINDSURF_API_KEY?.trim()) return { authenticated: true, verified: false }; + for (const file of devinCredentialFiles({ env, homeDir: home })) { + if (await fileExists(file)) { + return { authenticated: true, verified: false }; + } + } + try { + const { getApiCredentialKey } = await import("./apiKeyStore"); + if (getApiCredentialKey("devin-cli")?.trim()) { + return { authenticated: true, verified: false }; + } + } catch { + // The store is Electron-main scoped; probes elsewhere keep disk auth. + } + return { authenticated: false, verified: false }; + } + // Copilot's durable login is normally keychain/session-state backed, not a // reliable JSON field in config.json. Environment tokens are still a useful // presence hint; the ACP handshake remains the authority. Keep the legacy diff --git a/apps/desktop/src/main/services/ai/devinModelsDiscovery.test.ts b/apps/desktop/src/main/services/ai/devinModelsDiscovery.test.ts new file mode 100644 index 0000000000..f1331a6068 --- /dev/null +++ b/apps/desktop/src/main/services/ai/devinModelsDiscovery.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { parseDevinModels } from "./devinModelsDiscovery"; + +const FIXTURE = JSON.stringify({ + families: [ + { + family_label: "Adaptive", + slug: "adaptive", + aliases: [], + variants: [ + { model_uid: "adaptive", label: "Adaptive", max_context_tokens: 200_000, max_output_tokens: 64_000 }, + ], + }, + { + family_label: "SWE-2", + slug: "swe-2", + aliases: ["swe"], + variants: [ + { model_uid: "swe-2-high", label: "SWE-2 High", max_context_tokens: 262_000, max_output_tokens: 128_000 }, + { model_uid: "swe-2-medium", label: "SWE-2 Medium", max_context_tokens: 262_000, max_output_tokens: 128_000 }, + { model_uid: "swe-2-max", label: "SWE-2 Max" }, + ], + }, + { + family_label: "Legacy", + slug: "legacy", + aliases: [], + variants: [{ model_uid: "MODEL_PRIVATE_11", label: "Internal" }], + }, + ], +}); + +describe("parseDevinModels", () => { + it("maps one row per family, preferring the -medium variant", () => { + const models = parseDevinModels(FIXTURE); + expect(models.map((model) => model.id)).toEqual(["devin/adaptive", "devin/swe-2-medium"]); + + const swe = models.find((model) => model.id === "devin/swe-2-medium")!; + expect(swe.providerModelId).toBe("swe-2-medium"); + expect(swe.displayName).toBe("SWE-2 Medium"); + expect(swe.family).toBe("devin"); + expect(swe.isCliWrapped).toBe(true); + expect(swe.contextWindow).toBe(262_000); + expect(swe.maxOutputTokens).toBe(128_000); + // Family label and alias are nameable, the variant uid is the provider id. + expect(swe.aliases).toEqual(expect.arrayContaining(["swe", "swe-2"])); + }); + + it("drops families whose only variants are internal enum names", () => { + const models = parseDevinModels(FIXTURE); + expect(models.some((model) => model.providerModelId.startsWith("MODEL_"))).toBe(false); + expect(models.some((model) => model.id === "devin/legacy")).toBe(false); + }); + + it("returns an empty list for malformed output instead of throwing", () => { + expect(parseDevinModels("not json")).toEqual([]); + expect(parseDevinModels("{}")).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/ai/devinModelsDiscovery.ts b/apps/desktop/src/main/services/ai/devinModelsDiscovery.ts new file mode 100644 index 0000000000..4ec4936403 --- /dev/null +++ b/apps/desktop/src/main/services/ai/devinModelsDiscovery.ts @@ -0,0 +1,166 @@ +/** + * Devin CLI model discovery. + * + * `devin models list --format json` reports every model family the account can + * use, with per-variant context/output limits. ADE's curated Devin rows are + * only durable family picks; this is the live catalog the account actually + * offers, so the picker does not drift behind Cognition's releases. + * + * The response is large (52+ families, hundreds of variants), and the ACP + * dynamic rows cross the sync wire, so this maps to ONE row per family — the + * family's aliases ride the row, and a preferred variant carries the real + * `model_uid` and limits. The registry's own cap still applies downstream. + * + * Discovery is lazy and cached: a status read serves the last catalog and warms + * in the background only when cold, so the picker never blocks on a subprocess. + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + createDynamicAcpModelDescriptor, + type ModelDescriptor, +} from "../../../shared/modelRegistry"; + +const execFileAsync = promisify(execFile); +const DEFAULT_TIMEOUT_MS = 20_000; +const CACHE_TTL_MS = 10 * 60_000; +const MAX_BUFFER_BYTES = 8 * 1024 * 1024; + +type Json = Record; + +function isRecord(value: unknown): value is Json { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +/** + * Internal enum names (`MODEL_PRIVATE_11`, `MODEL_GPT_5_2_LOW`) are not valid + * `--model` tokens and would surface as unusable rows. + */ +function isUsableModelUid(uid: string): boolean { + return !/^MODEL_/i.test(uid); +} + +/** + * Pick the family's representative variant: a `-medium` tier when present (the + * usual default middle), else `-high`, else the first usable variant. + */ +function pickVariant(variants: unknown[]): Json | null { + const usable = variants + .filter(isRecord) + .filter((variant) => { + const uid = readString(variant.model_uid); + return Boolean(uid && isUsableModelUid(uid)); + }); + if (!usable.length) return null; + const uidOf = (variant: Json): string => readString(variant.model_uid) ?? ""; + return usable.find((variant) => /-medium$/i.test(uidOf(variant))) + ?? usable.find((variant) => /-high$/i.test(uidOf(variant))) + ?? usable[0]!; +} + +/** + * Pure parse of `devin models list --format json` stdout into ADE descriptors. + * Returns [] on any malformed input rather than throwing into a status read. + */ +export function parseDevinModels(stdout: string): ModelDescriptor[] { + let root: unknown; + try { + root = JSON.parse(stdout); + } catch { + return []; + } + const families = isRecord(root) && Array.isArray(root.families) ? root.families : []; + const out: ModelDescriptor[] = []; + const seen = new Set(); + for (const family of families) { + if (!isRecord(family)) continue; + const label = readString(family.family_label) ?? readString(family.slug); + const variants = Array.isArray(family.variants) ? family.variants : []; + const chosen = pickVariant(variants); + if (!chosen) continue; + const uid = readString(chosen.model_uid); + if (!uid || !isUsableModelUid(uid) || seen.has(uid)) continue; + seen.add(uid); + const contextWindow = readNumber(chosen.max_context_tokens); + const maxOutputTokens = readNumber(chosen.max_output_tokens); + const descriptor = createDynamicAcpModelDescriptor("devin", uid, { + ...(readString(chosen.label) ? { displayName: readString(chosen.label)! } : label ? { displayName: label } : {}), + ...(contextWindow ? { contextWindow } : {}), + ...(maxOutputTokens ? { maxOutputTokens } : {}), + }); + // The family label and its aliases (`swe`, `opus`, `gpt`, …) resolve to this + // row so a user can name a family without knowing its variant uid. + const aliasRefs = [label, ...(Array.isArray(family.aliases) ? family.aliases : [])] + .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : "")) + .filter((value) => value.length > 0 && value !== uid.toLowerCase()); + if (aliasRefs.length) { + descriptor.aliases = [...new Set([...(descriptor.aliases ?? []), ...aliasRefs])]; + } + out.push(descriptor); + } + return out; +} + +/** Don't re-spawn a failing discovery on every status read. */ +const MIN_RETRY_MS = 30_000; + +let cache: { at: number; models: ModelDescriptor[] } | null = null; +let inflight: Promise | null = null; +let lastAttemptAt = 0; + +/** Last catalog if still fresh, else null — never spawns. */ +export function getCachedDevinModels(): ModelDescriptor[] | null { + return cache && Date.now() - cache.at < CACHE_TTL_MS ? cache.models : null; +} + +/** Run `devin models list --format json` and parse it. Throws on spawn failure. */ +export async function discoverDevinModels(options?: { + binaryPath?: string | null; + timeoutMs?: number; +}): Promise { + const binary = + options?.binaryPath?.trim() + || process.env.DEVIN_EXECUTABLE?.trim() + || process.env.DEVIN_CLI_EXECUTABLE?.trim() + || "devin"; + const { stdout } = await execFileAsync(binary, ["models", "list", "--format", "json"], { + timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: MAX_BUFFER_BYTES, + windowsHide: true, + }); + const models = parseDevinModels(stdout); + cache = { at: Date.now(), models }; + return models; +} + +/** + * Discovery for a status read: returns the cache when warm and otherwise kicks + * one background refresh (deduped), resolving to [] while it is in flight. + */ +export function warmDevinModels(): Promise { + const cached = getCachedDevinModels(); + if (cached) return Promise.resolve(cached); + if (inflight) return inflight; + // A failed attempt (a CLI that errors while authenticated) must not spawn on + // every status read; back off before trying again. + if (Date.now() - lastAttemptAt < MIN_RETRY_MS) return Promise.resolve([]); + lastAttemptAt = Date.now(); + inflight = discoverDevinModels() + .catch(() => [] as ModelDescriptor[]) + .finally(() => { inflight = null; }); + return inflight; +} diff --git a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts index 12f96616c0..0a1174760e 100644 --- a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts +++ b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts @@ -9,7 +9,8 @@ export type ProviderRuntimeHealthProvider = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; export type ProviderRuntimeHealth = { provider: ProviderRuntimeHealthProvider; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/acpAccounts.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/acpAccounts.ts index 1fcfdba137..bb1894e91c 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpDialects/acpAccounts.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/acpAccounts.ts @@ -7,13 +7,13 @@ * The account email is not read here; ADE's quota service fills it. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import type { AgentChatUsageAccount } from "../../../../../shared/types"; import { loopbackOrigin } from "../../../../../shared/remoteLoopbackUrl"; import { parseQwenUserSettings } from "../../../ai/qwenUserSettings"; import { resolveKimiCodeLogin } from "../../../shared/kimiCodeLogin"; -import { grokConfigHome, qwenConfigHome } from "../../../shared/providerConfigHomes"; +import { devinCredentialFiles, grokConfigHome, qwenConfigHome } from "../../../shared/providerConfigHomes"; import { asRecord, toOptionalString } from "../../../shared/utils"; function readText(filePath: string): string | null { @@ -74,6 +74,20 @@ export function readKimiAccount({ env }: { env: NodeJS.ProcessEnv }): AgentChatU return { provider: "kimi", kind: "unknown" }; } +/** + * Devin: `devin auth login` stores the user's Devin login in + * `credentials.toml` under the XDG data dir (`~/.local/share/devin`). Only + * existence is checked — the token stays unread. `WINDSURF_API_KEY` is the + * API-key path. + */ +export function readDevinAccount({ env }: { env: NodeJS.ProcessEnv }): AgentChatUsageAccount { + if (devinCredentialFiles({ env }).some((file) => existsSync(file))) { + return { provider: "devin", kind: "subscription" }; + } + if (hasEnv(env, "WINDSURF_API_KEY")) return { provider: "devin", kind: "api_key" }; + return { provider: "devin", kind: "unknown" }; +} + /** Qwen's auth type for a signed-in Qwen account, as opposed to an API key. */ export const QWEN_OAUTH_AUTH_TYPE = "qwen-oauth"; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts new file mode 100644 index 0000000000..101a0a3b72 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts @@ -0,0 +1,130 @@ +/** + * Devin dialect. `devin acp` — Cognition's Devin CLI speaking Agent Client + * Protocol over stdio. Native Rust binary (brew `devin-cli`, install.sh); + * Windows x86 and arm64 builds exist. + * + * Auth: `devin auth login` stores account credentials (browser OAuth, any + * Devin account — no org required). `devin acp` also honours + * `WINDSURF_API_KEY` and accepts the ACP `authenticate` request at runtime, + * so ADE can drive sign-in in-process like the other providers. + * + * Session ids are the short opaque ids `devin list` shows (e.g. `abc12345`); + * there is no flag to mint one at launch, and `devin -r ` / + * `devin -c` cover CLI resume. The ACP server reports the id on the wire. + * + * Devin's `/handoff` escalates a local chat into a cloud session; ADE's own + * cloud surface covers that path natively, so no dialect wiring is needed + * for it here. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardAcpUsage, + standardClose, + standardLoad, + standardResume, + standardSetConfigOption, + standardSetModel, + transportGatedMcpInjection, + withOptionalEnv, +} from "./shared"; +import { readDevinAccount } from "./acpAccounts"; +import { getApiCredentialKey } from "../../../ai/apiKeyStore"; +import { resolveDevinCliModelForLaunch } from "../../../../../shared/cliLaunch"; + +/** + * A Windsurf API key saved on the Devin provider page files under the + * `devin-cli` credential id — the spawn has to export it or the key a user + * saved never reaches the CLI. An explicit env var wins over the store. + */ +function storedDevinCliApiKey(baseEnv: NodeJS.ProcessEnv): string | null { + if (baseEnv.WINDSURF_API_KEY?.trim()) return null; + try { + return getApiCredentialKey("devin-cli")?.trim() || null; + } catch { + // The store is Electron-main scoped; spawns elsewhere keep env-only auth. + return null; + } +} + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + // `devin acp --model ` sets the default model for every ACP session the + // server opens; the per-session `setModel` request still overrides it. The + // CLI accepts the same fuzzy family names ADE's registry rows carry. + const model = resolveDevinCliModelForLaunch(context.modelId); + const args = ["acp", ...(model ? ["--model", model] : [])]; + return { + command: context.binaryPath, + args, + cwd: context.cwd, + env: withOptionalEnv(context.baseEnv, { + WINDSURF_API_KEY: storedDevinCliApiKey(context.baseEnv), + }), + }; +} + +export const devinDialect = defineAcpDialect({ + providerId: "devin", + displayName: "Devin", + tier: "preview", + binaryNames: ["devin"], + buildSpawnPlan, + + cancelStyle: "request", + poolEnvKeys: ["WINDSURF_API_KEY"], + oneProcessPerSession: false, + advertiseFsCapability: false, + advertiseTerminalCapability: false, + initializeMeta: null, + clientInfo: ADE_CLIENT_INFO, + postSessionNewNotifications: () => [], + includeSlashCommand: () => true, + + sessionIdPersistence: { + assignableAtLaunch: false, + sessionsDirName: null, + idShape: "opaque", + }, + + authProbe: { + // `devin acp` advertises its own authenticate methods; defer to them. + methodId: null, + loginCommand: "devin auth login", + apiKeyEnvVars: ["WINDSURF_API_KEY"], + }, + + degradationNotes: [ + "Devin CLI does not yet expose account Knowledge, Playbooks, or Secrets to local sessions.", + ], + + extensionNotifications: {}, + usage: capability(standardAcpUsage), + // Devin keeps no local usage ledger of its own. + localUsage: capabilityAbsent, + readAccount: readDevinAccount, + // Devin reports context size via `usage_update` but never reports a + // compaction itself, so the host infers one from a sharp drop in `used`. + inferCompaction: true, + usageUpdateAfterTurn: false, + + closeStyle: "close_request", + closeSession: capability(standardClose), + + loadPolicy: "resume_preferred", + resumeSession: capability(standardResume), + loadSession: capability(standardLoad), + + sessionConfig: capability(standardSetConfigOption), + modelSelection: capability(standardSetModel), + mcpInjection: capability(transportGatedMcpInjection), + imagePrompts: capability(inlineImagePrompt), + configOptionIds: ["mode", "model"], +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/devinCloud.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devinCloud.ts new file mode 100644 index 0000000000..c0334d2bf6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devinCloud.ts @@ -0,0 +1,109 @@ +/** + * Devin Cloud dialect. `devin acp --cloud` relays the same Agent Client + * Protocol to Cognition's cloud ACP WebSocket, so every session this dialect + * opens runs on a Devin VM, not on this machine. It rides the CLI's own + * `devin auth login`; no API token is involved. + * + * What differs from the local dialect, all verified against the live relay: + * - `session/new` mints a cloud session (`devin-`, the same entity the + * REST API and app.devin.ai call ``) and offers config options + * `repos`, `devin_version` (the model), `platform` and `persona_slug`. + * - The model is the `devin_version` option. `--model` and `session/set_model` + * belong to the local agent and are ignored here. + * - Only `session/load` rejoins, and it replays the full history, user + * messages included; messages sent from app.devin.ai arrive the same way. + * - There is no branch option. The chat service pins a branch in the prompt. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardAcpUsage, + standardClose, + standardLoad, + standardSetConfigOption, +} from "./shared"; +import { readDevinAccount } from "./acpAccounts"; + +/** Config option ids the cloud relay offers on `session/new`. */ +export const DEVIN_CLOUD_CONFIG_OPTION_IDS = ["repos", "devin_version", "platform"] as const; + +/** The `devin-` prefix the relay puts on ids app.devin.ai shows bare. */ +export function devinCloudAcpSessionId(bareId: string): string { + const id = bareId.trim(); + return id.startsWith("devin-") ? id : `devin-${id}`; +} + +export function devinCloudBareSessionId(acpId: string): string { + return acpId.trim().replace(/^devin-/, ""); +} + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + return { + command: context.binaryPath, + args: ["acp", "--cloud"], + cwd: context.cwd, + env: { ...context.baseEnv }, + }; +} + +export const devinCloudDialect = defineAcpDialect({ + providerId: "devin", + displayName: "Devin Cloud", + tier: "preview", + binaryNames: ["devin"], + buildSpawnPlan, + + cancelStyle: "request", + poolEnvKeys: [], + oneProcessPerSession: false, + advertiseFsCapability: false, + advertiseTerminalCapability: false, + initializeMeta: null, + clientInfo: ADE_CLIENT_INFO, + postSessionNewNotifications: () => [], + includeSlashCommand: () => true, + + sessionIdPersistence: { + assignableAtLaunch: false, + sessionsDirName: null, + idShape: "opaque", + }, + + authProbe: { + methodId: null, + loginCommand: "devin auth login", + apiKeyEnvVars: [], + }, + + degradationNotes: [], + + extensionNotifications: {}, + usage: capability(standardAcpUsage), + localUsage: capabilityAbsent, + readAccount: readDevinAccount, + inferCompaction: false, + usageUpdateAfterTurn: false, + + closeStyle: "close_request", + closeSession: capability(standardClose), + + loadPolicy: "load_only", + resumeSession: capabilityAbsent, + loadSession: capability(standardLoad), + + sessionConfig: capability(standardSetConfigOption), + modelSelection: capabilityAbsent, + mcpInjection: capabilityAbsent, + imagePrompts: capability(inlineImagePrompt), + configOptionIds: [...DEVIN_CLOUD_CONFIG_OPTION_IDS], + modelConfigOptionId: "devin_version", + echoRemoteUserMessages: true, +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts index 72f5835e90..aaab36dd5b 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts @@ -8,6 +8,8 @@ import type { AcpDialect, AcpProviderId } from "../acpHostTypes"; import { copilotDialect } from "./copilot"; +import { devinDialect } from "./devin"; +import { devinCloudDialect } from "./devinCloud"; import { grokDialect } from "./grok"; import { kimiDialect } from "./kimi"; import { qwenDialect } from "./qwen"; @@ -17,13 +19,19 @@ export const ACP_DIALECTS: Record = { kimi: kimiDialect, grok: grokDialect, copilot: copilotDialect, + devin: devinDialect, }; -export function acpDialectFor(providerId: AcpProviderId): AcpDialect { +/** + * The dialect for a provider. `cloud` selects a provider's hosted variant; only + * Devin has one (`devin acp --cloud`), and every other provider ignores it. + */ +export function acpDialectFor(providerId: AcpProviderId, options?: { cloud?: boolean }): AcpDialect { + if (options?.cloud && providerId === "devin") return devinCloudDialect; return ACP_DIALECTS[providerId]; } -export { copilotDialect, grokDialect, kimiDialect, qwenDialect }; +export { copilotDialect, devinCloudDialect, devinDialect, grokDialect, kimiDialect, qwenDialect }; export { COPILOT_CONFIG_OPTION_IDS, COPILOT_NATIVE_MODE_IDS, @@ -57,3 +65,5 @@ export { QWEN_SYSTEM_SETTINGS_PATH_ENV, type QwenSkillDefaultsResult, } from "./qwenSkillDefaults"; + +export { DEVIN_CLOUD_CONFIG_OPTION_IDS, devinCloudAcpSessionId, devinCloudBareSessionId } from "./devinCloud"; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts b/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts index 15047137d3..7ddb94f30b 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts @@ -17,6 +17,7 @@ */ import { hasNonEmptyRecord } from "../../../../shared/agentObservationNormalizers"; +import { stripDevinCloudBranchPin } from "../../../../shared/devinCloud"; import type { AgentChatEvent, AgentChatPlanStep, ChatSourceRef } from "../../../../shared/types"; import { boundChatSourceRefs } from "../../../../shared/chatSources"; import { acpResourceLinkSourceRef, acpToolSourceRefs } from "../chatSourceAdapters"; @@ -93,7 +94,7 @@ export type AcpTranslatorCallbacks = { currentModeId: string | null; }) => void; /** Fired for `session_info_update`. Carries the agent's own session title. */ - onSessionInfo?: (info: { title: string | null; updatedAt: string | null }) => void; + onSessionInfo?: (info: { title: string | null; updatedAt: string | null; meta?: Record | null }) => void; /** Fired for every usage sample the dialect could read. */ onUsage?: (sample: AcpUsageSample) => void; /** @@ -108,6 +109,8 @@ export type AcpEventTranslatorOptions = { readUsage?: ((update: Extract) => AcpUsageSample | null) | null; /** Keeps only the slash commands ADE should offer in its picker. */ includeSlashCommand?: (command: AcpAvailableCommand) => boolean; + /** See `AcpDialectBase.echoRemoteUserMessages`. */ + echoRemoteUserMessages?: boolean; callbacks?: AcpTranslatorCallbacks; }; @@ -124,6 +127,18 @@ export type AcpEventTranslator = { resetTurnState(): void; }; +/** + * A chunk that is a whole message, from an agent that marks them so. Devin's + * relay stamps every event with an id and `streaming: false` when the chunk is + * complete; without an id each such message would run into the previous one. + */ +function completeMessageEventId(update: { _meta?: unknown }): string | null { + const meta = update._meta && typeof update._meta === "object" ? update._meta as Record : null; + if (!meta || meta["cognition.ai/streaming"] !== false) return null; + const id = meta["cognition.ai/eventId"]; + return typeof id === "string" && id.length ? id : null; +} + function textOfContentBlock(block: AcpContentBlock): string { switch (block.type) { case "text": @@ -260,6 +275,7 @@ export function createAcpEventTranslator(options: AcpEventTranslatorOptions = {} let activeThoughtMessageId: string | null = null; const textByMessageId = new Map(); let lastSlashSignature: string | null = null; + const echoedUserMessageIds = new Set(); const withTurn = (event: T): T & { turnId?: string } => (turnId ? { ...event, turnId } : event) as T & { turnId?: string }; @@ -425,13 +441,31 @@ export function createAcpEventTranslator(options: AcpEventTranslatorOptions = {} const translate = (update: AcpSessionUpdate): AgentChatEvent[] => { switch (update.sessionUpdate) { - case "user_message_chunk": - // ADE already owns the user's message. Echoing it would duplicate the - // bubble on every replay. - return []; + case "user_message_chunk": { + // ADE already owns the user's message inside its own turn; echoing it + // would duplicate the bubble. Outside one, a dialect that says so is + // reporting a message ADE never saw (a replayed history, or a message + // sent from the provider's web app), and dropping it loses half the + // conversation. + if (turnId || !options.echoRemoteUserMessages) return []; + // A replayed turn ADE sent carries its branch pin; show what the user wrote. + const text = stripDevinCloudBranchPin(textOfContentBlock(update.content)).trim(); + if (!text.length) return []; + const meta = (update as { _meta?: Record })._meta ?? null; + const eventId = meta && typeof meta["cognition.ai/eventId"] === "string" + ? meta["cognition.ai/eventId"] as string + : null; + const messageId = eventId ?? update.messageId ?? `remote-user-${++syntheticMessageCounter}`; + if (echoedUserMessageIds.has(messageId)) return []; + echoedUserMessageIds.add(messageId); + return [{ type: "user_message", text, messageId, runtime: "cloud" }]; + } case "agent_message_chunk": { - const messageId = messageIdFor("text", update.messageId); + // Devin links its own terminal buffers as `devin:///terminal_contents/…`. + // They resolve only inside Devin, so as chat text they are noise. + if (update.content.type === "resource_link" && /^devin:/i.test(update.content.uri)) return []; + const messageId = messageIdFor("text", update.messageId ?? completeMessageEventId(update)); let text = textOfContentBlock(update.content); if (!text.length) return []; if (acpResourceLinkSourceRef(update.content)) { @@ -448,7 +482,7 @@ export function createAcpEventTranslator(options: AcpEventTranslatorOptions = {} case "agent_thought_chunk": { const text = textOfContentBlock(update.content); if (!text.length) return []; - const messageId = messageIdFor("thought", update.messageId); + const messageId = messageIdFor("thought", update.messageId ?? completeMessageEventId(update)); return [withTurn({ type: "reasoning" as const, text, itemId: messageId })]; } @@ -614,6 +648,7 @@ export function createAcpEventTranslator(options: AcpEventTranslatorOptions = {} options.callbacks?.onSessionInfo?.({ title: update.title ?? null, updatedAt: update.updatedAt ?? null, + meta: update._meta && typeof update._meta === "object" ? update._meta as Record : null, }); return []; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts b/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts index bf3488b9bf..e4e32ad45f 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts @@ -2418,24 +2418,25 @@ describe("run | degrade conformance matrix", () => { | "mcp_injection"; const EXPECTED: Record> = { - capabilities: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, - lifecycle: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, - prompt_stream: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, - permission: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, - cancel: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + capabilities: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + lifecycle: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + prompt_stream: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + permission: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + cancel: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, // Qwen 0.24.0 has no session/close. It degrades to ending its private process. - close_eviction: { qwen: "degrade", kimi: "run", grok: "run", copilot: "run" }, + close_eviction: { qwen: "degrade", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + // Copilot's resume is unverified, so ADE uses session/load instead. - resume: { qwen: "run", kimi: "run", grok: "run", copilot: "degrade" }, - slash_advertise: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + resume: { qwen: "run", kimi: "run", grok: "run", copilot: "degrade", devin: "run" }, + slash_advertise: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, // Kimi's post-turn usage_update and prompt usage are read when they arrive. - usage_fold: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, - mcp_injection: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + usage_fold: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, + mcp_injection: { qwen: "run", kimi: "run", grok: "run", copilot: "run", devin: "run" }, }; it("records the expected outcome for every cell", () => { const cells = Object.values(EXPECTED).flatMap((row) => Object.values(row)); - expect(cells).toHaveLength(40); + expect(cells).toHaveLength(50); }); it.each(ACP_PROVIDER_IDS)("%s matches its declared matrix row", (providerId: AcpProviderId) => { diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts b/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts index fdf0f7a58e..74e9e7f6f5 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts @@ -556,6 +556,21 @@ export type AcpDialectBase = { */ readonly servedModelMismatchNote?: string; + /** + * Config option that carries the model when the dialect selects it through a + * config option rather than `session/set_model`. Defaults to `model`; Devin + * Cloud names it `devin_version`. + */ + readonly modelConfigOptionId?: string; + + /** + * True when user messages the agent reports outside an ADE-dispatched turn + * are real messages ADE has not seen: a replayed history on first attach, or + * a message someone sent from the provider's own web app. Inside an ADE turn + * they are the echo of ADE's own send and stay dropped. + */ + readonly echoRemoteUserMessages?: boolean; + /** Absent when the agent takes no reasoning effort through a config option. */ readonly reasoningEffortOption?: AcpReasoningEffortOption; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts b/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts index 94dc50e2c1..cfaed13a97 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts @@ -77,7 +77,7 @@ export type AcpRuntimeCoordinatorCallbacks = { ) => void; onSessionInfo: ( runtime: AcpRuntimeState | null, - info: { title: string | null; updatedAt: string | null }, + info: { title: string | null; updatedAt: string | null; meta?: Record | null }, ) => void; onProcessExit: ( runtime: AcpRuntimeState | null, @@ -117,6 +117,12 @@ export type CreateAcpRuntimeArgs = { /** Provider-specific value for its declared mode config option. */ nativeModeValue: string; setResumeCommand: (command: string) => void; + /** + * Config values to set on a freshly created session, before the model. Only + * applied on `session/new`: a rejoined session already carries its own (a + * Devin Cloud session's repos are fixed once its VM exists). + */ + initialConfigValues?: ReadonlyArray<{ configId: string; value: string }>; spawnOverride?: OpenAcpSessionArgs["spawnOverride"]; pool?: OpenAcpSessionArgs["pool"]; binarySource: string; @@ -342,13 +348,14 @@ async function applyAcpModel( args: { sessionId: string; logger: Logger }, ): Promise { const { dialect, session } = runtime; + const modelOptionId = dialect.modelConfigOptionId ?? "model"; const modelBehavior = behaviorOf(dialect.modelSelection); const viaConfigOption = !modelBehavior && dialect.sessionConfig.declared - && dialect.configOptionIds.includes("model"); + && dialect.configOptionIds.includes(modelOptionId); if (!modelBehavior && !viaConfigOption) return; - const resolution = resolveAcpConfigValue(runtime.configOptions, "model", modelToken, dialect.modelIdFromAgent); + const resolution = resolveAcpConfigValue(runtime.configOptions, modelOptionId, modelToken, dialect.modelIdFromAgent); if (resolution.kind === "not_offered") { args.logger.warn("agent_chat.acp_model_not_offered", { sessionId: args.sessionId, @@ -366,10 +373,10 @@ async function applyAcpModel( const call = modelBehavior({ sessionId: session.sessionId, modelId: resolution.value }); await session.connection.request(call.method, call.params); } else { - reported = await session.setConfigOption({ configId: "model", value: resolution.value }); + reported = await session.setConfigOption({ configId: modelOptionId, value: resolution.value }); } - recordConfigChange(runtime, "model", resolution.value, reported); - const current = runtime.configOptions.find((option) => option.id === "model")?.value; + recordConfigChange(runtime, modelOptionId, resolution.value, reported); + const current = runtime.configOptions.find((option) => option.id === modelOptionId)?.value; session.noteCurrentModel(typeof current === "string" && current.length ? current : resolution.value); } catch (error) { args.logger.warn("agent_chat.acp_set_model_failed", { @@ -483,6 +490,32 @@ export async function createAcpRuntime( } } } + if (args.initialConfigValues?.length && session.entryPlan.mode === "new" && args.dialect.sessionConfig.declared) { + for (const { configId, value } of args.initialConfigValues) { + if (!value.trim()) continue; + const resolution = resolveAcpConfigValue(runtime.configOptions, configId, value); + if (resolution.kind === "not_offered") { + args.logger.warn("agent_chat.acp_initial_config_not_offered", { + sessionId: args.owner.session.id, + provider: args.provider, + configId, + value, + }); + continue; + } + if (resolution.kind === "advertised" && resolution.current) continue; + try { + recordConfigChange(runtime, configId, resolution.value, await session.setConfigOption({ configId, value: resolution.value })); + } catch (error) { + args.logger.warn("agent_chat.acp_initial_config_failed", { + sessionId: args.owner.session.id, + provider: args.provider, + configId, + error: getErrorMessage(error), + }); + } + } + } if (args.modelToken) { await applyAcpModel(runtime, args.modelToken, { sessionId: args.owner.session.id, logger: args.logger }); } diff --git a/apps/desktop/src/main/services/chat/acpHost/acpSession.ts b/apps/desktop/src/main/services/chat/acpHost/acpSession.ts index 7bbd4cbc71..b44b5710b7 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpSession.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpSession.ts @@ -188,7 +188,7 @@ export type AcpSessionCallbacks = { /** The agent reported session config options or a mode change. */ onConfigOptions?: (snapshot: { options: AcpSessionConfigOption[]; currentModeId: string | null }) => void; /** The agent reported its own session title. */ - onSessionInfo?: (info: { title: string | null; updatedAt: string | null }) => void; + onSessionInfo?: (info: { title: string | null; updatedAt: string | null; meta?: Record | null }) => void; /** The agent process went away. The session is dead. */ onProcessExit?: (detail: { code: number | null; signal: string | null; stderrTail: string }) => void; }; @@ -366,6 +366,7 @@ export async function openAcpSession(args: OpenAcpSessionArgs): Promise dialect.usage.behavior({ usageUpdate: update }), includeSlashCommand: dialect.includeSlashCommand, + ...(dialect.echoRemoteUserMessages ? { echoRemoteUserMessages: true } : {}), callbacks: { ...(callbacks.onSlashCommands ? { onSlashCommands: callbacks.onSlashCommands } : {}), onConfigOptions: (snapshot) => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 97fa64dc09..e5eace486c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -387,6 +387,7 @@ import type { AgentChatCodexSetGoalArgs, AgentChatCodexSetGoalStatusArgs, AgentChatCreateArgs, + AgentChatDevinCloudConfig, AgentChatLaunchArgs, AgentChatContextUsage, AgentChatContextUsageArgs, @@ -1013,6 +1014,17 @@ import { type CursorCloudMirrorRefreshResult, } from "./cursorCloudConversation"; import { createCursorCloudMirrorWatch } from "./cursorCloudMirrorWatch"; +import { + assertDevinCloudChatHasRelay, + buildDevinCloudBranchPin, + DEVIN_CLOUD_DEFAULT_VERSION, + isDevinCloudAcpSession, + normalizeDevinCloudConfig, + normalizeDevinCloudSessionId, +} from "../../../shared/devinCloud"; +import { devinDirectoryStatus } from "./devinCloudDirectory"; +import { syncCloudLaneMirror } from "../lanes/cloudLaneMirror"; +import { devinCloudAcpSessionId, devinCloudBareSessionId } from "./acpHost/acpDialects/devinCloud"; import { acquireDroidSdkConnection, releaseDroidSdkConnection, @@ -1679,6 +1691,13 @@ type PersistedChatState = { acpConfigSnapshot?: AgentChatAcpConfigSnapshot; /** Degradation notes already shown for this chat, so each is emitted once. */ acpDegradationNotesShown?: string[]; + /** + * True while the Devin Cloud relay status owns this chat's needs-you marker. + * Persisted so a restarted host can still clear the marker it raised once + * the remote session stops waiting — without it the row reads Needs you + * forever. + */ + devinCloudAttentionRaised?: boolean; /** * True once ADE told this chat that its ACP agent approves its own writes. * Persisted so the honest-degradation line stays once per chat rather than @@ -1738,6 +1757,12 @@ type PersistedChatState = { cursorRuntime?: AgentChatRuntime; /** First turn id at which the session flipped to cloud (renders the system bubble). */ cursorPromotedTurnId?: string; + /** Durable Devin cloud session id once this session has been linked to cloud. */ + devinSessionId?: string; + /** Default runtime for new turns in this session. Set on promotion. */ + devinRuntime?: AgentChatRuntime; + /** Devin Cloud chat driven over the ACP relay. */ + devinCloud?: AgentChatDevinCloudConfig | null; recentConversationEntries?: PersistedRecentConversationEntry[]; continuitySummary?: string | null; continuitySummaryUpdatedAt?: string | null; @@ -5577,6 +5602,7 @@ const CHAT_SESSION_TOOL_TYPES = [ "kimi-chat", "grok-chat", "copilot-chat", + "devin-chat", ] satisfies TerminalToolType[]; type ChatSessionToolType = (typeof CHAT_SESSION_TOOL_TYPES)[number]; @@ -5606,6 +5632,7 @@ function providerFromToolType(toolType: TerminalToolType | null | undefined): Ag if (toolType === "kimi" || toolType === "kimi-chat") return "kimi"; if (toolType === "grok" || toolType === "grok-chat") return "grok"; if (toolType === "copilot" || toolType === "copilot-chat") return "copilot"; + if (toolType === "devin" || toolType === "devin-chat") return "devin"; return "codex"; } @@ -5619,6 +5646,7 @@ function toolTypeFromProvider(provider: AgentChatProvider): TerminalToolType { if (provider === "kimi") return "kimi-chat"; if (provider === "grok") return "grok-chat"; if (provider === "copilot") return "copilot-chat"; + if (provider === "devin") return "devin-chat"; return "codex-chat"; } @@ -6785,6 +6813,7 @@ function resumeCommandForProvider(provider: AgentChatProvider, sessionId: string if (provider === "kimi") return `chat:kimi:${sessionId}`; if (provider === "grok") return `chat:grok:${sessionId}`; if (provider === "copilot") return `chat:copilot:${sessionId}`; + if (provider === "devin") return `chat:devin:${sessionId}`; return `chat:claude:${sessionId}`; } @@ -16373,6 +16402,25 @@ export function createAgentChatService(args: { : prevPersisted?.acpDegradationNotesShown?.length ? { acpDegradationNotesShown: prevPersisted.acpDegradationNotesShown } : {}), + // Boolean, not a latch — the relay status clears this when Devin stops + // waiting, so a stale true must be rewritten false rather than carried + // forward. Seed the live set first: a persist that runs before the first + // status update would otherwise rewrite a persisted raise to false, and + // the unseeded set could then never clear the surviving marker. + ...(devinCloudAttentionSeeded.has(managed.session.id) + ? {} + : (() => { + devinCloudAttentionSeeded.add(managed.session.id); + if (prevPersisted?.devinCloudAttentionRaised === true) { + devinCloudAttentionRaised.add(managed.session.id); + } + return {}; + })()), + ...(devinCloudAttentionRaised.has(managed.session.id) + ? { devinCloudAttentionRaised: true } + : prevPersisted?.devinCloudAttentionRaised + ? { devinCloudAttentionRaised: false } + : {}), // Latching: once said, always remembered. A live runtime that has not yet // tripped the invariant must not erase a flag an earlier run set. ...(managed.acpSupervisionNoticeShown || prevPersisted?.acpSupervisionNoticeShown @@ -16479,6 +16527,15 @@ export function createAgentChatService(args: { ...(managed.session.cursorPromotedTurnId ? { cursorPromotedTurnId: managed.session.cursorPromotedTurnId } : prevPersisted?.cursorPromotedTurnId ? { cursorPromotedTurnId: prevPersisted.cursorPromotedTurnId } : {}), + ...(managed.session.devinSessionId + ? { devinSessionId: managed.session.devinSessionId } + : prevPersisted?.devinSessionId ? { devinSessionId: prevPersisted.devinSessionId } : {}), + ...(managed.session.devinRuntime + ? { devinRuntime: managed.session.devinRuntime } + : prevPersisted?.devinRuntime ? { devinRuntime: prevPersisted.devinRuntime } : {}), + ...(managed.session.devinCloud + ? { devinCloud: managed.session.devinCloud } + : prevPersisted?.devinCloud ? { devinCloud: prevPersisted.devinCloud } : {}), ...(managed.recentConversationEntries.length ? { recentConversationEntries: managed.recentConversationEntries.map((entry) => ({ @@ -16778,6 +16835,7 @@ export function createAgentChatService(args: { const acpDegradationNotesShown = Array.isArray(record.acpDegradationNotesShown) ? record.acpDegradationNotesShown.filter((note): note is string => typeof note === "string" && note.length > 0) : []; + const devinCloudAttentionRaised = record.devinCloudAttentionRaised === true; const acpSupervisionNoticeShown = record.acpSupervisionNoticeShown === true; if (!laneId || !model) return null; const recentConversationEntries = Array.isArray(record.recentConversationEntries) @@ -16874,6 +16932,14 @@ export function createAgentChatService(args: { const cursorPromotedTurnId = typeof record.cursorPromotedTurnId === "string" && record.cursorPromotedTurnId.trim().length ? record.cursorPromotedTurnId.trim() : undefined; + const devinSessionId = typeof record.devinSessionId === "string" && record.devinSessionId.trim().length + ? record.devinSessionId.trim() + : undefined; + const devinRuntime: AgentChatRuntime | undefined = + record.devinRuntime === "cloud" || record.devinRuntime === "local" + ? (record.devinRuntime as AgentChatRuntime) + : undefined; + const devinCloud = normalizeDevinCloudConfig(record.devinCloud); const codexTerminalTurnIds = Array.isArray(record.codexTerminalTurnIds) ? uniqueNonEmpty( record.codexTerminalTurnIds.map((turnId) => typeof turnId === "string" ? turnId : null), @@ -17003,6 +17069,7 @@ export function createAgentChatService(args: { ...(acpPermissionMode ? { acpPermissionMode } : {}), ...(acpConfigSnapshot ? { acpConfigSnapshot } : {}), ...(acpDegradationNotesShown.length ? { acpDegradationNotesShown } : {}), + ...(devinCloudAttentionRaised ? { devinCloudAttentionRaised } : {}), ...(acpSupervisionNoticeShown ? { acpSupervisionNoticeShown } : {}), ...(instanceId ? { instanceId } : {}), ...(presetId ? { presetId } : {}), @@ -17025,6 +17092,9 @@ export function createAgentChatService(args: { ...(cursorCloudAgentId ? { cursorCloudAgentId } : {}), ...(cursorRuntime ? { cursorRuntime } : {}), ...(cursorPromotedTurnId ? { cursorPromotedTurnId } : {}), + ...(devinSessionId ? { devinSessionId } : {}), + ...(devinRuntime ? { devinRuntime } : {}), + ...(devinCloud ? { devinCloud } : {}), ...(approvalOverrides?.length ? { approvalOverrides } : {}), ...(pendingSteers?.length ? { pendingSteers } : {}), ...(asyncQuestions?.length ? { asyncQuestions } : {}), @@ -17238,7 +17308,8 @@ export function createAgentChatService(args: { case "qwen": case "kimi": case "grok": - case "copilot": return { acpSessionId: candidate.pointer }; + case "copilot": + case "devin": return { acpSessionId: candidate.pointer }; default: return {}; } }; @@ -17816,6 +17887,7 @@ export function createAgentChatService(args: { kimi: "curl -LsSf https://code.kimi.com/kimi-code/install.sh | bash", grok: "npm install -g @xai-official/grok@1.0.34", copilot: `npm install -g ${COPILOT_NPM_PACKAGE_SPEC}`, + devin: "curl -fsSL https://cli.devin.ai/install.sh | bash", }; /** @@ -22777,6 +22849,9 @@ export function createAgentChatService(args: { ...(persisted?.cursorCloudAgentId ? { cursorCloudAgentId: persisted.cursorCloudAgentId } : {}), ...(persisted?.cursorRuntime ? { cursorRuntime: persisted.cursorRuntime } : {}), ...(persisted?.cursorPromotedTurnId ? { cursorPromotedTurnId: persisted.cursorPromotedTurnId } : {}), + ...(persisted?.devinSessionId ? { devinSessionId: persisted.devinSessionId } : {}), + ...(persisted?.devinRuntime ? { devinRuntime: persisted.devinRuntime } : {}), + ...(persisted?.devinCloud ? { devinCloud: persisted.devinCloud } : {}), ...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}), ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), ...(persisted?.surface ? { surface: persisted.surface } : {}), @@ -28633,6 +28708,9 @@ export function createAgentChatService(args: { case "kimi": return kimiCodeConfigHome({ env }); case "copilot": return copilotConfigHome({ env }); case "grok": return grokConfigHome({ env }); + // Devin reads `~/.config/devin` (%APPDATA%\devin on Windows) and honors + // no override env var, so ADE sets nothing. + case "devin": return null; } }; @@ -28922,7 +29000,10 @@ export function createAgentChatService(args: { if (!isAcpChatProvider(provider)) { throw new Error(`Session '${managed.session.id}' is not an ACP chat.`); } - const dialect = acpDialectFor(provider); + // A Devin Cloud chat rides `devin acp --cloud`: same host, the cloud + // dialect. Everything below is shared. + const devinCloud = isDevinCloudAcpSession(managed.session) ? managed.session.devinCloud ?? null : null; + const dialect = acpDialectFor(provider, { cloud: devinCloud !== null }); // Daemon-hosted chats fetch the browser capability from the desktop; in // Electron main this is a no-op and the launch stays synchronous. const browserCapabilityReady = prepareBrowserActorCapability(managed); @@ -28970,7 +29051,9 @@ export function createAgentChatService(args: { }); } const permissionMode = resolveAcpPermissionMode(managed.session); - const modelToken = acpModelTokenFor(managed.session); + const modelToken = devinCloud + ? devinCloud.version ?? DEVIN_CLOUD_DEFAULT_VERSION + : acpModelTokenFor(managed.session); const spawnPlan = dialect.buildSpawnPlan({ binaryPath: executable.path, cwd: managed.laneWorktreePath, @@ -28986,6 +29069,11 @@ export function createAgentChatService(args: { const persisted = readPersistedState(managed.session.id); const existingSessionId = managed.seededAcpSessionId?.trim() || persisted?.acpSessionId?.trim() + // A Devin Cloud chat opened from the panel knows its session by the web + // id before it has ever spoken ACP. + || (devinCloud && managed.session.devinSessionId?.trim() + ? devinCloudAcpSessionId(managed.session.devinSessionId) + : null) || null; if (managed.acpSupervisionNoticeShown === undefined) { managed.acpSupervisionNoticeShown = persisted?.acpSupervisionNoticeShown === true; @@ -29022,6 +29110,14 @@ export function createAgentChatService(args: { teardownExistingRuntime: () => teardownRuntime(managed, "handle_close"), nativeModeValue: acpNativeModeValue(permissionMode), setResumeCommand: (command) => sessionService.setResumeCommand(managed.session.id, command), + ...(devinCloud + ? { + initialConfigValues: [ + ...(devinCloud.repo ? [{ configId: "repos", value: devinCloud.repo }] : []), + ...(devinCloud.platform ? [{ configId: "platform", value: devinCloud.platform }] : []), + ], + } + : {}), binarySource: executable.source, ...(args.acpSpawnOverride ? { spawnOverride: args.acpSpawnOverride } : {}), ...(args.acpSessionPool ? { pool: args.acpSessionPool } : {}), @@ -29051,6 +29147,7 @@ export function createAgentChatService(args: { onSessionInfo: (runtime, info) => { if (!runtime || managed.runtime !== runtime) return; adoptRuntimeSessionTitle(managed, info.title, "acp_session_info"); + if (devinCloud) applyDevinCloudSessionStatus(managed, runtime, info.meta ?? null); }, onProcessExit: (runtime, detail) => { // A null runtime means the process died during open, before the @@ -29084,6 +29181,16 @@ export function createAgentChatService(args: { managed.acpReasoningEffortInvalidated = false; managed.seededAcpSessionId = runtime.session.sessionId; managed.session.acpPermissionMode = permissionMode; + if (devinCloud) { + // The ACP id and the web id name one session; the panel, + // the header link and `devin ssh` all key on the bare form. + const bareId = devinCloudBareSessionId(runtime.session.sessionId); + if (managed.session.devinSessionId !== bareId) { + managed.session.devinSessionId = bareId; + managed.session.devinRuntime = "cloud"; + persistChatState(managed); + } + } // A live runtime means the previous exit's stderr no longer explains // this chat; drop it so it cannot headline a later failure. managed.acpLastExitStderrTail = undefined; @@ -29113,6 +29220,55 @@ export function createAgentChatService(args: { return runtime; }; + /** + * Cognition's relay reports the cloud session's own state on + * `session_info_update` (`cognition.ai/statusEnum`). Inside an ADE turn the + * turn owns the chat state. Outside one — a session running on the VM that + * ADE only watches, or one someone steered from app.devin.ai — this is the + * only signal, so it drives the chat's busy state and the Needs-you tier. + */ + const applyDevinCloudSessionStatus = ( + managed: ManagedChatSession, + runtime: AcpRuntime, + meta: Record | null, + ): void => { + // No status in this update is not "starting": leave the chat as it is. + if ( + !meta + || (meta["cognition.ai/statusEnum"] == null + && meta["cognition.ai/sessionStatus"] == null + && meta["cognition.ai/isArchived"] == null) + ) { + return; + } + // One rule with the Devin Cloud panel: needs-you is "blocked" with a user + // action or a pending request. + const { status } = devinDirectoryStatus(meta); + const needsYou = status === "needs_you"; + if (needsYou && !hasLivePendingInput(managed) && !devinCloudAttentionIsRaised(managed.session.id)) { + devinCloudAttentionRaised.add(managed.session.id); + sessionService.requestAttention(managed.session.id, "Devin needs your input", "provider_structured"); + } else if (!needsYou && devinCloudAttentionIsRaised(managed.session.id)) { + devinCloudAttentionRaised.delete(managed.session.id); + sessionService.clearAttentionRequest(managed.session.id, "provider_structured"); + } + if (runtime.busy) return; + switch (status) { + case "working": + setSessionActive(managed); + break; + case "needs_you": + case "idle": + case "finished": + case "failed": + case "archived": + markSessionIdleWithFreshCache(managed); + break; + case "starting": + break; + } + }; + /** * Emit the one visible failure for an ACP turn. When the agent process left a * stderr tail, the card leads with its last meaningful line and keeps the @@ -29198,6 +29354,10 @@ export function createAgentChatService(args: { } emitAcpDegradationNotes(managed, runtime.dialect, runtime.permissionMode); + const devinCloudTurn = isDevinCloudAcpSession(managed.session) ? managed.session.devinCloud ?? null : null; + // The first turn of a fresh cloud session sets the VM up on the lane + // branch; a rejoined session already stands on it. + const devinCloudFirstTurn = devinCloudTurn !== null && devinCloudTurn.pinned !== true; runtime.busy = true; runtime.activeTurnId = turnId; @@ -29230,7 +29390,16 @@ export function createAgentChatService(args: { let prompt = args.promptText; const pendingContext = consumePendingTurnContextPrefix(managed, false)?.composed; if (pendingContext) prompt = `${pendingContext}\n\n${prompt}`; - if (!isPersonalSession(managed.session) && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { + if (devinCloudTurn) { + // ADE's lane guidance describes this machine (paths, the `ade` CLI); + // none of it exists on a Devin VM. The branch pin is what the VM needs. + const pin = buildDevinCloudBranchPin({ + repo: devinCloudTurn.repo, + branch: devinCloudTurn.branch, + firstTurn: devinCloudFirstTurn, + }); + if (pin) prompt = `${pin}\n\n${prompt}`; + } else if (!isPersonalSession(managed.session) && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { const guidance = buildAdeGuidanceForLane( managed.laneWorktreePath, managed.session, @@ -29271,7 +29440,20 @@ export function createAgentChatService(args: { // never the deciding word, and a Stop after the answer is not either. const interrupted = outcome.interrupted; if (!interrupted) reportProviderRuntimeReady(provider); - void emitTurnDiffSummaryIfChanged(managed, turnId); + if (devinCloudTurn) { + if (devinCloudFirstTurn && !interrupted && managed.session.devinCloud) { + managed.session.devinCloud = { ...managed.session.devinCloud, pinned: true }; + } + // The work landed on the VM and was pushed to the lane branch. Pull it + // into the lane's mirror so Files, Git and the turn diff show it. + void syncCloudLaneMirror({ + worktreePath: managed.laneWorktreePath, + branch: devinCloudTurn.branch, + logger, + }).finally(() => emitTurnDiffSummaryIfChanged(managed, turnId)); + } else { + void emitTurnDiffSummaryIfChanged(managed, turnId); + } emitChatEvent(managed, { type: "status", turnStatus: interrupted ? "interrupted" : "completed", @@ -38992,6 +39174,7 @@ export function createAgentChatService(args: { reasoningEffort, fastMode: requestedFastModeArg, cursorCloudServiceTier: requestedCursorCloudServiceTier, + devinCloud: requestedDevinCloud, codexFastMode: requestedLegacyFastModeArg, interactionMode: requestedInteractionMode, claudePermissionMode: requestedClaudePermissionMode, @@ -39480,6 +39663,9 @@ export function createAgentChatService(args: { ? { piSessionFile: requestedPiSessionFile } : {}), sessionProfile: sessionProfile ?? "workflow", + ...(effectiveProvider === "devin" && normalizeDevinCloudConfig(requestedDevinCloud) + ? { devinCloud: normalizeDevinCloudConfig(requestedDevinCloud), devinRuntime: "cloud" as const } + : {}), ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), ...(initialFastMode ? { fastMode: true } : {}), ...(initialCursorCloudServiceTier !== undefined @@ -42868,7 +43054,13 @@ export function createAgentChatService(args: { const slashCommandPromptWithMentions = expandedSlashCommandPrompt != null ? carryChatMentionBlocks(trimmed, expandedSlashCommandPrompt) : null; - const promptText = providerSlashCommand && !personalSession + // A Devin Cloud turn runs on a Devin VM: every directive below describes + // this machine (its worktree path, the `ade` CLI, computer use) and none of + // it exists there. Its only framing is the branch pin `runAcpTurn` adds. + const devinCloudPrompt = isDevinCloudAcpSession(managed.session); + const promptText = devinCloudPrompt + ? trimmed + : providerSlashCommand && !personalSession ? slashCommandPromptWithMentions ?? trimmed : composeLaunchDirectives(trimmed, [ shouldInjectLaneDirective @@ -47454,6 +47646,161 @@ export function createAgentChatService(args: { return { sessionId: managed.session.id, session: managed.session }; }; + // ------------------------------------------------------------------ + // Devin Cloud (ACP relay) + // + // A Devin Cloud chat is an ACP chat on `devin acp --cloud`: joining the + // relay loads the session's history and keeps streaming it. The only state + // ADE keeps beside the transcript is whether the relay status raised the + // chat's needs-you marker, so a restarted host can still clear it. + // ------------------------------------------------------------------ + + /** Sessions whose needs-you marker the relay status raised (so it can clear it without touching others'). */ + const devinCloudAttentionRaised = new Set(); + /** Sessions whose persisted attention flag has been folded back into the live set. */ + const devinCloudAttentionSeeded = new Set(); + const devinCloudAttentionIsRaised = (sessionId: string): boolean => { + if (!devinCloudAttentionSeeded.has(sessionId)) { + devinCloudAttentionSeeded.add(sessionId); + if (readPersistedState(sessionId)?.devinCloudAttentionRaised === true) { + devinCloudAttentionRaised.add(sessionId); + } + } + return devinCloudAttentionRaised.has(sessionId); + }; + + const forgetDevinCloudAttentionState = (sessionId: string): void => { + devinCloudAttentionRaised.delete(sessionId); + devinCloudAttentionSeeded.delete(sessionId); + }; + + const clearAllDevinCloudAttentionState = (): void => { + devinCloudAttentionRaised.clear(); + devinCloudAttentionSeeded.clear(); + }; + + /** + * Open (or reopen) the ADE chat for a Devin Cloud session and join it over + * the ACP relay. `devinCloud` is required for a chat that is not already a + * relay chat: there is no other way to reach a cloud session. + */ + const openDevinCloudChat = async (args: { + devinSessionId: string; + laneId: string; + sessionId?: string | null; + devinCloud?: AgentChatDevinCloudConfig | null; + }): Promise<{ sessionId: string; session: AgentChatSession }> => { + const trimmedDevin = normalizeDevinCloudSessionId(args.devinSessionId); + const trimmedLane = args.laneId.trim(); + if (!trimmedLane) throw new Error("Lane id is required."); + const acpCloud = normalizeDevinCloudConfig(args.devinCloud); + + const laneInfo = (() => { + try { + return laneService.getLaneBaseAndBranch(trimmedLane); + } catch { + return null; + } + })(); + if (!laneInfo) throw new Error(`Lane '${trimmedLane}' was not found.`); + + const requestedId = args.sessionId?.trim() || ""; + let managed: ManagedChatSession | null = null; + if (requestedId) { + try { + managed = managedSessions.get(requestedId) + ?? (sessionService.get(requestedId) ? ensureManagedSession(requestedId) : null); + } catch { + managed = null; + } + } + if (!managed) { + for (const candidate of managedSessions.values()) { + if (candidate.session.devinSessionId === trimmedDevin) { + managed = candidate; + break; + } + } + } + if (!managed) { + // A link created before this process started is invisible in + // managedSessions; check persisted state before minting a duplicate + // chat for the same Devin session. + try { + const rows = sessionService.list({ + // Unbounded: a bounded scan silently mints a second chat for any + // Devin link older than the cutoff. This cold path only runs when + // neither managedSessions nor the lane index knows the link. + limit: null, + toolTypes: CHAT_SESSION_TOOL_TYPES, + }); + for (const row of rows) { + if (!isChatToolType(row.toolType)) continue; + const persistedDevinId = readPersistedState(row.id)?.devinSessionId; + const persistedId = persistedDevinId ? devinCloudBareSessionId(persistedDevinId) : ""; + if (persistedId !== trimmedDevin) continue; + managed = ensureManagedSession(row.id); + break; + } + } catch (error) { + logger.warn("agent_chat.devin_cloud_link_lookup_failed", { + devinSessionId: trimmedDevin, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Validate before creating or touching anything. + const cloudConfig = acpCloud + ?? (managed && isDevinCloudAcpSession(managed.session) ? managed.session.devinCloud ?? null : null); + if (!cloudConfig) { + throw new Error("Devin Cloud chats run over the Devin CLI relay and need a cloud config. Open the session from the Devin Cloud panel."); + } + // A chat already linked elsewhere keeps its transcript — relinking would + // silently retarget its replies to the new Devin session. + if (managed?.session.devinSessionId && managed.session.devinSessionId !== trimmedDevin) { + throw new Error("This chat is already linked to a different Devin session."); + } + + const existedBefore = Boolean(managed); + if (!managed) { + const created = await createSession({ + laneId: trimmedLane, + provider: "devin", + model: "adaptive", + modelId: "devin/adaptive", + devinCloud: cloudConfig, + ...(requestedId ? { sessionId: requestedId } : {}), + }); + managed = managedSessions.get(created.id) ?? null; + } + if (!managed) throw new Error("Could not open a Devin Cloud chat session."); + + managed.session.devinSessionId = trimmedDevin; + managed.session.devinRuntime = "cloud"; + // Keep a pinned branch the chat already had; the directory only knows the + // branch once the session has a pull request. + managed.session.devinCloud = { + ...cloudConfig, + branch: managed.session.devinCloud?.branch ?? cloudConfig.branch, + }; + persistChatState(managed); + + // Joining the relay loads the session: a chat ADE has never shown gets + // the full history, and a running session keeps streaming into it. + const target = managed; + const attach = ensureAcpSessionRuntime(target).then(() => undefined).catch((error) => { + logger.warn("agent_chat.devin_cloud_acp_attach_failed", { + sessionId: target.session.id, + devinSessionId: trimmedDevin, + error: error instanceof Error ? error.message : String(error), + }); + if (existedBefore) throw error; + }); + if (existedBefore) await attach; + return { sessionId: target.session.id, session: target.session }; + }; + const droidPoolKeyFor = (managed: ManagedChatSession): string => [ "sdk", managed.session.id, @@ -48058,6 +48405,7 @@ export function createAgentChatService(args: { if (reasoningEffort !== undefined) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } + assertDevinCloudChatHasRelay(managed.session); // A slash command is ordinary prompt text for every ACP dialect: the // agent advertises the list, ADE offers it, and the chosen text is sent // unchanged. There is no dispatch verb to translate it into. @@ -51005,6 +51353,7 @@ export function createAgentChatService(args: { managed.seededAcpSessionId = persisted?.acpSessionId ?? managed.seededAcpSessionId; managed.session.acpPermissionMode = persisted?.acpPermissionMode ?? managed.session.acpPermissionMode; managed.session.acpConfigSnapshot = persisted?.acpConfigSnapshot ?? managed.session.acpConfigSnapshot; + assertDevinCloudChatHasRelay(managed.session); const acpRuntime = await ensureAcpSessionRuntime(managed); managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; enforceManagedLocalHarnessPermissionMode(managed); @@ -52220,6 +52569,15 @@ export function createAgentChatService(args: { ...(liveSession?.cursorPromotedTurnId || persisted?.cursorPromotedTurnId ? { cursorPromotedTurnId: liveSession?.cursorPromotedTurnId ?? persisted?.cursorPromotedTurnId } : {}), + ...(liveSession?.devinSessionId || persisted?.devinSessionId + ? { devinSessionId: liveSession?.devinSessionId ?? persisted?.devinSessionId } + : {}), + ...(liveSession?.devinRuntime || persisted?.devinRuntime + ? { devinRuntime: liveSession?.devinRuntime ?? persisted?.devinRuntime } + : {}), + ...(liveSession?.devinCloud || persisted?.devinCloud + ? { devinCloud: liveSession?.devinCloud ?? persisted?.devinCloud } + : {}), ...(liveSession?.cursorCloudServiceTier !== undefined || persisted?.cursorCloudServiceTier !== undefined ? { cursorCloudServiceTier: liveSession?.cursorCloudServiceTier ?? persisted?.cursorCloudServiceTier } : {}), @@ -53042,6 +53400,7 @@ export function createAgentChatService(args: { transcriptHistoryCacheBySession.delete(sessionId); resolvedTranscriptPathBySession.delete(sessionId); forgetCursorCloudHydrationState(sessionId); + forgetDevinCloudAttentionState(sessionId); }; const countActiveForLane = (laneId: string): number => { @@ -55367,6 +55726,7 @@ export function createAgentChatService(args: { teardownRuntime(managed, "ended_session"); managedSessions.delete(trimmedSessionId); forgetCursorCloudHydrationState(trimmedSessionId); + forgetDevinCloudAttentionState(trimmedSessionId); } else { clearSubagentSnapshots(trimmedSessionId); } @@ -55453,6 +55813,7 @@ export function createAgentChatService(args: { unsubscribeCliChildExit?.(); clearCursorCloudMirrorWatches(); clearAllCursorCloudHydrationState(); + clearAllDevinCloudAttentionState(); scheduledWorkScheduler?.dispose(); autoResume.forgetAll(); for (const recovery of cancelledQueueRecoveries.values()) clearTimeout(recovery.timer); @@ -59273,6 +59634,7 @@ export function createAgentChatService(args: { handleCursorCloudStatusChange, openCursorCloudChat, watchCursorCloudMirror, + openDevinCloudChat, subscribeToEvents(callback: (event: AgentChatEventEnvelope) => void) { eventSubscribers.add(callback); return () => { diff --git a/apps/desktop/src/main/services/chat/cloudAgents/cloudAgentsContext.ts b/apps/desktop/src/main/services/chat/cloudAgents/cloudAgentsContext.ts new file mode 100644 index 0000000000..93a4a855fc --- /dev/null +++ b/apps/desktop/src/main/services/chat/cloudAgents/cloudAgentsContext.ts @@ -0,0 +1,294 @@ +/** + * What both cloud-agent strategies share: this project's origin, the chat and + * lane lookups that link an agent to ADE, and the cloud-lane helpers — make a + * lane, tag it, push its branch so the VM can check it out. + */ +import { runGit } from "../../git/git"; +import type { LaneSummary } from "../../../../shared/types/lanes"; +import type { AgentChatSessionSummary } from "../../../../shared/types/chat"; +import type { + CloudAgentLaunchArgs, + CloudAgentLink, + CloudAgentList, + CloudAgentOpenResult, +} from "../../../../shared/types/cloudAgents"; +import { + CLOUD_LANE_LABELS, + laneCloudProvider, + withCloudLaneTag, + type CloudLaneProvider, +} from "../../../../shared/cloudLanes"; +import { repoMatchKey } from "../../../../shared/cursorCloudRepoMatch"; +import type { Logger } from "../../logging/logger"; +import type { AgentChatDevinCloudConfig } from "../../../../shared/types/chat"; +import type { CursorCloudFleetResult } from "../../../../shared/types/config"; +import type { DevinCloudDirectory } from "../devinCloudDirectory"; + +const ORIGIN_TTL_MS = 60_000; +const PUSH_TIMEOUT_MS = 90_000; + +type LaneDeps = { + list: (args: { includeArchived?: boolean; includeStatus?: boolean }) => Promise; + importBranch: (args: { branchRef: string; name?: string; description?: string; baseBranch?: string }) => Promise; + create: (args: { name: string; baseBranch?: string; branchName?: string; description?: string }) => Promise; + updateAppearance: (args: { laneId: string; tags?: string[] | null }) => void; +}; + +export type CloudAgentsServiceDeps = { + projectRoot: string; + logger: Logger; + devinDirectory: DevinCloudDirectory; + cursorFleet: { + getFleet: (args?: { force?: boolean; includeArchived?: boolean }) => Promise; + resolveLaneForAgent: (agentId: string) => Promise<{ laneId: string; laneName: string; created: boolean }>; + stopAgentRun: (agentId: string) => Promise<{ stopped: boolean }>; + invalidateCache: () => void; + } | null; + cursorArchive?: ((agentId: string, archived: boolean) => Promise) | null; + /** Null when Cursor has no API key; the Cursor list then says so. */ + cursorUnavailableReason?: () => Promise; + lanes: LaneDeps; + chats: { + list: () => Promise; + openDevinCloudChat: (args: { + devinSessionId: string; + laneId: string; + sessionId?: string | null; + devinCloud?: AgentChatDevinCloudConfig | null; + }) => Promise<{ sessionId: string }>; + openCursorCloudChat: (args: { cloudAgentId: string; laneId: string; sessionId?: string | null }) => Promise<{ sessionId: string }>; + interrupt: (sessionId: string) => Promise; + createDevinCloudChat: (args: { laneId: string; devinCloud: AgentChatDevinCloudConfig }) => Promise<{ id: string }>; + send: (args: { sessionId: string; text: string }) => Promise; + }; + cursorCreateRun?: ((args: { + promptText: string; + repoUrl: string; + startingRef: string; + modelId?: string | null; + laneId: string; + }) => Promise<{ agent: { agentId: string } }>) | null; +}; + +/** How long a just-launched Devin chat without a session id still counts as running. */ +const LAUNCH_SETTLE_MS = 120_000; + +export const ONE_AGENT_PER_LANE_MESSAGE = + "A cloud agent is already working in this lane. Send it a message, or wait for it to finish."; + +export type ProjectOrigin = { + /** Match key (`github.com/owner/repo`); empty without an origin. */ + key: string; + /** `owner/repo`, the form cloud providers clone. */ + slug: string | null; + defaultBranch: string | null; + at: number; +}; + +/** A lane name from the first line of a prompt: short, no trailing punctuation. */ +export function cloudLaneNameFromPrompt(prompt: string): string { + const line = prompt.trim().split(/\r?\n/)[0] ?? ""; + const words = line.replace(/\s+/g, " ").slice(0, 42).trim(); + const clipped = words.length >= 42 ? words.replace(/\s+\S*$/, "") : words; + return clipped.replace(/[.,;:!?]+$/, "") || "Cloud agent"; +} + +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "session"; +} + +export function createCloudAgentsContext(deps: CloudAgentsServiceDeps) { + const { projectRoot, logger } = deps; + let originCache: ProjectOrigin | null = null; + + /** This project's origin as a match key, `owner/repo`, and its default branch. */ + const origin = async (): Promise => { + if (originCache && Date.now() - originCache.at < ORIGIN_TTL_MS) return originCache; + const url = await runGit(["remote", "get-url", "origin"], { cwd: projectRoot, timeoutMs: 8_000 }) + .then((result) => (result.exitCode === 0 ? result.stdout.trim() : "")) + .catch(() => ""); + const head = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: projectRoot, timeoutMs: 8_000 }) + .then((result) => (result.exitCode === 0 ? result.stdout.trim().replace(/^origin\//, "") : "")) + .catch(() => ""); + const key = repoMatchKey(url); + const slugMatch = /[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url); + originCache = { key, slug: slugMatch ? slugMatch[1] : null, defaultBranch: head || null, at: Date.now() }; + return originCache; + }; + + const lanesById = async (): Promise> => { + const lanes = await deps.lanes.list({ includeArchived: false, includeStatus: false }); + return new Map(lanes.map((lane) => [lane.id, lane])); + }; + + const linkFor = ( + chat: AgentChatSessionSummary | undefined, + lanes: Map, + ): CloudAgentLink | null => { + if (!chat) return null; + const lane = lanes.get(chat.laneId) ?? null; + return { + chatSessionId: chat.sessionId, + laneId: chat.laneId, + laneName: lane?.name ?? null, + laneIsCloud: laneCloudProvider(lane) !== null, + }; + }; + + /** Most recent chat per provider id, so a relinked agent shows its live chat. */ + const chatIndex = async (key: "devinSessionId" | "cursorCloudAgentId") => { + const chats = await deps.chats.list(); + const index = new Map(); + for (const chat of [...chats].sort((a, b) => Date.parse(a.lastActivityAt) - Date.parse(b.lastActivityAt))) { + const id = chat[key]?.trim(); + if (id) index.set(id, chat); + } + return index; + }; + + /** Mark a lane ADE uses for a cloud agent as that cloud's lane. */ + const tagCloudLane = (lane: LaneSummary, provider: CloudLaneProvider) => { + if (laneCloudProvider(lane) === provider) return; + deps.lanes.updateAppearance({ laneId: lane.id, tags: withCloudLaneTag(lane.tags, provider) }); + }; + + /** + * Push the lane's branch to origin so a cloud VM can check it out. No + * upstream is set: the lane's own tracking is not this push's business. + * + * A rejected (non-fast-forward) push means origin already has the branch + * with commits the lane lacks — the cloud agent pushed them. The VM can + * check that out, so it is not a failure. + */ + const pushLaneBranch = async ( + lane: LaneSummary, + options: { onFailure: "throw" | "warn" }, + ): Promise => { + const pushed = await runGit(["push", "--quiet", "origin", `HEAD:refs/heads/${lane.branchRef}`], { + cwd: lane.worktreePath, + timeoutMs: PUSH_TIMEOUT_MS, + }).catch((error: unknown) => ({ exitCode: 1, stdout: "", stderr: String(error) })); + if (pushed.exitCode === 0) return true; + const stderr = pushed.stderr.trim().slice(-300); + // Rejected because origin is ahead: fine when the lane holds nothing origin + // lacks (the cloud agent pushed past it), since the VM checks out origin. + // A lane with commits of its own would start the agent without them. + if (/non-fast-forward|fetch first/i.test(stderr)) { + const behindOnly = await runGit(["fetch", "--quiet", "origin", `refs/heads/${lane.branchRef}`], { + cwd: lane.worktreePath, + timeoutMs: PUSH_TIMEOUT_MS, + }) + .then(async (fetched) => { + if (fetched.exitCode !== 0) return false; + const fetchedHead = await runGit(["rev-parse", "FETCH_HEAD"], { cwd: lane.worktreePath, timeoutMs: 15_000 }); + const remoteSha = fetchedHead.stdout.trim(); + return fetchedHead.exitCode === 0 + && /^[0-9a-f]{40}$/.test(remoteSha) + && (await runGit(["merge-base", "--is-ancestor", "HEAD", remoteSha], { cwd: lane.worktreePath, timeoutMs: 15_000 })).exitCode === 0; + }) + .catch(() => false); + if (behindOnly) { + logger.info("cloud_agents.lane_branch_behind_origin", { laneId: lane.id, branch: lane.branchRef }); + return true; + } + if (options.onFailure === "throw") { + throw new Error(`${lane.branchRef} has diverged from origin. Pull or push it first so the cloud agent starts from your commits.`); + } + } + if (options.onFailure === "throw") { + throw new Error(`Could not push ${lane.branchRef} to origin, so the cloud VM cannot check it out: ${stderr || "git push failed"}`); + } + logger.warn("cloud_agents.lane_branch_push_failed", { laneId: lane.id, branch: lane.branchRef, stderr }); + return false; + }; + + /** + * A cloud lane for new work: a fresh lane on a new branch off the default + * branch, tagged with its cloud and pushed so the VM can check it out. + */ + const createCloudLane = async ( + provider: CloudLaneProvider, + args: { name: string; branchName: string; onPushFailure: "throw" | "warn" }, + ): Promise => { + const { defaultBranch } = await origin(); + const lane = await deps.lanes.create({ + name: args.name, + branchName: args.branchName, + ...(defaultBranch ? { baseBranch: defaultBranch } : {}), + }); + tagCloudLane(lane, provider); + await pushLaneBranch(lane, { onFailure: args.onPushFailure }); + return lane; + }; + + /** + * Everything a launch needs before the provider starts its agent: a task, + * a GitHub origin to clone, and a lane — the one asked for (never the + * primary, never another cloud's, branch pushed) or a new cloud lane. + */ + const prepareLaunch = async (args: CloudAgentLaunchArgs) => { + const prompt = args.prompt.trim(); + if (!prompt) throw new Error("Tell the agent what to do."); + const { slug } = await origin(); + if (!slug) throw new Error("This project has no GitHub origin, so a cloud agent cannot clone it."); + if (!args.laneId) { + const name = cloudLaneNameFromPrompt(prompt); + const suffix = Math.random().toString(36).slice(2, 6); + const lane = await createCloudLane(args.provider, { + name, + branchName: `${args.provider}/${slugify(name)}-${suffix}`, + onPushFailure: "throw", + }); + return { prompt, slug, lane, created: true }; + } + // One cloud agent per lane at a time: two VMs pushing one branch race. + // A Devin chat just launched is still spawning its relay: it has its cloud + // config but no session id yet, and it is not "active" until the relay is up. + // Bounded, so a launch that failed does not hold the lane forever. + const running = (await deps.chats.list()).find((chat) => + chat.laneId === args.laneId + && ( + (chat.status === "active" && (Boolean(chat.devinSessionId) || Boolean(chat.cursorCloudAgentId))) + || (Boolean(chat.devinCloud) && !chat.devinSessionId && chat.status !== "ended" && Date.now() - Date.parse(chat.startedAt) < LAUNCH_SETTLE_MS) + )); + if (running) throw new Error(ONE_AGENT_PER_LANE_MESSAGE); + const lane = (await lanesById()).get(args.laneId); + if (!lane) throw new Error("That lane no longer exists."); + if (lane.laneType === "primary") { + throw new Error("The primary lane can't move to a cloud. Pick a cloud lane or start a new one."); + } + const cloud = laneCloudProvider(lane); + if (cloud && cloud !== args.provider) throw new Error(`That lane lives on ${CLOUD_LANE_LABELS[cloud]}.`); + // Make sure the VM can see the branch before the agent tries to. + await pushLaneBranch(lane, { onFailure: "throw" }); + return { prompt, slug, lane, created: false }; + }; + + return { + deps, + logger, + origin, + lanesById, + linkFor, + chatIndex, + tagCloudLane, + pushLaneBranch, + createCloudLane, + prepareLaunch, + }; +} + +export type CloudAgentsContext = ReturnType; + +/** One provider's side of the cloud-agents contract. */ +export type CloudAgentsStrategy = { + list: (force: boolean) => Promise; + open: (id: string) => Promise; + stop: (id: string) => Promise<{ stopped: true }>; + archive: (id: string, archived: boolean) => Promise<{ archived: boolean }>; + launch: (args: CloudAgentLaunchArgs) => Promise; +}; diff --git a/apps/desktop/src/main/services/chat/cloudAgents/cursorCloudAgents.ts b/apps/desktop/src/main/services/chat/cloudAgents/cursorCloudAgents.ts new file mode 100644 index 0000000000..177cc5b8ee --- /dev/null +++ b/apps/desktop/src/main/services/chat/cloudAgents/cursorCloudAgents.ts @@ -0,0 +1,182 @@ +/** + * Cursor Cloud side of the cloud-agents contract: the fleet service (SDK + + * API key) lists and stops agents, the chat service streams them. Archive and + * launch need their own host capability; without it the verb says so. + */ +import type { + CloudAgent, + CloudAgentList, + CloudAgentOpenResult, + CloudAgentPullRequest, + CloudAgentStatus, +} from "../../../../shared/types/cloudAgents"; +import type { CursorCloudFleetEntry, CursorCloudFleetResult } from "../../../../shared/types/config"; +import { laneCloudProvider } from "../../../../shared/cloudLanes"; +import { repoMatchKey } from "../../../../shared/cursorCloudRepoMatch"; +import { pullRequestNumber } from "../../../../shared/cursorCloudRepoMatch"; +import type { CloudAgentsServiceDeps } from "./cloudAgentsContext"; +import type { CloudAgentsContext, CloudAgentsStrategy } from "./cloudAgentsContext"; + +export type CursorCloudAgentsDeps = { + /** Null when this host has no Cursor fleet; every verb then says so. */ + fleet: NonNullable | null; + /** Null when this host cannot archive; the capability is then off. */ + archive: ((agentId: string, archived: boolean) => Promise) | null; + /** Null when this host cannot start runs; launch then says so. */ + createRun: NonNullable | null; + /** Why Cursor cannot list right now (no API key), or null. */ + unavailableReason: (() => Promise) | null; +}; + +const NOT_AVAILABLE = "Cursor Cloud is not available."; + +function cursorStatus(entry: CursorCloudFleetEntry): { status: CloudAgentStatus; statusText: string } { + if (entry.agent.archived) return { status: "archived", statusText: "Archived" }; + switch (entry.runStatus ?? entry.agent.status) { + case "creating": + return { status: "starting", statusText: "Starting" }; + case "running": + return { status: "working", statusText: "Working" }; + case "error": + return { status: "failed", statusText: "Errored" }; + case "cancelled": + return { status: "finished", statusText: "Stopped" }; + case "expired": + return { status: "finished", statusText: "Expired" }; + default: + return { status: "finished", statusText: "Finished" }; + } +} + +function prFromUrl(url: string | null, headRef: string | null): CloudAgentPullRequest | null { + if (!url) return null; + return { + url, + number: pullRequestNumber(url), + state: null, + title: null, + headRef, + baseRef: null, + additions: null, + deletions: null, + }; +} + +function toIso(value: number | null | undefined): string | null { + return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null; +} + +export function cursorCloudAgents(ctx: CloudAgentsContext, cursor: CursorCloudAgentsDeps): CloudAgentsStrategy { + const { deps } = ctx; + const capabilities = { stop: Boolean(cursor.fleet), archive: Boolean(cursor.archive), vmShell: false, web: true }; + + const requireFleet = () => { + if (!cursor.fleet) throw new Error(NOT_AVAILABLE); + return cursor.fleet; + }; + + const list = async (force: boolean): Promise => { + const fetchedAt = new Date().toISOString(); + const unavailable = (reason: string): CloudAgentList => + ({ provider: "cursor", items: [], fetchedAt, capabilities, unavailableReason: reason, models: [] }); + if (!cursor.fleet) return unavailable("Cursor Cloud is not available on this machine."); + const reason = await cursor.unavailableReason?.().catch(() => null) ?? null; + if (reason) return unavailable(reason); + let fleet: CursorCloudFleetResult; + try { + fleet = await cursor.fleet.getFleet({ force, includeArchived: true }); + } catch (error) { + return unavailable(error instanceof Error ? error.message : String(error)); + } + const [{ key }, chats, lanes] = await Promise.all([ctx.origin(), ctx.chatIndex("cursorCloudAgentId"), ctx.lanesById()]); + const items = fleet.items.map((entry): CloudAgent => { + const { status, statusText } = cursorStatus(entry); + const repos = (entry.agent.repos ?? []).map((repo) => repo.replace(/^https?:\/\//, "").replace(/^github\.com\//, "")); + const chat = chats.get(entry.agent.agentId); + return { + provider: "cursor", + id: entry.agent.agentId, + title: entry.agent.name || entry.agent.summary || "Cursor agent", + status, + statusText, + unread: false, + webUrl: entry.agent.webUrl ?? `https://cursor.com/agents?id=${entry.agent.agentId}`, + repos, + branch: entry.branch, + pullRequest: prFromUrl(entry.prUrl, entry.branch), + model: entry.modelId, + platform: null, + origin: null, + excerpt: entry.agent.summary && entry.agent.summary !== entry.agent.name ? entry.agent.summary : null, + createdAt: toIso(entry.agent.createdAt), + updatedAt: toIso(entry.agent.lastModified), + inThisProject: entry.matchedBy !== "account" || (Boolean(key) && (entry.agent.repos ?? []).some((repo) => repoMatchKey(repo) === key)), + link: chat ? ctx.linkFor(chat, lanes) : entry.ownership.laneId && entry.ownership.sessionId + ? { + chatSessionId: entry.ownership.sessionId, + laneId: entry.ownership.laneId, + laneName: entry.ownership.laneName, + laneIsCloud: laneCloudProvider(lanes.get(entry.ownership.laneId)) !== null, + } + : null, + }; + }); + return { provider: "cursor", items, fetchedAt, capabilities, unavailableReason: null, models: [] }; + }; + + const open = async (id: string): Promise => { + const fleet = requireFleet(); + const chats = await ctx.chatIndex("cursorCloudAgentId"); + const existing = chats.get(id); + const resolved = existing + ? { laneId: existing.laneId, laneName: null as string | null, created: false } + : await fleet.resolveLaneForAgent(id); + if (resolved.created) { + const lane = (await ctx.lanesById()).get(resolved.laneId); + if (lane) ctx.tagCloudLane(lane, "cursor"); + } + const opened = await deps.chats.openCursorCloudChat({ + cloudAgentId: id, + laneId: resolved.laneId, + ...(existing ? { sessionId: existing.sessionId } : {}), + }); + fleet.invalidateCache(); + return { + chatSessionId: opened.sessionId, + laneId: resolved.laneId, + laneName: resolved.laneName, + createdLane: resolved.created, + }; + }; + + const stop = async (id: string): Promise<{ stopped: true }> => { + await requireFleet().stopAgentRun(id); + return { stopped: true }; + }; + + const archive = async (id: string, archived: boolean): Promise<{ archived: boolean }> => { + if (!cursor.archive) throw new Error("Archiving Cursor agents is not available."); + await cursor.archive(id, archived); + cursor.fleet?.invalidateCache(); + return { archived }; + }; + + const launch: CloudAgentsStrategy["launch"] = async (args) => { + // Checked before the lane: a launch that cannot start must not leave one. + const createRun = cursor.createRun; + if (!createRun) throw new Error(NOT_AVAILABLE); + const { prompt, slug, lane, created } = await ctx.prepareLaunch(args); + const run = await createRun({ + promptText: prompt, + repoUrl: `https://github.com/${slug}`, + startingRef: lane.branchRef, + modelId: args.model?.trim() || null, + laneId: lane.id, + }); + const opened = await deps.chats.openCursorCloudChat({ cloudAgentId: run.agent.agentId, laneId: lane.id }); + cursor.fleet?.invalidateCache(); + return { chatSessionId: opened.sessionId, laneId: lane.id, laneName: lane.name, createdLane: created }; + }; + + return { list, open, stop, archive, launch }; +} diff --git a/apps/desktop/src/main/services/chat/cloudAgents/devinCloudAgents.ts b/apps/desktop/src/main/services/chat/cloudAgents/devinCloudAgents.ts new file mode 100644 index 0000000000..36c24ffcde --- /dev/null +++ b/apps/desktop/src/main/services/chat/cloudAgents/devinCloudAgents.ts @@ -0,0 +1,240 @@ +/** + * Devin Cloud side of the cloud-agents contract. Everything rides the ACP + * relay directory (`devin acp --cloud`, CLI login, no token); opening a + * session puts it in a Devin cloud lane with a live relay chat. + */ +import type { LaneSummary } from "../../../../shared/types/lanes"; +import type { + CloudAgent, + CloudAgentLink, + CloudAgentList, + CloudAgentModelOption, + CloudAgentOpenResult, +} from "../../../../shared/types/cloudAgents"; +import { + DEVIN_CLOUD_DEFAULT_VERSION, + DEVIN_CLOUD_VERSIONS, + stripDevinCloudBranchPin, +} from "../../../../shared/devinCloud"; +import { devinCloudRepoMatchKey, repoMatchKey } from "../../../../shared/cursorCloudRepoMatch"; +import { devinCloudBareSessionId } from "../acpHost/acpDialects/devinCloud"; +import type { DevinCloudDirectory, DevinCloudDirectoryEntry } from "../devinCloudDirectory"; +import { slugify, type CloudAgentsContext, type CloudAgentsStrategy } from "./cloudAgentsContext"; + +const DEVIN_MODELS: CloudAgentModelOption[] = DEVIN_CLOUD_VERSIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + ...(option.badge ? { badge: option.badge } : {}), +})); + +const CAPABILITIES = { stop: true, archive: true, vmShell: true, web: true }; + +function devinEntryToAgent(entry: DevinCloudDirectoryEntry, originKey: string, link: CloudAgentLink | null): CloudAgent { + // Only a PR on this project's repo names a branch ADE can check out. A + // session that also opened PRs elsewhere still shows one, but its branch + // belongs to that other repo. + const ours = originKey + ? entry.pullRequests.find((pr) => repoMatchKey(pr.url.replace(/\/pull\/\d+.*$/, "")) === originKey) ?? null + : null; + return { + provider: "devin", + id: entry.id, + title: entry.title, + status: entry.status, + statusText: entry.statusText, + unread: entry.unread, + webUrl: entry.url ?? `https://app.devin.ai/sessions/${entry.id}`, + repos: entry.repos, + branch: ours?.headRef ?? null, + pullRequest: ours ?? entry.pullRequests[0] ?? null, + model: entry.model, + platform: entry.platform, + origin: entry.origin, + excerpt: entry.excerpt ? stripDevinCloudBranchPin(entry.excerpt) || null : null, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + inThisProject: Boolean(originKey) && entry.repos.some((repo) => devinCloudRepoMatchKey(repo) === originKey), + link, + }; +} + +/** Import failures that mean "no usable branch to import", not a real error. */ +function isBranchUnavailable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + // "not found": a merged or closed PR usually took its branch with it. + // "already exists": an archived lane (or the primary) still holds it. + return /not found|already exists/i.test(message); +} + +export function devinCloudAgents(ctx: CloudAgentsContext, directory: DevinCloudDirectory): CloudAgentsStrategy { + const { deps, logger } = ctx; + + const list = async (force: boolean): Promise => { + const fetchedAt = new Date().toISOString(); + let entries: DevinCloudDirectoryEntry[]; + try { + entries = await directory.list({ force }); + } catch (error) { + return { + provider: "devin", + items: [], + fetchedAt, + capabilities: CAPABILITIES, + unavailableReason: error instanceof Error ? error.message : String(error), + models: DEVIN_MODELS, + }; + } + const [{ key }, chats, lanes] = await Promise.all([ctx.origin(), ctx.chatIndex("devinSessionId"), ctx.lanesById()]); + return { + provider: "devin", + items: entries.map((entry) => devinEntryToAgent(entry, key, ctx.linkFor(chats.get(entry.id), lanes))), + fetchedAt, + capabilities: CAPABILITIES, + unavailableReason: null, + models: DEVIN_MODELS, + }; + }; + + /** + * The session's lane: a non-primary lane already on its PR branch, else a + * new cloud lane imported from that branch, else — no PR yet, or the branch + * is gone — a new cloud lane on a fresh branch ADE pushes, which the chat's + * first turn pins the VM to. + */ + const laneFor = async ( + entry: DevinCloudDirectoryEntry, + agent: CloudAgent, + ): Promise<{ lane: LaneSummary; created: boolean; pinned: boolean }> => { + const branch = agent.branch?.trim() || null; + if (branch && !branch.startsWith("-")) { + const lanes = await deps.lanes.list({ includeArchived: false, includeStatus: false }); + // The primary lane is the user's own checkout; it never becomes a cloud lane. + const existing = lanes.find((lane) => lane.branchRef === branch && lane.laneType !== "primary"); + if (existing) { + ctx.tagCloudLane(existing, "devin"); + return { lane: existing, created: false, pinned: true }; + } + const imported = await deps.lanes + .importBranch({ branchRef: branch, name: entry.title.slice(0, 60) }) + .catch((error: unknown) => { + if (!isBranchUnavailable(error)) throw error; + logger.info("cloud_agents.devin_branch_unavailable", { + branch, + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (imported) { + ctx.tagCloudLane(imported, "devin"); + return { lane: imported, created: true, pinned: true }; + } + } + // Opening must not fail on a push: the chat's first turn pins the branch + // and says so if the VM cannot see it. + const lane = await ctx.createCloudLane("devin", { + name: entry.title.slice(0, 60), + branchName: `devin/${slugify(entry.title)}-${entry.id.slice(0, 6)}`, + onPushFailure: "warn", + }); + return { lane, created: true, pinned: false }; + }; + + const open = async (id: string): Promise => { + const [entry, { key, slug }, chats] = await Promise.all([ + directory.find(id, { force: true }), + ctx.origin(), + ctx.chatIndex("devinSessionId"), + ]); + const existing = chats.get(id); + if (!entry && existing) { + // The relay no longer lists it (archived there), but ADE has its chat: + // reattach with the chat's own relay config, which keeps its history. + const opened = await deps.chats.openDevinCloudChat({ + devinSessionId: id, + laneId: existing.laneId, + sessionId: existing.sessionId, + }); + const lanes = await ctx.lanesById(); + return { chatSessionId: opened.sessionId, laneId: existing.laneId, laneName: lanes.get(existing.laneId)?.name ?? null, createdLane: false }; + } + if (!entry) throw new Error("This Devin session is no longer listed. It may be archived."); + const agent = devinEntryToAgent(entry, key, null); + const baseConfig = { + transport: "acp" as const, + version: entry.model, + platform: entry.platform, + repo: entry.repos[0] ?? slug, + }; + if (existing) { + const opened = await deps.chats.openDevinCloudChat({ + devinSessionId: entry.id, + laneId: existing.laneId, + sessionId: existing.sessionId, + devinCloud: { ...baseConfig, branch: agent.branch, pinned: Boolean(agent.branch) }, + }); + const lanes = await ctx.lanesById(); + return { chatSessionId: opened.sessionId, laneId: existing.laneId, laneName: lanes.get(existing.laneId)?.name ?? null, createdLane: false }; + } + // A lane here is a checkout of this project. A session on another repo + // has nothing to check out, and pinning it to a branch of ours would + // point its VM at code it never cloned. + if (!agent.inThisProject) { + throw new Error(entry.repos.length + ? `This session works on ${entry.repos.join(", ")}, not this project.` + : "This session is not tied to a repository, so it has no lane in this project."); + } + const { lane, created, pinned } = await laneFor(entry, agent); + const opened = await deps.chats.openDevinCloudChat({ + devinSessionId: entry.id, + laneId: lane.id, + devinCloud: { ...baseConfig, branch: lane.branchRef, pinned }, + }); + directory.invalidate(); + return { chatSessionId: opened.sessionId, laneId: lane.id, laneName: lane.name, createdLane: created }; + }; + + const stop = async (id: string): Promise<{ stopped: true }> => { + // A chat ADE is running owns the turn; stop it there so the chat settles. + const chat = (await ctx.chatIndex("devinSessionId")).get(id); + if (chat && chat.status === "active") { + await deps.chats.interrupt(chat.sessionId); + } else { + await directory.cancel(id); + } + directory.invalidate(); + return { stopped: true }; + }; + + const archive = async (id: string, archived: boolean): Promise<{ archived: boolean }> => { + if (!archived) throw new Error("Unarchive Devin sessions on app.devin.ai."); + await directory.archive(id); + return { archived: true }; + }; + + const launch: CloudAgentsStrategy["launch"] = async (args) => { + const { prompt, slug, lane, created } = await ctx.prepareLaunch(args); + const chat = await deps.chats.createDevinCloudChat({ + laneId: lane.id, + devinCloud: { + transport: "acp", + version: args.model?.trim() || DEVIN_CLOUD_DEFAULT_VERSION, + platform: args.platform?.trim() || null, + repo: slug, + branch: lane.branchRef, + }, + }); + // The turn runs on the relay; the chat shows it streaming. Launch returns + // as soon as it is under way. + void deps.chats.send({ sessionId: chat.id, text: prompt }).catch((error) => { + logger.warn("cloud_agents.devin_launch_send_failed", { + sessionId: chat.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + directory.invalidate(); + return { chatSessionId: chat.id, laneId: lane.id, laneName: lane.name, createdLane: created }; + }; + + return { list, open, stop, archive, launch }; +} diff --git a/apps/desktop/src/main/services/chat/cloudAgentsService.test.ts b/apps/desktop/src/main/services/chat/cloudAgentsService.test.ts new file mode 100644 index 0000000000..530f99e0a0 --- /dev/null +++ b/apps/desktop/src/main/services/chat/cloudAgentsService.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types/lanes"; +import type { CloudAgentLaunchArgs } from "../../../shared/types/cloudAgents"; +import type { DevinCloudDirectoryEntry } from "./devinCloudDirectory"; +import { createCloudAgentsService, type CloudAgentsServiceDeps } from "./cloudAgentsService"; + +const git = vi.hoisted(() => ({ runGit: vi.fn() })); +vi.mock("../git/git", () => ({ runGit: git.runGit })); + +const originUrl = "https://github.com/acme/project.git"; +const launchArgs: CloudAgentLaunchArgs = { provider: "devin", prompt: "Fix the bug", laneId: "cloud-lane" }; + +function lane(id = "cloud-lane", branchRef = "devin/work", laneType: LaneSummary["laneType"] = "worktree") { + return { + id, + name: id, + laneType, + branchRef, + worktreePath: `/tmp/${id}`, + tags: laneType === "primary" ? [] : ["ade:cloud:devin"], + } as LaneSummary; +} + +function entry(overrides: Partial = {}): DevinCloudDirectoryEntry { + return { + id: "abc123", + title: "Fix the bug", + status: "idle", + statusText: "Waiting", + unread: false, + url: null, + repos: ["acme/project"], + pullRequests: [], + model: "devin-swe-2-low", + platform: "linux", + origin: "ADE", + excerpt: null, + createdAt: null, + updatedAt: null, + ...overrides, + }; +} + +function fixture() { + const lanes = { + list: vi.fn().mockResolvedValue([lane()]), + importBranch: vi.fn().mockResolvedValue(lane("imported", "devin/pr")), + create: vi.fn().mockResolvedValue(lane("created", "devin/new")), + updateAppearance: vi.fn(), + }; + const chats = { + list: vi.fn().mockResolvedValue([]), + openDevinCloudChat: vi.fn().mockResolvedValue({ sessionId: "chat-1" }), + openCursorCloudChat: vi.fn().mockResolvedValue({ sessionId: "chat-1" }), + interrupt: vi.fn().mockResolvedValue(undefined), + createDevinCloudChat: vi.fn().mockResolvedValue({ id: "chat-1" }), + send: vi.fn().mockResolvedValue(undefined), + }; + const directory = { + list: vi.fn().mockResolvedValue([]), + find: vi.fn().mockResolvedValue(entry()), + cancel: vi.fn().mockResolvedValue(undefined), + archive: vi.fn().mockResolvedValue(undefined), + invalidate: vi.fn(), + }; + const deps = { + projectRoot: "/tmp/project", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + devinDirectory: directory, + cursorFleet: null, + lanes, + chats, + } as unknown as CloudAgentsServiceDeps; + return { service: createCloudAgentsService(deps), lanes, chats, directory }; +} + +beforeEach(() => { + git.runGit.mockReset(); + git.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "remote") return { exitCode: 0, stdout: originUrl, stderr: "" }; + if (args[0] === "symbolic-ref") return { exitCode: 0, stdout: "origin/main", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); +}); + +describe("cloud agent lane ownership", () => { + it("refuses to move the primary lane to a cloud", async () => { + const { service, lanes, chats } = fixture(); + lanes.list.mockResolvedValue([lane("primary", "main", "primary")]); + + await expect(service.launch({ ...launchArgs, laneId: "primary" })).rejects.toThrow(/primary lane can't move/); + expect(chats.createDevinCloudChat).not.toHaveBeenCalled(); + }); + + it.each([ + { status: "active", devinSessionId: "abc123", startedAt: "2020-01-01T00:00:00.000Z", blocked: true }, + { status: "idle", devinSessionId: null, startedAt: new Date().toISOString(), blocked: true }, + { status: "ended", devinSessionId: null, startedAt: new Date().toISOString(), blocked: false }, + ])("handles an existing $status chat before launch", async ({ status, devinSessionId, startedAt, blocked }) => { + const { service, chats } = fixture(); + chats.list.mockResolvedValue([{ laneId: "cloud-lane", status, devinSessionId, startedAt, + devinCloud: { transport: "acp", version: "devin-swe-2-low" } }]); + + if (blocked) { + await expect(service.launch(launchArgs)).rejects.toThrow(/already working in this lane/); + expect(chats.createDevinCloudChat).not.toHaveBeenCalled(); + } else { + await expect(service.launch(launchArgs)).resolves.toMatchObject({ laneId: "cloud-lane" }); + } + }); + + it("rejects a concurrent launch into the same lane before either chat has a session id", async () => { + const { service, chats } = fixture(); + let release!: (value: { id: string }) => void; + let entered!: () => void; + const started = new Promise((resolve) => { entered = resolve; }); + chats.createDevinCloudChat.mockImplementation(() => new Promise((resolve) => { release = resolve; entered(); })); + const first = service.launch(launchArgs); + await started; + await expect(service.launch(launchArgs)).rejects.toThrow(/already working in this lane/); + release({ id: "chat-1" }); + await expect(first).resolves.toMatchObject({ chatSessionId: "chat-1" }); + expect(chats.createDevinCloudChat).toHaveBeenCalledTimes(1); + }); + + it("shares one in-flight open of the same Devin session", async () => { + const { service, directory, chats } = fixture(); + let release!: (value: DevinCloudDirectoryEntry) => void; + let entered!: () => void; + const started = new Promise((resolve) => { entered = resolve; }); + directory.find.mockImplementation(() => new Promise((resolve) => { release = resolve; entered(); })); + const first = service.open({ provider: "devin", id: "abc123" }); + await started; + const second = service.open({ provider: "devin", id: "devin-abc123" }); + release(entry()); + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ chatSessionId: "chat-1" }), + expect.objectContaining({ chatSessionId: "chat-1" }), + ]); + expect(directory.find).toHaveBeenCalledTimes(1); + expect(chats.openDevinCloudChat).toHaveBeenCalledTimes(1); + }); + + it("never imports a PR branch from another repository", async () => { + const { service, directory, lanes } = fixture(); + directory.find.mockResolvedValue(entry({ + repos: ["someone/else"], + pullRequests: [{ url: "https://github.com/someone/else/pull/3", number: 3, state: "open", title: null, + headRef: "foreign-branch", baseRef: "main", additions: null, deletions: null }], + })); + + await expect(service.open({ provider: "devin", id: "abc123" })).rejects.toThrow(/not this project/); + expect(lanes.importBranch).not.toHaveBeenCalled(); + expect(lanes.create).not.toHaveBeenCalled(); + }); + + it("makes a fresh lane when the PR branch is held by the primary or an archived lane", async () => { + const { service, directory, lanes, chats } = fixture(); + directory.find.mockResolvedValue(entry({ pullRequests: [{ + url: "https://github.com/acme/project/pull/3", number: 3, state: "open", title: null, + headRef: "main", baseRef: "main", additions: null, deletions: null, + }] })); + lanes.list.mockResolvedValue([lane("primary", "main", "primary")]); + lanes.importBranch.mockRejectedValue(new Error("branch already exists")); + + await expect(service.open({ provider: "devin", id: "abc123" })).resolves.toMatchObject({ + laneId: "created", createdLane: true, + }); + expect(chats.openDevinCloudChat).toHaveBeenCalledWith(expect.objectContaining({ laneId: "created" })); + }); + + it.each([ + { outcome: "behind", stderr: "non-fast-forward", ancestor: true, launches: true, failure: null }, + { outcome: "diverged", stderr: "non-fast-forward", ancestor: false, launches: false, failure: /diverged from origin/ }, + { outcome: "push denied", stderr: "permission denied", ancestor: false, launches: false, failure: /Could not push/ }, + ])("handles a $outcome branch push before launch", async ({ stderr, ancestor, launches, failure }) => { + const { service, chats } = fixture(); + git.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "remote") return { exitCode: 0, stdout: originUrl, stderr: "" }; + if (args[0] === "symbolic-ref") return { exitCode: 0, stdout: "origin/main", stderr: "" }; + if (args[0] === "push") return { exitCode: 1, stdout: "", stderr }; + if (args[0] === "fetch") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-parse") return { exitCode: 0, stdout: "a".repeat(40), stderr: "" }; + if (args[0] === "merge-base") return { exitCode: ancestor ? 0 : 1, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + if (launches) { + await expect(service.launch(launchArgs)).resolves.toMatchObject({ laneId: "cloud-lane" }); + expect(git.runGit).toHaveBeenCalledWith(["merge-base", "--is-ancestor", "HEAD", "a".repeat(40)], expect.anything()); + } else { + await expect(service.launch(launchArgs)).rejects.toThrow(failure!); + expect(chats.createDevinCloudChat).not.toHaveBeenCalled(); + } + }); + + it("rejects malformed Devin ids before asking the relay to open them", async () => { + const { service, directory } = fixture(); + await expect(service.open({ provider: "devin", id: "abc; touch /tmp/unsafe" })).rejects.toThrow(); + expect(directory.find).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/cloudAgentsService.ts b/apps/desktop/src/main/services/chat/cloudAgentsService.ts new file mode 100644 index 0000000000..6a0b5e6025 --- /dev/null +++ b/apps/desktop/src/main/services/chat/cloudAgentsService.ts @@ -0,0 +1,179 @@ +/** + * Cloud agents: one contract over Devin Cloud sessions and Cursor Cloud agents. + * + * Each provider keeps its own client — Devin through the ACP relay directory + * (`devin acp --cloud`, CLI login, no token), Cursor through its fleet service + * (SDK + API key) — and this service maps both onto `CloudAgent` rows and the + * same verbs: list, open, stop, archive, launch. Each provider is one + * strategy (`cloudAgents/devinCloudAgents.ts`, `cloudAgents/cursorCloudAgents.ts`) + * over a shared context (`cloudAgents/cloudAgentsContext.ts`). + * + * "Open" is the verb that makes a cloud agent part of ADE. It finds or makes + * the agent's cloud lane — a lane on the agent's branch whose machine is the + * provider's cloud — and opens the agent's chat in it. The chat is live: the + * Devin chat rides the relay, the Cursor chat the SDK stream. + */ +import type { Logger } from "../logging/logger"; +import type { LaneSummary } from "../../../shared/types/lanes"; +import type { + AgentChatDevinCloudConfig, + AgentChatSessionSummary, +} from "../../../shared/types/chat"; +import type { + CloudAgentArchiveArgs, + CloudAgentLaunchArgs, + CloudAgentList, + CloudAgentListArgs, + CloudAgentOpenResult, + CloudAgentProvider, + CloudAgentRef, +} from "../../../shared/types/cloudAgents"; +import type { CursorCloudFleetResult } from "../../../shared/types/config"; +import { normalizeDevinCloudSessionId } from "../../../shared/devinCloud"; +import { createDevinCloudDirectory, type DevinCloudDirectory } from "./devinCloudDirectory"; +import { + ONE_AGENT_PER_LANE_MESSAGE, + createCloudAgentsContext, + type CloudAgentsServiceDeps, + type CloudAgentsStrategy, +} from "./cloudAgents/cloudAgentsContext"; + +export type { CloudAgentsServiceDeps }; +import { devinCloudAgents } from "./cloudAgents/devinCloudAgents"; +import { cursorCloudAgents } from "./cloudAgents/cursorCloudAgents"; + + + +/** + * The service over both strategies. Each verb dispatches to the provider's + * strategy; two calls that would race on the same thing share one flight + * instead — opening one agent twice, or two launches into one lane. + */ +export function createCloudAgentsService(deps: CloudAgentsServiceDeps) { + const ctx = createCloudAgentsContext(deps); + const strategies: Record = { + devin: devinCloudAgents(ctx, deps.devinDirectory), + cursor: cursorCloudAgents(ctx, { + fleet: deps.cursorFleet, + archive: deps.cursorArchive ?? null, + createRun: deps.cursorCreateRun ?? null, + unavailableReason: deps.cursorUnavailableReason ?? null, + }), + }; + const inflight = new Map>(); + const shareFlight = (key: string, run: () => Promise): Promise => { + const current = inflight.get(key); + if (current) return current; + const flight = run().finally(() => { + if (inflight.get(key) === flight) inflight.delete(key); + }); + inflight.set(key, flight); + return flight; + }; + + const list = async (args: CloudAgentListArgs): Promise => + await strategies[args.provider].list(args.force === true); + + /** One id form per provider for every verb: Devin's bare web id, validated. */ + const agentId = (provider: CloudAgentProvider, id: string): string => + provider === "devin" ? normalizeDevinCloudSessionId(id) : id.trim(); + + const open = async (ref: CloudAgentRef): Promise => { + const id = agentId(ref.provider, ref.id); + return await shareFlight(`${ref.provider}:${id}`, () => strategies[ref.provider].open(id)); + }; + + const stop = async (ref: CloudAgentRef): Promise<{ stopped: true }> => + await strategies[ref.provider].stop(agentId(ref.provider, ref.id)); + + const archive = async (args: CloudAgentArchiveArgs): Promise<{ archived: boolean }> => + await strategies[args.provider].archive(agentId(args.provider, args.id), args.archived); + + const launch = async (args: CloudAgentLaunchArgs): Promise => { + const laneId = args.laneId?.trim(); + if (!laneId) return await strategies[args.provider].launch(args); + // A second launch into a lane whose first is still starting would pass + // the running-chat check (no chat yet) and put two VMs on one branch. + const key = `lane:${laneId}`; + if (inflight.has(key)) throw new Error(ONE_AGENT_PER_LANE_MESSAGE); + return await shareFlight(key, () => strategies[args.provider].launch({ ...args, laneId })); + }; + + return { list, open, stop, archive, launch }; +} + +export type CloudAgentsService = ReturnType; + +/** + * Build the service from the host's own services. Both hosts (the `ade serve` + * brain and the in-process desktop runtime) call this, so the wiring cannot + * drift between them. + */ +export function createCloudAgentsServiceFromHost(host: { + projectRoot: string; + logger: Logger; + laneService: { + list: CloudAgentsServiceDeps["lanes"]["list"]; + importBranch: CloudAgentsServiceDeps["lanes"]["importBranch"]; + create: CloudAgentsServiceDeps["lanes"]["create"]; + updateAppearance: CloudAgentsServiceDeps["lanes"]["updateAppearance"]; + }; + getAgentChatService: () => { + listSessions: (laneId?: string, options?: { includeArchived?: boolean }) => Promise; + openDevinCloudChat: CloudAgentsServiceDeps["chats"]["openDevinCloudChat"]; + openCursorCloudChat: CloudAgentsServiceDeps["chats"]["openCursorCloudChat"]; + interrupt: (args: { sessionId: string }) => Promise; + createSession: (args: { + laneId: string; + provider: "devin"; + model: string; + modelId?: string; + devinCloud?: AgentChatDevinCloudConfig | null; + }) => Promise<{ id: string }>; + sendMessage: (args: { sessionId: string; text: string }) => Promise; + } | null; + cursorFleet: CloudAgentsServiceDeps["cursorFleet"]; + cursorCreateRun?: CloudAgentsServiceDeps["cursorCreateRun"]; + archiveCursorAgent?: ((agentId: string) => Promise) | null; + unarchiveCursorAgent?: ((agentId: string) => Promise) | null; + resolveDevinBinary: () => Promise<{ path: string; env: NodeJS.ProcessEnv } | null>; +}): CloudAgentsService { + const chats = () => { + const service = host.getAgentChatService(); + if (!service) throw new Error("Agent chat service not available."); + return service; + }; + const { archiveCursorAgent, unarchiveCursorAgent } = host; + return createCloudAgentsService({ + projectRoot: host.projectRoot, + logger: host.logger, + devinDirectory: createDevinCloudDirectory({ logger: host.logger, cwd: host.projectRoot, resolveBinary: host.resolveDevinBinary }), + cursorFleet: host.cursorFleet, + cursorArchive: archiveCursorAgent && unarchiveCursorAgent + ? (agentId, archived) => (archived ? archiveCursorAgent(agentId) : unarchiveCursorAgent(agentId)) + : null, + lanes: { + list: (args) => host.laneService.list(args), + importBranch: (args) => host.laneService.importBranch(args), + create: (args) => host.laneService.create(args), + updateAppearance: (args) => host.laneService.updateAppearance(args), + }, + chats: { + // Archived chats included: an agent linked to one is still that chat's, + // and opening it must reuse it rather than make a second lane. + list: () => chats().listSessions(undefined, { includeArchived: true }), + openDevinCloudChat: (args) => chats().openDevinCloudChat(args), + openCursorCloudChat: (args) => chats().openCursorCloudChat(args), + interrupt: (sessionId) => chats().interrupt({ sessionId }), + createDevinCloudChat: (args) => chats().createSession({ + laneId: args.laneId, + provider: "devin", + model: "adaptive", + modelId: "devin/adaptive", + devinCloud: args.devinCloud, + }), + send: (args) => chats().sendMessage(args), + }, + cursorCreateRun: host.cursorCreateRun ?? null, + }); +} diff --git a/apps/desktop/src/main/services/chat/devinCloudBinary.ts b/apps/desktop/src/main/services/chat/devinCloudBinary.ts new file mode 100644 index 0000000000..b00400d4f0 --- /dev/null +++ b/apps/desktop/src/main/services/chat/devinCloudBinary.ts @@ -0,0 +1,18 @@ +import { resolveAcpExecutable } from "../ai/acpExecutables"; +import { detectCliAuthStatuses } from "../ai/authDetector"; + +/** + * The `devin` binary for relay calls, with this process's env. Null when the + * CLI is not installed; a signed-out CLI still resolves, and the relay's own + * error then says to run `devin auth login`. + */ +export async function resolveDevinCloudBinary(): Promise<{ path: string; env: NodeJS.ProcessEnv } | null> { + const statuses = await detectCliAuthStatuses({ skipAuthProbe: true }).catch(() => []); + const cli = statuses.find((entry) => entry.cli === "devin") ?? null; + if (cli && !cli.installed) return null; + const executable = resolveAcpExecutable("devin", { + env: process.env, + ...(cli?.path ? { auth: [{ type: "cli-subscription", cli: "devin", path: cli.path, authenticated: cli.authenticated, verified: cli.verified }] } : {}), + }); + return { path: executable.path, env: { ...process.env } }; +} diff --git a/apps/desktop/src/main/services/chat/devinCloudDirectory.ts b/apps/desktop/src/main/services/chat/devinCloudDirectory.ts new file mode 100644 index 0000000000..dedc7d6b9d --- /dev/null +++ b/apps/desktop/src/main/services/chat/devinCloudDirectory.ts @@ -0,0 +1,293 @@ +/** + * Devin Cloud session directory over the ACP relay. + * + * `devin acp --cloud` answers `session/list` with every cloud session the + * signed-in account can see, and each row carries what the fleet needs: title, + * status, unread, repos, pull requests with their head branch, model and + * platform. It rides the CLI's own `devin auth login`, so listing needs no API + * token. A list is one short-lived relay process: spawn, initialize, list, + * exit — about a second — so nothing stays running while no panel is open. + */ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Logger } from "../logging/logger"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; +import { + AcpConnectionClosedError, + AcpRpcError, + createAcpConnection, + initializeAcpConnection, + type AcpConnection, +} from "./acpHost/acpConnection"; +import { devinCloudAcpSessionId, devinCloudDialect, devinCloudBareSessionId } from "./acpHost/acpDialects/devinCloud"; +import type { CloudAgentPullRequest, CloudAgentStatus } from "../../../shared/types/cloudAgents"; +import { pullRequestNumber } from "../../../shared/cursorCloudRepoMatch"; + +export type DevinCloudDirectoryEntry = { + /** Bare id (``), the form app.devin.ai uses. */ + id: string; + title: string; + status: CloudAgentStatus; + statusText: string | null; + unread: boolean; + url: string | null; + repos: string[]; + pullRequests: CloudAgentPullRequest[]; + model: string | null; + platform: string | null; + origin: string | null; + excerpt: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +const RELAY_TIMEOUT_MS = 30_000; +const CACHE_TTL_MS = 4_000; + +const ORIGIN_LABELS: Record = { + api: "API", + webapp: "Web", + slack: "Slack", + teams: "Teams", + linear: "Linear", + jira: "Jira", + cli: "CLI", + desktop: "Desktop", + automation: "Automation", + code_scan: "Code scan", +}; + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function num(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** The number in a GitHub pull request URL (`…/pull/123`). */ +function readPullRequests(value: unknown): CloudAgentPullRequest[] { + if (!Array.isArray(value)) return []; + const out: CloudAgentPullRequest[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const record = raw as Record; + const url = text(record.url); + if (!url) continue; + const state = text(record.state)?.toLowerCase() ?? null; + out.push({ + url, + number: pullRequestNumber(url), + state: state === "open" || state === "merged" || state === "closed" || state === "draft" ? state : null, + title: text(record.title), + headRef: text(record.headRef), + baseRef: text(record.baseRef), + additions: num(record.additions), + deletions: num(record.deletions), + }); + } + return out; +} + +/** Map the relay's status fields onto the shared cloud-agent status. */ +export function devinDirectoryStatus(meta: Record): { status: CloudAgentStatus; statusText: string | null } { + if (meta["cognition.ai/isArchived"] === true) return { status: "archived", statusText: "Archived" }; + const statusEnum = text(meta["cognition.ai/statusEnum"])?.toLowerCase() ?? null; + const sessionStatus = text(meta["cognition.ai/sessionStatus"])?.toLowerCase() ?? null; + const outcome = text(meta["cognition.ai/finishedOutcome"])?.toLowerCase() ?? null; + if (statusEnum === "error" || sessionStatus === "error") return { status: "failed", statusText: "Errored" }; + if (statusEnum === "working") return { status: "working", statusText: "Working" }; + if (statusEnum === "blocked") { + if (meta["cognition.ai/userActionRequired"] != null || meta["cognition.ai/pendingRequest"] != null) { + return { status: "needs_you", statusText: "Needs your approval" }; + } + return { status: "idle", statusText: "Waiting for your reply" }; + } + if (statusEnum === "finished" || sessionStatus === "exit" || sessionStatus === "suspended") { + return { + status: "finished", + statusText: outcome === "stopped" ? "Stopped" : sessionStatus === "suspended" ? "Asleep" : "Finished", + }; + } + return { status: "starting", statusText: "Starting" }; +} + +function readEntry(raw: unknown): DevinCloudDirectoryEntry | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + const acpId = text(record.sessionId); + if (!acpId) return null; + const meta = (record._meta && typeof record._meta === "object" ? record._meta : {}) as Record; + const { status, statusText } = devinDirectoryStatus(meta); + const repos = Array.isArray(meta["cognition.ai/sessionRepos"]) + ? (meta["cognition.ai/sessionRepos"] as unknown[]) + .map((repo) => (repo && typeof repo === "object" ? text((repo as Record).name) : null)) + .filter((repo): repo is string => Boolean(repo)) + : []; + const origin = text(meta["cognition.ai/sessionOrigin"]); + const excerpt = text(meta["cognition.ai/messageExcerpts"]); + return { + id: devinCloudBareSessionId(acpId), + title: text(record.title) ?? excerpt?.slice(0, 80) ?? "Devin session", + status, + statusText, + unread: meta["cognition.ai/isUnread"] === true, + url: text(meta["cognition.ai/url"]), + repos, + pullRequests: readPullRequests(meta["cognition.ai/sessionPRs"]), + model: text(meta["cognition.ai/devinVersionOverride"]), + platform: text(meta["cognition.ai/platform"]), + origin: origin ? ORIGIN_LABELS[origin] ?? origin : null, + excerpt: excerpt ? excerpt.slice(0, 240) : null, + createdAt: text(meta["cognition.ai/createdAt"]), + updatedAt: text(record.updatedAt) ?? text(meta["cognition.ai/sortUpdatedAt"]), + }; +} + +type RelayClient = Pick; + +/** The relay's own words for an ACP error, without the host's prefix. */ +function relayErrorText(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/^ACP \S+ failed \(-?\d+\): /, ""); +} + +/** + * One scripted relay session: spawn `devin acp --cloud`, initialize, run + * `script`, exit. It rides the ACP host's connection, so framing, the + * handshake and the process-tree kill are the chat's own. Session updates the + * relay streams meanwhile (a load's replay) are ignored. Rejects on timeout, a + * JSON-RPC error, or the process dying first. + */ +async function withRelay( + args: { binaryPath: string; env: NodeJS.ProcessEnv; cwd: string }, + script: (client: RelayClient) => Promise, +): Promise { + const spawnPlan = devinCloudDialect.buildSpawnPlan({ binaryPath: args.binaryPath, cwd: args.cwd, baseEnv: args.env }); + let stderrTail = ""; + const connection = createAcpConnection({ + dialect: devinCloudDialect, + spawnPlan, + // Spawned here rather than by the connection so stdin gets an error + // listener: a relay that dies mid-write raises EPIPE on the pipe, and an + // unheard stream error is an uncaught exception in the main process. + spawnOverride: (plan) => { + const invocation = resolveCliSpawnInvocation(plan.command, plan.args, plan.env); + const child = spawn(invocation.command, invocation.args, { + cwd: plan.cwd, + env: plan.env, + stdio: ["pipe", "pipe", "pipe"], + // A process group on POSIX so the tree kill reaches the relay's + // children; Windows uses `taskkill /T /F` instead. + detached: process.platform !== "win32", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }) as ChildProcessWithoutNullStreams; + child.stdin.on("error", (error) => connection.dispose(`relay stdin failed: ${error.message}`)); + return child; + }, + }); + connection.onExit((exit) => { + stderrTail = exit.stderrTail; + }); + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + (async () => { + await initializeAcpConnection({ connection, dialect: devinCloudDialect, timeoutMs: RELAY_TIMEOUT_MS }); + return await script(connection); + })(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Devin Cloud did not answer in time.")), RELAY_TIMEOUT_MS); + }), + ]); + } catch (error) { + if (error instanceof AcpConnectionClosedError) { + const hint = /not logged in|auth login|unauthori/i.test(stderrTail) ? " Run `devin auth login`." : ""; + throw new Error(`Devin Cloud relay exited.${hint}`); + } + if (error instanceof AcpRpcError) throw new Error(relayErrorText(error) || "Devin Cloud rejected the request."); + throw error; + } finally { + if (timer) clearTimeout(timer); + // Ends stdin, then SIGTERM to the tree with a SIGKILL after the grace. + connection.dispose("devin cloud directory done"); + } +} + +export function createDevinCloudDirectory(deps: { + logger: Logger; + cwd: string; + /** Resolves the `devin` binary and the env to run it with; null when absent. */ + resolveBinary: () => Promise<{ path: string; env: NodeJS.ProcessEnv } | null>; +}) { + let cache: { at: number; entries: DevinCloudDirectoryEntry[] } | null = null; + + const relay = async (script: (client: RelayClient) => Promise): Promise => { + const binary = await deps.resolveBinary(); + if (!binary) throw new Error("Install the devin CLI and run `devin auth login` to use Devin Cloud."); + return await withRelay({ binaryPath: binary.path, env: binary.env, cwd: deps.cwd }, script); + }; + + /** Join a session long enough to run `action` on it. */ + const withSession = async (id: string, action: (client: RelayClient, acpId: string) => Promise) => { + const acpId = devinCloudAcpSessionId(id); + await relay(async (client) => { + await client.request("session/load", { sessionId: acpId, cwd: deps.cwd, mcpServers: [] }); + await action(client, acpId); + }); + cache = null; + }; + + /** Stop what the session is doing. It stays resumable. */ + const cancel = async (id: string): Promise => { + await withSession(id, async (client, acpId) => { + client.notify("session/cancel", { sessionId: acpId }); + // A notification has no answer; give the relay a beat to forward it + // before the process goes away. + await new Promise((resolve) => setTimeout(resolve, 800)); + }); + }; + + /** Archive (and suspend) the session: the relay's own `/archive` command. */ + const archive = async (id: string): Promise => { + await withSession(id, async (client, acpId) => { + await client.request("session/prompt", { sessionId: acpId, prompt: [{ type: "text", text: "/archive" }] }); + }); + }; + let inflight: Promise | null = null; + + const list = async (options?: { force?: boolean }): Promise => { + if (!options?.force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.entries; + if (inflight) return await inflight; + inflight = (async () => { + const result = await relay((client) => client.request<{ sessions?: unknown[] }>("session/list", {})); + const entries = (result?.sessions ?? []).map(readEntry).filter((entry): entry is DevinCloudDirectoryEntry => entry !== null); + cache = { at: Date.now(), entries }; + return entries; + })().finally(() => { + inflight = null; + }); + try { + return await inflight; + } catch (error) { + deps.logger.warn("devin_cloud_directory.list_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const find = async (id: string, options?: { force?: boolean }): Promise => { + const bare = devinCloudBareSessionId(id); + const entries = await list(options); + return entries.find((entry) => entry.id === bare) ?? null; + }; + + const invalidate = () => { + cache = null; + }; + + return { list, find, invalidate, cancel, archive }; +} + +export type DevinCloudDirectory = ReturnType; diff --git a/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts b/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts index 31d39cfa2f..060042dfd5 100644 --- a/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts +++ b/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts @@ -36,6 +36,7 @@ import { buildTrackedCliLaunchCommand, buildTrackedCliResumeLaunchCommand } from import type { TerminalResumeMetadata } from "../../../shared/types/sessions"; import { allProviderKeySpecs } from "../../../renderer/components/settings/providers/keys/providerKeySpecs"; import type { HarnessPreset, HarnessPresetBody, HarnessPresetSource } from "../../../shared/harnessPresets"; +import { isHarnessPresetBody } from "../../../shared/harnessPresets"; import type { ApiCredentialSummary } from "../../../shared/types/apiCredentials"; import { createAccountSettingsStore } from "../../../../../ade-cli/src/services/account/accountSettingsStore"; @@ -102,6 +103,7 @@ describe("credential store provider mapping", () => { // getting this wrong files the key where nothing looks for it. it("uses the shared mapping in every renderer provider-key spec", () => { for (const spec of allProviderKeySpecs()) { + if (!isHarnessPresetBody(spec.provider)) continue; expect(spec.credentialProvider).toBe( HARNESS_CREDENTIAL_STORE_PROVIDER[spec.provider as HarnessPresetBody], ); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index db18cf9fb4..2e4d9c70ac 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -707,6 +707,12 @@ import type { CursorCloudFleetResult, CursorCloudFleetEvent, CursorCloudPullIntoLaneResult, + CloudAgentArchiveArgs, + CloudAgentLaunchArgs, + CloudAgentList, + CloudAgentListArgs, + CloudAgentOpenResult, + CloudAgentRef, CursorAgentUsage, CursorAgentUsageRequest, AgentToolsCacheSnapshot, @@ -883,6 +889,7 @@ import type { createAutomationIngressService } from "../automations/automationIn import type { LinearIngressService, LinearIngressStatus } from "../automations/linearIngressService"; import type { CursorCloudIngressService } from "../automations/cursorCloudIngressService"; import type { CursorCloudFleetService } from "../chat/cursorCloudFleetService"; +import type { CloudAgentsService } from "../chat/cloudAgentsService"; import type { createGithubPollingService } from "../automations/githubPollingService"; import { ADE_ACTION_ALLOWLIST, getAdeActionDomainServices, listAllowedAdeActionNames } from "../adeActions/registry"; import { createSessionBoardMoveActions } from "../adeActions/sessionBoardMove"; @@ -1200,6 +1207,7 @@ export type AppContext = { linearIngressService?: LinearIngressService | null; cursorCloudIngressService?: CursorCloudIngressService | null; cursorCloudFleetService?: CursorCloudFleetService | null; + cloudAgentsService?: CloudAgentsService | null; githubPollingService?: ReturnType | null; projectConfigService: ReturnType | null; projectSecretService?: ReturnType | null; @@ -5804,6 +5812,32 @@ export function registerIpc({ }, ); + ipcMain.handle(IPC.cloudAgentsList, async (_event, arg: CloudAgentListArgs): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["cloudAgentsService"] as const); + return await ctx.cloudAgentsService.list(arg); + }); + ipcMain.handle(IPC.cloudAgentsOpen, async (_event, arg: CloudAgentRef): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["cloudAgentsService"] as const); + return await ctx.cloudAgentsService.open(arg); + }); + ipcMain.handle(IPC.cloudAgentsStop, async (_event, arg: CloudAgentRef): Promise<{ stopped: true }> => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["cloudAgentsService"] as const); + return await ctx.cloudAgentsService.stop(arg); + }); + ipcMain.handle(IPC.cloudAgentsLaunch, async (_event, arg: CloudAgentLaunchArgs): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["cloudAgentsService"] as const); + return await ctx.cloudAgentsService.launch(arg); + }); + ipcMain.handle(IPC.cloudAgentsArchive, async (_event, arg: CloudAgentArchiveArgs): Promise<{ archived: boolean }> => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["cloudAgentsService"] as const); + return await ctx.cloudAgentsService.archive(arg); + }); + ipcMain.handle(IPC.syncGetStatus, async (event, arg?: SyncGetStatusArgs): Promise => { const params = { includeTransferReadiness: arg?.includeTransferReadiness === true, diff --git a/apps/desktop/src/main/services/lanes/cloudLaneMirror.ts b/apps/desktop/src/main/services/lanes/cloudLaneMirror.ts new file mode 100644 index 0000000000..4573234b60 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/cloudLaneMirror.ts @@ -0,0 +1,66 @@ +import type { Logger } from "../logging/logger"; +import { runGit } from "../git/git"; +import { getErrorMessage } from "../shared/utils"; + +export type CloudLaneMirrorSyncResult = + | "fast_forwarded" + | "skipped_no_branch" + | "skipped_fetch_failed" + | "skipped_wrong_branch" + | "skipped_dirty" + | "skipped_not_fast_forward" + | "failed"; + +/** + * Fast-forward a cloud lane's mirror worktree to what the cloud agent pushed. + * + * The mirror is never edited in ADE, so a plain fast-forward is always the + * right move. Anything else is left alone and logged rather than merged, + * because merging would invent history the cloud agent never wrote: + * - the worktree is not checked out on `branch` (someone switched it), or + * - it has uncommitted changes, or + * - the branch diverged. + */ +export async function syncCloudLaneMirror(args: { + worktreePath: string | null | undefined; + branch: string | null | undefined; + logger: Logger; +}): Promise { + const branch = args.branch?.trim(); + const cwd = args.worktreePath?.trim(); + if (!branch || !cwd) return "skipped_no_branch"; + const { logger } = args; + try { + const fetched = await runGit(["fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { + cwd, + timeoutMs: 60_000, + }); + if (fetched.exitCode !== 0) { + logger.info("cloud_lane_mirror.fetch_failed", { branch, stderr: fetched.stderr.slice(-300) }); + return "skipped_fetch_failed"; + } + const head = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd, timeoutMs: 15_000 }); + const checkedOut = head.exitCode === 0 ? head.stdout.trim() : null; + if (checkedOut !== branch) { + logger.info("cloud_lane_mirror.wrong_branch", { branch, checkedOut }); + return "skipped_wrong_branch"; + } + const status = await runGit(["status", "--porcelain=v1", "--untracked-files=no"], { cwd, timeoutMs: 15_000 }); + if (status.exitCode !== 0 || status.stdout.trim().length) { + logger.info("cloud_lane_mirror.dirty", { branch }); + return "skipped_dirty"; + } + const merged = await runGit(["merge", "--ff-only", "--quiet", `refs/remotes/origin/${branch}`], { + cwd, + timeoutMs: 30_000, + }); + if (merged.exitCode !== 0) { + logger.info("cloud_lane_mirror.not_fast_forward", { branch, stderr: merged.stderr.slice(-300) }); + return "skipped_not_fast_forward"; + } + return "fast_forwarded"; + } catch (error) { + logger.warn("cloud_lane_mirror.sync_failed", { branch, error: getErrorMessage(error) }); + return "failed"; + } +} diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 6a1cf97d66..c116a972b4 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -24,7 +24,8 @@ vi.mock("../git/git", () => ({ runGitMergeTree: (...args: unknown[]) => mockGit.runGitMergeTree(...args), })); -vi.mock("node:child_process", () => ({ +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), spawn: (...args: unknown[]) => mockChildProcess.spawn(...args), })); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a4a8ebf1ec..ce2f2d2d1d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -1386,10 +1386,12 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { "kimi", "grok", "copilot", + "devin", "qwen-chat", "kimi-chat", "grok-chat", "copilot-chat", + "devin-chat", "aider", "continue", "other", @@ -1682,7 +1684,8 @@ function isPersistedChatToolType(toolType: TerminalToolType | null): boolean { || toolType === "qwen-chat" || toolType === "kimi-chat" || toolType === "grok-chat" - || toolType === "copilot-chat"; + || toolType === "copilot-chat" + || toolType === "devin-chat"; } function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undefined): string | null { diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 77fe88aa63..e126235cf3 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -705,10 +705,12 @@ export function createSessionService({ "kimi", "grok", "copilot", + "devin", "qwen-chat", "kimi-chat", "grok-chat", "copilot-chat", + "devin-chat", "aider", "continue", "other" @@ -731,6 +733,7 @@ export function createSessionService({ if (normalized.startsWith("chat:kimi:")) return "kimi-chat"; if (normalized.startsWith("chat:grok:")) return "grok-chat"; if (normalized.startsWith("chat:copilot:")) return "copilot-chat"; + if (normalized.startsWith("chat:devin:")) return "devin-chat"; if (normalized === "chat:codex" || normalized.startsWith("chat:codex:")) return "codex-chat"; return toolType; }; @@ -881,6 +884,9 @@ export function createSessionService({ } else if (toolType === "copilot-chat") { legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); legacyChatParams.push("chat:copilot:%"); + } else if (toolType === "devin-chat") { + legacyChatClauses.push("lower(coalesce(s.resume_command, '')) like ?"); + legacyChatParams.push("chat:devin:%"); } } @@ -2287,8 +2293,20 @@ export function createSessionService({ }); }, - clearAttentionRequest(sessionId: string): boolean { + /** + * Clears the attention marker. When `expectedSource` is given the clear is + * conditional on the persisted source still matching — a newer request a + * different owner wrote in the meantime survives. + */ + clearAttentionRequest(sessionId: string, expectedSource?: SessionAttentionSource): boolean { return mutateSessionMeta(sessionId, (id) => { + if (expectedSource) { + db.run( + "update terminal_sessions set attention_requested_at = null, attention_message = null, attention_source = null where id = ? and attention_source = ?", + [id, expectedSource], + ); + return; + } db.run( "update terminal_sessions set attention_requested_at = null, attention_message = null, attention_source = null where id = ?", [id], diff --git a/apps/desktop/src/main/services/shared/providerConfigHomes.ts b/apps/desktop/src/main/services/shared/providerConfigHomes.ts index 67fd900343..b516f80a61 100644 --- a/apps/desktop/src/main/services/shared/providerConfigHomes.ts +++ b/apps/desktop/src/main/services/shared/providerConfigHomes.ts @@ -140,6 +140,28 @@ export function openCodeDataDirs(args: HomeArg & { platform?: NodeJS.Platform } }); } +/** + * Devin's stored login: `credentials.toml` under the XDG data dir. `devin + * auth status` reports it as `$XDG_DATA_HOME/devin/credentials.toml` + * (`~/.local/share/devin` by default); Windows honours `%LOCALAPPDATA%`. + * XDG selects THE data directory — it is not a search path — so when the + * override is set only that location counts; returning the default alongside + * it would let a stale, logged-out credential mark the provider active. + * The auth detector and the per-turn account reader share this list so they + * never disagree about where the login lives. + */ +export function devinCredentialFiles(args: HomeArg & { platform?: NodeJS.Platform } = {}): string[] { + const env = args.env ?? process.env; + const home = baseHome(args); + const platform = args.platform ?? process.platform; + const xdgData = trimmed(env.XDG_DATA_HOME); + if (platform === "win32") { + return [path.join(trimmed(env.LOCALAPPDATA) ?? path.join(home, "AppData", "Local"), "devin", "credentials.toml")]; + } + const dataHome = xdgData ? path.resolve(xdgData) : path.join(home, ".local", "share"); + return [path.join(dataHome, "devin", "credentials.toml")]; +} + /** * Qwen's per-request usage files. `QWEN_RUNTIME_DIR` moves Qwen's runtime * state (usage included) away from the config home, and the Qwen binary diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 6446598b1e..59d83acf26 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -374,6 +374,12 @@ import type { CursorAgentUsageRequest, CursorCloudStreamRunRequest, CursorCloudStreamRunResult, + CloudAgentArchiveArgs, + CloudAgentLaunchArgs, + CloudAgentList, + CloudAgentListArgs, + CloudAgentOpenResult, + CloudAgentRef, AdeCliInstallResult, AdeCliStatus, OpenCodeRuntimeSnapshot, @@ -1345,7 +1351,7 @@ declare global { * have it and callers must guard before reaching for it. */ acpProviderDiagnostics?: (args: { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; runDoctor?: boolean; }, pin?: OpenProjectBinding | null) => Promise; /** @@ -1353,7 +1359,7 @@ declare global { * older host that predates it leaves this undefined. */ acpProviderUpdate?: (args: { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; }, pin?: OpenProjectBinding | null) => Promise; opencodeAuthMethods: (pin?: OpenProjectBinding | null) => Promise<{ methods: OpenCodeProviderAuthMethods }>; opencodeOAuthStart: (args: { @@ -1521,6 +1527,14 @@ declare global { getCloudRelayStatus: () => Promise; onEvent: (cb: (event: SyncStatusEventPayload) => void) => () => void; }; + /** Devin Cloud sessions and Cursor Cloud agents, one contract for both panels. */ + cloudAgents: { + list: (args: CloudAgentListArgs) => Promise; + open: (args: CloudAgentRef) => Promise; + stop: (args: CloudAgentRef) => Promise<{ stopped: true }>; + archive: (args: CloudAgentArchiveArgs) => Promise<{ archived: boolean }>; + launch: (args: CloudAgentLaunchArgs) => Promise; + }; agentTools: { detect: () => Promise; }; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 254027d483..701b8f35eb 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -8859,7 +8859,7 @@ describe("per-chat runtime routing", () => { await vi.advanceTimersByTimeAsync(750); await vi.runOnlyPendingTimersAsync(); - expect(streamRequests).toEqual([ + expect(streamRequests.slice(0, 3)).toEqual([ { id: "target-b", projectId: "project-b", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 91277f58fe..a936e74cb4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -234,6 +234,12 @@ import type { CursorAgentUsageRequest, CursorCloudStreamRunRequest, CursorCloudStreamRunResult, + CloudAgentArchiveArgs, + CloudAgentLaunchArgs, + CloudAgentList, + CloudAgentListArgs, + CloudAgentOpenResult, + CloudAgentRef, OpenCodeRuntimeSnapshot, SyncDesktopConnectionDraft, SyncCloudRelayStatus, @@ -5146,7 +5152,7 @@ const adeBridge = { ), ), acpProviderDiagnostics: async (args: { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; runDoctor?: boolean; }, pin?: OpenProjectBinding | null): Promise => // Unpinned stays on desktop IPC: it reports on the CLIs installed on the @@ -5157,7 +5163,7 @@ const adeBridge = { ? callPinnedRuntimeAction(pin, "ai", "acpProviderDiagnostics", { args }) : ipcRenderer.invoke(IPC.aiAcpProviderDiagnostics, args), acpProviderUpdate: async (args: { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; }, pin?: OpenProjectBinding | null): Promise => // Same rule as diagnostics: unpinned updates This computer's CLI, a pin // updates the named machine's. @@ -5671,6 +5677,28 @@ const adeBridge = { }; }, }, + cloudAgents: { + list: async (args: CloudAgentListArgs): Promise => + callProjectRuntimeActionOr("ai", "listCloudAgents", { args }, () => + ipcRenderer.invoke(IPC.cloudAgentsList, args), + ), + open: async (args: CloudAgentRef): Promise => + callProjectRuntimeActionOr("ai", "openCloudAgent", { args }, () => + ipcRenderer.invoke(IPC.cloudAgentsOpen, args), + ), + stop: async (args: CloudAgentRef): Promise<{ stopped: true }> => + callProjectRuntimeActionOr("ai", "stopCloudAgent", { args }, () => + ipcRenderer.invoke(IPC.cloudAgentsStop, args), + ), + launch: async (args: CloudAgentLaunchArgs): Promise => + callProjectRuntimeActionOr("ai", "launchCloudAgent", { args }, () => + ipcRenderer.invoke(IPC.cloudAgentsLaunch, args), + ), + archive: async (args: CloudAgentArchiveArgs): Promise<{ archived: boolean }> => + callProjectRuntimeActionOr("ai", "archiveCloudAgent", { args }, () => + ipcRenderer.invoke(IPC.cloudAgentsArchive, args), + ), + }, agentTools: { detect: async (): Promise => ipcRenderer.invoke(IPC.agentToolsDetect), diff --git a/apps/desktop/src/renderer/assets/provider-logos/devin-glyph.svg b/apps/desktop/src/renderer/assets/provider-logos/devin-glyph.svg new file mode 100644 index 0000000000..28505de3ec --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/devin-glyph.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/desktop/src/renderer/assets/provider-logos/devin.svg b/apps/desktop/src/renderer/assets/provider-logos/devin.svg new file mode 100644 index 0000000000..412081f055 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/devin.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 5824dbea94..15d9b86409 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4821,6 +4821,20 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { cursorCloudOpenChat: resolvedArg({ sessionId: "", session: null } as any), cursorCloudWatchMirror: resolvedArg(undefined), }, + cloudAgents: { + list: async (args: { provider: "devin" | "cursor" }) => ({ + provider: args.provider, + items: [], + fetchedAt: new Date().toISOString(), + capabilities: { stop: false, archive: false, vmShell: false, web: false }, + unavailableReason: null, + models: [], + }), + open: resolvedArg({ chatSessionId: "", laneId: "", laneName: null, createdLane: false }), + stop: resolvedArg({ stopped: true as const }), + archive: async (args: { archived: boolean }) => ({ archived: args.archived }), + launch: resolvedArg({ chatSessionId: "", laneId: "", laneName: null, createdLane: false }), + }, agentTools: { detect: resolved([]), }, @@ -5807,6 +5821,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { return mockAgentChatSummaryFromSession(session) ?? null; }, create: resolvedArg({ id: "mock" }), + launchCli: resolvedArg({ sessionId: "mock-cli", ptyId: "mock-pty", pid: null, attachedLinearIssueIds: [] } as any), suggestLaneName: resolvedArg("browser-mock-chat"), generateAutoLaneIdentity: async (args: any = {}) => ({ laneTitle: String(args.fallbackName ?? "Browser Mock Chat"), diff --git a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.test.tsx b/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.test.tsx deleted file mode 100644 index c9c0feaeea..0000000000 --- a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* @vitest-environment jsdom */ - -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CursorCloudFleetEntry, CursorCloudFleetResult } from "../../../shared/types"; -import { CursorCloudFleetModal } from "./CursorCloudFleetModal"; - -const originalAde = globalThis.window.ade; - -function entry(overrides: Partial & { agentId: string }): CursorCloudFleetEntry { - return { - agent: { - agentId: overrides.agentId, - name: `Agent ${overrides.agentId}`, - summary: "summary text", - repos: ["https://github.com/acme/ade"], - webUrl: `https://cursor.com/agents?id=${overrides.agentId}`, - }, - latestRunId: null, - branch: null, - prUrl: null, - modelId: null, - ownership: { sessionId: null, sessionTitle: null, laneId: null, laneName: null, linearIssueId: null }, - matchedBy: "repo", - ...overrides, - }; -} - -function fleetResult(items: CursorCloudFleetResult["items"]): CursorCloudFleetResult { - return { items, relayState: "ready", lastEventAt: null, fetchedAt: new Date().toISOString() }; -} - -function installAdeMocks(overrides?: { - fleet?: () => Promise; -}) { - globalThis.window.ade = { - ai: { - cursorCloudFleet: overrides?.fleet ?? vi.fn().mockResolvedValue(fleetResult([])), - cursorCloudListRuns: vi.fn().mockResolvedValue({ items: [] }), - cursorCloudGetUsage: vi.fn().mockResolvedValue({ agentId: "x", cost: null }), - cursorCloudListArtifacts: vi.fn().mockResolvedValue([]), - cursorCloudCancelRun: vi.fn().mockResolvedValue(undefined), - cursorCloudOpenChat: vi.fn().mockResolvedValue({ sessionId: "s1" }), - cursorCloudResolveLane: vi.fn().mockResolvedValue({ laneId: "lane-1", laneName: "Lane 1", created: false }), - cursorCloudPullIntoLane: vi.fn().mockResolvedValue({ - status: "pulled", laneId: "lane-1", laneName: "Lane 1", sessionId: "s1", mergedBranch: "cursor/a", - }), - onCursorCloudFleetEvent: vi.fn(() => () => undefined), - }, - } as any; -} - -describe("CursorCloudFleetModal", () => { - beforeEach(() => { - installAdeMocks(); - }); - - afterEach(() => { - cleanup(); - if (originalAde === undefined) { - delete (globalThis.window as any).ade; - } else { - globalThis.window.ade = originalAde; - } - }); - - it("renders an honest key-missing state instead of 'no agents'", async () => { - installAdeMocks({ - fleet: vi.fn().mockRejectedValue(new Error("Add a Cursor API key in Settings.")), - }); - render(); - await waitFor(() => { - expect(screen.getByText(/connect cursor first/i)).toBeTruthy(); - }); - expect(screen.queryByText(/no cloud agents/i)).toBeNull(); - }); - - it("renders a fetch failure with retry instead of the empty state", async () => { - installAdeMocks({ - fleet: vi.fn().mockRejectedValue(new Error("cursor api is down")), - }); - render(); - await waitFor(() => { - expect(screen.getByText(/could not load your cloud agents/i)).toBeTruthy(); - }); - expect(screen.getByText("Retry")).toBeTruthy(); - }); - - it("renders the empty fleet as its own state", async () => { - render(); - await waitFor(() => { - expect(screen.getByText(/no cloud agents/i)).toBeTruthy(); - }); - expect(screen.queryByText(/retry/i)).toBeNull(); - }); - - it("groups finished rows under their owning lane and active runs on top", async () => { - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ agentId: "a-run", runStatus: "running" }), - entry({ - agentId: "a-linked", - agent: { - agentId: "a-linked", name: "Linked agent", summary: "", repos: ["https://github.com/acme/ade"], - status: "finished", - }, - ownership: { sessionId: "s1", sessionTitle: null, laneId: "lane-1", laneName: "Perf lane", linearIssueId: "ADE-9" }, - }), - entry({ agentId: "a-unlinked", agent: { agentId: "a-unlinked", name: "Stray", summary: "", repos: ["https://github.com/acme/ade"], status: "error" } }), - ])), - }); - render(); - await waitFor(() => { - expect(screen.getByText("Active runs (1)")).toBeTruthy(); - }); - expect(screen.getAllByText(/perf lane/i).length).toBeGreaterThan(0); - expect(screen.getByText(/unlinked/i)).toBeTruthy(); - expect(screen.getByText("Linked agent")).toBeTruthy(); - expect(screen.getByText("Stray")).toBeTruthy(); - }); - - it("hydrates finished-row status, branch, model, and PR on first expansion", async () => { - const listRuns = vi.fn().mockResolvedValue({ - items: [{ - runId: "run-finished", - status: "FINISHED", - modelId: "claude-4-sonnet", - git: { - branches: [{ - repoUrl: "https://github.com/acme/ade.git", - branch: "cursor/fix-login", - prUrl: "https://github.com/acme/ade/pull/42", - }], - }, - }], - }); - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ - agentId: "a-finished", - agent: { - agentId: "a-finished", name: "Finished agent", summary: "A finished run", repos: ["https://github.com/acme/ade"], - status: "finished", - }, - }), - ])), - }); - (globalThis.window.ade as any).ai.cursorCloudListRuns = listRuns; - - render(); - const row = await screen.findByText("Finished agent"); - fireEvent.click(row); - - await waitFor(() => { - expect(listRuns).toHaveBeenCalledWith({ agentId: "a-finished", limit: 1 }); - expect(screen.getByText("cursor/fix-login")).toBeTruthy(); - expect(screen.getByTitle("Open pull request")).toBeTruthy(); - expect(screen.getByText(/run run-finished/)).toBeTruthy(); - expect(screen.getByText("claude-4-sonnet")).toBeTruthy(); - }); - }); - - it("stops an active run through the dedicated stop endpoint", async () => { - const stopRun = vi.fn().mockResolvedValue({ stopped: true }); - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ agentId: "a-run", runStatus: "running" }), - ])), - }); - (globalThis.window.ade as any).ai.cursorCloudStopRun = stopRun; - render(); - const stop = await screen.findByRole("button", { name: /^stop$/i }); - fireEvent.click(stop); - await waitFor(() => { - expect(stopRun).toHaveBeenCalledWith("a-run"); - }); - }); - - it("hides archived rows until the toggle is used", async () => { - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ agentId: "a-live" }), - entry({ agentId: "a-dead", agent: { agentId: "a-dead", name: "Old one", summary: "", repos: [], archived: true } }), - ])), - }); - render(); - await waitFor(() => { - expect(screen.getByText("Agent a-live")).toBeTruthy(); - }); - expect(screen.queryByText("Old one")).toBeNull(); - fireEvent.click(screen.getByText(/show archived \(1\)/i)); - expect(screen.getByText("Old one")).toBeTruthy(); - }); - - it("draws an archived-only fleet as rows after revealing archived agents", async () => { - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ agentId: "a-archived", agent: { - agentId: "a-archived", name: "Only archived", summary: "old", repos: [], archived: true, - } }), - ])), - }); - render(); - await waitFor(() => { - expect(screen.getByText(/all matching agents are archived/i)).toBeTruthy(); - }); - fireEvent.click(screen.getByText(/show archived \(1\)/i)); - expect(screen.getByText("Only archived")).toBeTruthy(); - expect(screen.queryByText(/no cloud agents/i)).toBeNull(); - }); - - it("opens unlinked agents by resolving their lane first", async () => { - const resolveLane = vi.fn().mockResolvedValue({ laneId: "lane-x", laneName: "X", created: true }); - const openChat = vi.fn().mockResolvedValue({ sessionId: "s2" }); - installAdeMocks({ - fleet: vi.fn().mockResolvedValue(fleetResult([ - entry({ agentId: "a-open" }), - ])), - }); - (globalThis.window.ade as any).ai.cursorCloudResolveLane = resolveLane; - (globalThis.window.ade as any).ai.cursorCloudOpenChat = openChat; - const onClose = vi.fn(); - render(); - const open = await screen.findByRole("button", { name: /^open$/i }); - fireEvent.click(open); - await waitFor(() => { - expect(resolveLane).toHaveBeenCalledWith("a-open"); - expect(openChat).toHaveBeenCalledWith(expect.objectContaining({ cloudAgentId: "a-open", laneId: "lane-x" })); - expect(onClose).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx deleted file mode 100644 index f617d6c0c1..0000000000 --- a/apps/desktop/src/renderer/components/app/CursorCloudFleetModal.tsx +++ /dev/null @@ -1,732 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ArrowSquareOut, - ArrowsClockwise, - CircleNotch, - CloudArrowUp, - Warning, - X, -} from "@phosphor-icons/react"; -import { Cursor } from "@lobehub/icons"; - -import type { - CursorAgentUsage, - CursorCloudArtifactSummary, - CursorCloudFleetEntry, - CursorCloudFleetEvent, - CursorCloudFleetRunStatus, - CursorCloudFleetResult, - CursorCloudRunSummary, -} from "../../../shared/types"; -import { isCursorCloudFleetEntryActive } from "../../../shared/cursorCloudFleetStatus"; -import { openExternalUrl } from "../../lib/openExternal"; -import { cursorCloudErrorMessage, repoMatchKey, cursorCloudRepoLabel, formatCursorCloudAge } from "../../lib/cursorCloudUtils"; -import { announceWorkChatSessionCreated } from "../../lib/chatSessionEvents"; -import { settingsRouteFor } from "../settings/settingsManifest"; -import { useAppStore } from "../../state/appStore"; -import { cn } from "../ui/cn"; -import { Dialog } from "../ui/dialog"; -import { Banner } from "../ui/notice"; -import { FleetRow, SectionHeader } from "./CursorCloudFleetRow"; - -const CURSOR_VIOLET = "#A78BFA"; - -type FleetFilter = "all" | "active" | "finished" | "failed"; - -type FleetEntryDetail = Pick; - -function normalizeFleetRunStatus(value: unknown): CursorCloudFleetRunStatus | undefined { - const status = typeof value === "string" ? value.trim().toLowerCase() : ""; - return status === "creating" - || status === "running" - || status === "finished" - || status === "error" - || status === "cancelled" - || status === "expired" - ? status - : undefined; -} - -function readFleetRunDetail(run: CursorCloudRunSummary, entry: CursorCloudFleetEntry): FleetEntryDetail { - const git = run.git && typeof run.git === "object" && !Array.isArray(run.git) - ? run.git as Record - : {}; - const branches = Array.isArray(git.branches) ? git.branches : []; - const projectRepos = new Set((entry.agent.repos ?? []).map(repoMatchKey).filter(Boolean)); - const pushedBranches = branches.flatMap((value) => { - if (!value || typeof value !== "object" || Array.isArray(value)) return []; - const record = value as Record; - const branch = typeof record.branch === "string" ? record.branch.trim() : ""; - if (!branch) return []; - const repoUrl = typeof record.repoUrl === "string" ? record.repoUrl : null; - const prUrl = typeof record.prUrl === "string" ? record.prUrl.trim() || null : null; - return [{ repoKey: repoUrl ? repoMatchKey(repoUrl) : null, branch, prUrl }]; - }); - const primary = pushedBranches.find((branch) => branch.repoKey && projectRepos.has(branch.repoKey)) - ?? pushedBranches.find((branch) => !branch.repoKey) - ?? pushedBranches[0] - ?? null; - const flatBranch = typeof git.branch === "string" ? git.branch.trim() : ""; - const flatPrUrl = typeof git.prUrl === "string" ? git.prUrl.trim() || null : null; - return { - runStatus: normalizeFleetRunStatus(run.status), - latestRunId: run.runId.trim() || null, - branch: primary?.branch ?? (flatBranch || null), - prUrl: primary?.prUrl ?? flatPrUrl, - modelId: typeof run.modelId === "string" ? run.modelId.trim() || null : null, - }; -} - -function filterMatches(entry: CursorCloudFleetEntry, filter: FleetFilter): boolean { - const status = entry.runStatus ?? entry.agent.status; - switch (filter) { - case "active": - return isCursorCloudFleetEntryActive(entry); - case "finished": - return status === "finished"; - case "failed": - return status === "error" || status === "expired"; - default: - return true; - } -} - -export function CursorCloudFleetModal({ - projectRoot, - projectName, - onClose, -}: { - projectRoot: string | null; - projectName: string | null; - onClose: () => void; -}) { - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [keyMissing, setKeyMissing] = useState(false); - const [filter, setFilter] = useState("all"); - const [laneFilter, setLaneFilter] = useState("all"); - const [showArchived, setShowArchived] = useState(false); - const [expandedId, setExpandedId] = useState(null); - const [busyAgentId, setBusyAgentId] = useState(null); - const [rowError, setRowError] = useState<{ agentId: string; message: string } | null>(null); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [usageByAgentId, setUsageByAgentId] = useState>({}); - const [artifactsByAgentId, setArtifactsByAgentId] = useState>({}); - // Finished/error rows are intentionally cheap in the fleet fetch. Cache the - // first expanded row detail here so switching between rows stays instant - // without turning every list refresh into N run requests. - const [detailByAgentId, setDetailByAgentId] = useState>({}); - const [pulledNotice, setPulledNotice] = useState(null); - const requestGeneration = useRef(0); - - const refreshLanes = useAppStore((s) => s.refreshLanes); - - const refresh = useCallback(async (soft: boolean) => { - const generation = ++requestGeneration.current; - if (soft) setRefreshing(true); - else setLoading(true); - setError(null); - try { - // Cursor caps this endpoint at 100 per page; the host follows the - // returned cursor to include the rest of the fleet. - const next = await window.ade.ai.cursorCloudFleet({ includeArchived: true, limit: 100 }); - if (generation !== requestGeneration.current) return; - setResult(next); - setKeyMissing(false); - } catch (err) { - if (generation !== requestGeneration.current) return; - const message = cursorCloudErrorMessage(err); - setKeyMissing(/api key/i.test(message)); - setError(message); - } finally { - if (generation === requestGeneration.current) { - setLoading(false); - setRefreshing(false); - } - } - }, []); - - useEffect(() => { - void refresh(false); - }, [refresh]); - - // Relay wake: a FINISHED/ERROR webhook refreshes the affected row's data. - // There is deliberately no timer here — freshness comes from the relay or - // from the user's hand. - useEffect(() => { - const unsubscribe = window.ade.ai.onCursorCloudFleetEvent((event: CursorCloudFleetEvent) => { - if (!event?.agentId) return; - if (document.visibilityState !== "visible") return; - void refresh(true); - }); - return unsubscribe; - }, [refresh]); - - const entries = useMemo(() => result?.items ?? [], [result]); - - const laneOptions = useMemo(() => { - const seen = new Map(); - for (const entry of entries) { - if (entry.ownership.laneId && entry.ownership.laneName) { - seen.set(entry.ownership.laneId, entry.ownership.laneName); - } - } - return [...seen.entries()].map(([id, name]) => ({ id, name })); - }, [entries]); - - const visibleEntries = useMemo(() => { - return entries.filter((entry) => { - if (!showArchived && entry.agent.archived) return false; - if (laneFilter !== "all" && entry.ownership.laneId !== laneFilter) { - // Unlinked rows survive a lane filter only when "all" is chosen. - return false; - } - return filterMatches(entry, filter); - }); - }, [entries, filter, laneFilter, showArchived]); - - const grouped = useMemo(() => { - const active: CursorCloudFleetEntry[] = []; - const byLane = new Map(); - const unlinked = new Map(); - for (const entry of visibleEntries) { - if (isCursorCloudFleetEntryActive(entry)) { - active.push(entry); - continue; - } - if (entry.ownership.laneId) { - const key = entry.ownership.laneId; - const group = byLane.get(key) - ?? { laneId: key, laneName: entry.ownership.laneName ?? "Lane", entries: [] }; - group.entries.push(entry); - byLane.set(key, group); - } else { - const key = repoMatchKey(entry.agent.repos?.[0]) + "|" + (entry.branch ?? ""); - const list = unlinked.get(key) ?? []; - list.push(entry); - unlinked.set(key, list); - } - } - const recency = (entry: CursorCloudFleetEntry): number => - entry.agent.lastModified ?? entry.agent.createdAt ?? 0; - active.sort((a, b) => recency(b) - recency(a)); - const lanes = [...byLane.values()]; - for (const group of lanes) { - group.entries.sort((a, b) => recency(b) - recency(a)); - } - lanes.sort((a, b) => recency(b.entries[0]) - recency(a.entries[0])); - const unlinkedGroups = [...unlinked.entries()] - .map(([key, list]) => ({ - key, - label: list[0]?.agent.repos?.[0] - ? `${cursorCloudRepoLabel(list[0].agent.repos[0])}${list[0].branch ? ` · ${list[0].branch}` : ""}` - : "Unknown repo", - entries: list.sort((a, b) => recency(b) - recency(a)), - })) - .sort((a, b) => recency(b.entries[0]) - recency(a.entries[0])); - return { active, lanes, unlinkedGroups }; - }, [visibleEntries]); - - const totalCostCents = useMemo(() => { - let sum = 0; - let any = false; - for (const entry of visibleEntries) { - const usage = usageByAgentId[entry.agent.agentId]; - const cents = usage?.cost?.chargedCents ?? usage?.cost?.rawCostCents; - if (typeof cents === "number" && Number.isFinite(cents)) { - sum += cents; - any = true; - } - } - return any ? sum : null; - }, [usageByAgentId, visibleEntries]); - - const expandEntry = useCallback(async (agentId: string) => { - setExpandedId((current) => (current === agentId ? null : agentId)); - setConfirmDeleteId(null); - const reads: Array> = []; - if (detailByAgentId[agentId] === undefined) { - const currentEntry = entries.find((entry) => entry.agent.agentId === agentId); - if (currentEntry) { - reads.push(window.ade.ai.cursorCloudListRuns({ agentId, limit: 1 }).then((runs) => { - const detail = runs.items[0] ? readFleetRunDetail(runs.items[0], currentEntry) : null; - setDetailByAgentId((current) => ({ ...current, [agentId]: detail })); - if (!detail) return; - setResult((current) => current - ? { - ...current, - items: current.items.map((item) => item.agent.agentId === agentId - ? { ...item, ...detail } - : item), - } - : current); - }).catch(() => { - // A detail read is best-effort; the cached fleet row remains usable. - setDetailByAgentId((current) => ({ ...current, [agentId]: null })); - })); - } - } - if (!usageByAgentId[agentId]) { - reads.push(window.ade.ai.cursorCloudGetUsage({ agentId }).then((usage) => { - setUsageByAgentId((current) => ({ ...current, [agentId]: usage })); - }).catch(() => { - // Cost is optional decoration; absence renders as no chip. - })); - } - if (!artifactsByAgentId[agentId]) { - reads.push(window.ade.ai.cursorCloudListArtifacts(agentId).then((artifacts) => { - setArtifactsByAgentId((current) => ({ ...current, [agentId]: artifacts })); - }).catch(() => { - // Artifacts are optional decoration; the chat still contains the diff. - })); - } - await Promise.all(reads); - }, [artifactsByAgentId, detailByAgentId, entries, usageByAgentId]); - - const openInAde = useCallback(async (entry: CursorCloudFleetEntry) => { - const agentId = entry.agent.agentId; - setBusyAgentId(agentId); - setRowError(null); - try { - let laneId = entry.ownership.laneId; - if (!laneId) { - const resolved = await window.ade.ai.cursorCloudResolveLane(agentId); - laneId = resolved.laneId; - } - const opened = await window.ade.ai.cursorCloudOpenChat({ - cloudAgentId: agentId, - laneId, - }); - if (opened.session) { - announceWorkChatSessionCreated(projectRoot ?? "", opened.session); - } - onClose(); - } catch (err) { - setRowError({ agentId, message: cursorCloudErrorMessage(err) }); - } finally { - setBusyAgentId(null); - } - }, [onClose, projectRoot]); - - const stopRun = useCallback(async (entry: CursorCloudFleetEntry) => { - const agentId = entry.agent.agentId; - setBusyAgentId(agentId); - setRowError(null); - try { - await window.ade.ai.cursorCloudStopRun(agentId); - await refresh(true); - } catch (err) { - setRowError({ agentId, message: cursorCloudErrorMessage(err) }); - } finally { - setBusyAgentId(null); - } - }, [refresh]); - - const pullIntoLane = useCallback(async (entry: CursorCloudFleetEntry) => { - const agentId = entry.agent.agentId; - setBusyAgentId(agentId); - setRowError(null); - try { - const pulled = await window.ade.ai.cursorCloudPullIntoLane(agentId); - setPulledNotice( - pulled.status === "created_lane" - ? `Created lane '${pulled.laneName}' and merged ${pulled.mergedBranch}.` - : `Merged ${pulled.mergedBranch} into '${pulled.laneName}'.`, - ); - void refreshLanes(); - await refresh(true); - } catch (err) { - setRowError({ agentId, message: cursorCloudErrorMessage(err) }); - } finally { - setBusyAgentId(null); - } - }, [refresh, refreshLanes]); - - const toggleArchive = useCallback(async (entry: CursorCloudFleetEntry) => { - const agentId = entry.agent.agentId; - setBusyAgentId(agentId); - setRowError(null); - try { - if (entry.agent.archived) await window.ade.ai.cursorCloudUnarchiveAgent(agentId); - else await window.ade.ai.cursorCloudArchiveAgent(agentId); - await refresh(true); - } catch (err) { - setRowError({ agentId, message: cursorCloudErrorMessage(err) }); - } finally { - setBusyAgentId(null); - } - }, [refresh]); - - const deleteAgent = useCallback(async (entry: CursorCloudFleetEntry) => { - const agentId = entry.agent.agentId; - setBusyAgentId(agentId); - setRowError(null); - try { - await window.ade.ai.cursorCloudDeleteAgent(agentId); - setConfirmDeleteId(null); - setResult((current) => current - ? { ...current, items: current.items.filter((item) => item.agent.agentId !== agentId) } - : current); - } catch (err) { - setRowError({ agentId, message: cursorCloudErrorMessage(err) }); - } finally { - setBusyAgentId(null); - } - }, []); - - const archivedCount = useMemo( - () => entries.filter((entry) => entry.agent.archived).length, - [entries], - ); - const noVisibleAgentsBecauseArchived = entries.length > 0 && visibleEntries.length === 0 && archivedCount > 0; - - const relayBanner = (() => { - if (!result || loading) return null; - if (result.relayState === "ready") return null; - if (result.relayState === "error") { - return ( - - ); - } - return ( - - ); - })(); - - return ( - { - if (!next) onClose(); - }} - title="Cursor Cloud fleet" - hideHeader - width={880} - height="min(760px, calc(100dvh - 28px))" - maxHeight="calc(100dvh - 28px)" - bodyPadding={false} - scrollBody={false} - bodyStyle={{ display: "flex", flexDirection: "column" }} - // Nothing in the fleet grabs focus on open; the panel holds it. - preventAutoFocus - panelStyle={{ - background: "var(--ade-shell-surface, #121019)", - borderRadius: 12, - borderColor: "rgba(167,139,250,0.32)", - boxShadow: "0 24px 70px rgba(0,0,0,0.55), 0 0 0 1px rgba(167,139,250,0.14)", - }} - > - {/* Header */} -
-
- - - -
-
Cursor Cloud
-
- {projectName ? `${projectName} · ` : ""}account fleet -
-
-
-
- - {laneOptions.length > 0 ? ( - - ) : null} - - -
-
- - {relayBanner} - - {/* Body */} -
- {loading ? ( -
- - Loading cloud agents… -
- ) : error ? ( -
- -
- {keyMissing - ? "Connect Cursor first — add an API key or log in via Settings → AI connections." - : `Could not load your cloud agents: ${error}`} -
- {keyMissing ? ( - - ) : ( - - )} -
- ) : entries.length === 0 ? ( -
- - - -
No cloud agents
-
- Agents you launch from any chat composer with a Cursor model — and anything on cursor.com — - will show up here. -
-
- ) : noVisibleAgentsBecauseArchived ? ( -
-
All matching agents are archived
-
- Reveal archived agents to inspect or unarchive them. -
- -
- ) : ( -
- {grouped.active.length > 0 ? ( -
- -
- {grouped.active.map((entry) => ( - void expandEntry(entry.agent.agentId)} - onOpen={() => void openInAde(entry)} - onStop={() => void stopRun(entry)} - onPull={() => void pullIntoLane(entry)} - onArchive={() => void toggleArchive(entry)} - onRequestDelete={() => setConfirmDeleteId(entry.agent.agentId)} - onConfirmDelete={() => void deleteAgent(entry)} - /> - ))} -
-
- ) : null} - - {grouped.lanes.map((group) => ( -
- -
- {group.entries.map((entry) => ( - void expandEntry(entry.agent.agentId)} - onOpen={() => void openInAde(entry)} - onStop={() => void stopRun(entry)} - onPull={() => void pullIntoLane(entry)} - onArchive={() => void toggleArchive(entry)} - onRequestDelete={() => setConfirmDeleteId(entry.agent.agentId)} - onConfirmDelete={() => void deleteAgent(entry)} - /> - ))} -
-
- ))} - - {grouped.unlinkedGroups.length > 0 ? ( -
- n + g.entries.length, 0)} - /> -
- {grouped.unlinkedGroups.map((group) => ( -
-
- {group.label} -
-
- {group.entries.map((entry) => ( - void expandEntry(entry.agent.agentId)} - onOpen={() => void openInAde(entry)} - onStop={() => void stopRun(entry)} - onPull={() => void pullIntoLane(entry)} - onArchive={() => void toggleArchive(entry)} - onRequestDelete={() => setConfirmDeleteId(entry.agent.agentId)} - onConfirmDelete={() => void deleteAgent(entry)} - /> - ))} -
-
- ))} -
-
- ) : null} - - {archivedCount > 0 && !showArchived ? ( -
- -
- ) : null} -
- )} -
- - {/* Footer */} -
-
- - {visibleEntries.length} agent{visibleEntries.length === 1 ? "" : "s"} - {totalCostCents != null ? ` · $${(totalCostCents / 100).toFixed(2)} shown` : ""} - - {result ? · updated {formatCursorCloudAge(result.fetchedAt) ?? "just now"} : null} -
- {showArchived ? ( - - ) : ( - { - event.preventDefault(); - openExternalUrl("https://cursor.com/agents"); - }} - className="inline-flex items-center gap-1 transition-colors hover:text-fg/70" - > - All agents on cursor.com - - - )} -
- - {/* Pulled notice */} - {pulledNotice ? ( -
- setPulledNotice(null), label: "Dismiss", title: "Dismiss" }, - }} - /> -
- ) : null} -
- ); -} diff --git a/apps/desktop/src/renderer/components/app/CursorCloudFleetRow.tsx b/apps/desktop/src/renderer/components/app/CursorCloudFleetRow.tsx deleted file mode 100644 index d68a3a5b14..0000000000 --- a/apps/desktop/src/renderer/components/app/CursorCloudFleetRow.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { useRef, useState } from "react"; -import { - ArrowSquareOut, - CaretDown, - GitPullRequest, - Stop, - Trash, -} from "@phosphor-icons/react"; - -import type { CursorAgentUsage, CursorCloudArtifactSummary, CursorCloudFleetEntry } from "../../../shared/types"; -import { - cursorCloudFleetDisplayStatus, - isCursorCloudFleetEntryActive, -} from "../../../shared/cursorCloudFleetStatus"; -import { openExternalUrl } from "../../lib/openExternal"; -import { AnchoredMenu } from "../ui/AnchoredMenu"; -import { - cursorCloudRepoLabel, - cursorCloudStatusToneClass, - formatCursorCloudAge, -} from "../../lib/cursorCloudUtils"; -import { cn } from "../ui/cn"; - -const CURSOR_VIOLET = "#A78BFA"; - -export function StatusPill({ status }: { status: string }) { - return ( - - {status} - - ); -} - -function formatCost(usage: CursorAgentUsage | null | undefined): string | null { - const cents = usage?.cost?.chargedCents ?? usage?.cost?.rawCostCents ?? null; - if (cents == null || Number.isNaN(cents)) return null; - if (cents === 0) return "$0"; - return `$${(cents / 100).toFixed(2)}`; -} - -export function SectionHeader({ - label, - count, - hint, - accent, -}: { - label: string; - count?: number; - hint?: string; - accent?: boolean; -}) { - return ( -
- - {label} - - {count != null ? {count} : null} - {hint ? {hint} : null} -
- ); -} - -function OwnershipChip({ entry }: { entry: CursorCloudFleetEntry }) { - const { ownership } = entry; - if (!ownership.laneName && !ownership.linearIssueId) return null; - return ( - - {ownership.linearIssueId ? ( - {ownership.linearIssueId} - ) : null} - {ownership.laneName ? ( - {ownership.linearIssueId ? `· ${ownership.laneName}` : ownership.laneName} - ) : null} - - ); -} - -export function FleetRow({ - entry, - expanded, - busy, - confirmingDelete, - usage, - artifacts, - rowError, - onToggle, - onOpen, - onStop, - onPull, - onArchive, - onRequestDelete, - onConfirmDelete, -}: { - entry: CursorCloudFleetEntry; - expanded: boolean; - busy: boolean; - confirmingDelete: boolean; - usage: CursorAgentUsage | undefined; - artifacts: CursorCloudArtifactSummary[] | undefined; - rowError: string | null; - onToggle: () => void; - onOpen: () => void; - onStop: () => void; - onPull: () => void; - onArchive: () => void; - onRequestDelete: () => void; - onConfirmDelete: () => void; -}) { - const [liveUrlCopied, setLiveUrlCopied] = useState(false); - const { agent } = entry; - const status = cursorCloudFleetDisplayStatus(entry); - const active = isCursorCloudFleetEntryActive(entry); - const age = formatCursorCloudAge(agent.lastModified ?? agent.createdAt); - const cost = formatCost(usage); - const finished = status === "finished"; - const branchOrRepo = entry.branch ?? (agent.repos?.[0] ? cursorCloudRepoLabel(agent.repos[0]) : null); - - return ( -
- {/* Div, not button: the row hosts real interactive children (Stop, Open, - menu) and nested buttons would drop out of the a11y tree. */} -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggle(); - } - }} - aria-expanded={expanded} - className="flex w-full cursor-pointer items-start gap-3 px-3 py-2.5 text-left" - > - - {active ? ( - <> - - - - ) : ( - - )} - - - - - {agent.name || agent.agentId.slice(0, 12)} - - - {age ? {age} : null} - {cost ? ( - {cost} - ) : null} - - - {branchOrRepo ? {branchOrRepo} : null} - {entry.modelId ? {entry.modelId} : null} - - {entry.prUrl ? ( - { - event.stopPropagation(); - openExternalUrl(entry.prUrl!); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.stopPropagation(); - openExternalUrl(entry.prUrl!); - } - }} - className="inline-flex shrink-0 items-center gap-0.5 text-violet-200/70 hover:text-violet-100" - title="Open pull request" - > - PR - - ) : null} - - - event.stopPropagation()} - > - {!agent.archived ? ( - - ) : null} - {active ? ( - - ) : null} - - -
- - {rowError ? ( -
- {rowError} -
- ) : null} - - {expanded ? ( -
- {agent.summary && agent.summary !== agent.name ? ( -
{agent.summary}
- ) : null} -
- agent {agent.agentId.slice(0, 14)}… - {entry.latestRunId ? run {entry.latestRunId.slice(0, 14)}… : null} - {agent.webUrl ? ( - - ) : null} -
- {agent.webUrl ? ( -
- - -
- ) : null} - {artifacts ? ( -
-
Artifacts / diff
- {artifacts.length > 0 ? artifacts.map((artifact) => ( -
- {artifact.path} - {artifact.sizeBytes != null ? {formatArtifactSize(artifact.sizeBytes)} : null} -
- )) :
No artifacts reported.
} -
- ) : null} - {usage?.totalTokens != null ? ( -
- tokens {usage.totalTokens.toLocaleString()} - {usage.inputTokens != null ? ` · in ${usage.inputTokens.toLocaleString()} out ${usage.outputTokens?.toLocaleString() ?? "0"}` : ""} -
- ) : null} -
- ) : null} -
- ); -} - -function formatArtifactSize(sizeBytes: number): string { - if (sizeBytes < 1024) return `${sizeBytes} B`; - if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`; - return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function RowMenu({ - entry, - busy, - confirmingDelete, - finished, - onPull, - onArchive, - onRequestDelete, - onConfirmDelete, - onConfirmDismiss, -}: { - entry: CursorCloudFleetEntry; - busy: boolean; - confirmingDelete: boolean; - finished: boolean; - onPull: () => void; - onArchive: () => void; - onRequestDelete: () => void; - onConfirmDelete: () => void; - onConfirmDismiss: () => void; -}) { - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - - // Dismissing the menu without acting must also stand down an armed delete - // confirmation. - const dismiss = () => { - setOpen(false); - if (confirmingDelete) onConfirmDismiss(); - }; - - const itemClass = - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] text-fg/70 transition-colors hover:bg-white/[0.06] hover:text-fg/95 disabled:opacity-40"; - - return ( -
event.stopPropagation()}> - - {/* Portalled above the fleet modal (z 9999): the modal's scrolling list - would cut off a menu opened near its bottom edge. */} - event.stopPropagation()} - > - {finished && !entry.agent.archived ? ( - - ) : null} - - {entry.prUrl ? ( - - ) : null} - - -
- ); -} diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index ef25645186..6e43249657 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useSt import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; import { + ArrowLeft, Bell, Brain, ChartLineUp, @@ -812,6 +813,17 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { const activeTab = tabs.find((tab) => tab.id === section) ?? tabs.find((tab) => tab.id === defaultTab) ?? tabs[0]; + // A provider detail (or Harnesses) is a sub-view of the Providers tab. The + // shell header owns the back affordance so it sits on the title row rather + // than floating below it. + const subViewOpen = Boolean(searchParams.get("provider")?.trim()) || searchParams.get("harnesses") === "1"; + const closeSubView = () => { + const next = new URLSearchParams(searchParams); + next.delete("provider"); + next.delete("harnesses"); + const search = next.toString(); + navigate({ pathname: location.pathname, search: search ? `?${search}` : "", hash: "" }, { replace: true }); + }; const tabEntryCount = settingsEntriesForTab(section).length; const noMatchesHere = trimmedQuery.length > 0 && (matchesThisTab?.length ?? 0) === 0; @@ -942,8 +954,19 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { flexWrap: "wrap", }} > -
- {machinePageScope ? : null} +
+ {subViewOpen ? ( + + ) : null} +
+ {machinePageScope ? : null}

) : null} +