From e348c0e240ac0d9ff9930cb5aa8cd52f31ff6cf0 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Thu, 17 Sep 2026 08:00:24 -0700 Subject: [PATCH 01/45] feat(providers): add Devin as a first-class provider Devin joins ADE as one provider id covering local and cloud. Local: devin acp runs as an ACP dialect in the shared host for native Work chats, and the tracked devin CLI row gives PTY sessions with resume. Cloud: the v3 Sessions API powers an org-wide fleet view (list with repo/tag filters, provenance chips Mine/From ADE/All), mirrored transcript chats over GET/POST messages, lane-bound session creation with ade/ade:lane: tags and a devin_mode picker, terminate + archive/unarchive, pull-into-lane for pushed branches/PRs, and a built-in-browser live view via session.url. Cloud sessions join the attention system (waiting_for_user -> Needs you) and sync Devin attachments into the proof drawer. Hand off to Devin Cloud packages lane context into a cloud session; Continue in lane seeds a local CLI from a pulled session. Auth is a pasted v3 PAT (cog_) with a v1 personal-key fallback; CLI chats use devin auth login. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/ade-cli/src/adeRpcServer.ts | 2 +- apps/ade-cli/src/bootstrap.ts | 32 + apps/ade-cli/src/services/agentRegistry.ts | 16 + .../services/sync/syncRemoteCommandService.ts | 2 + apps/ade-cli/src/services/sync/syncService.ts | 2 + apps/ade-cli/src/tuiClient/adeApi.ts | 2 +- .../ModelPicker/modelPickerLayout.ts | 6 +- .../ade-cli/src/tuiClient/providerMetadata.ts | 4 + apps/ade-cli/src/tuiClient/theme.ts | 2 + apps/ade-cli/src/tuiClient/types.ts | 1 + apps/desktop/src/main/main.ts | 28 + .../main/services/adeActions/actionPolicy.ts | 11 + .../src/main/services/adeActions/registry.ts | 75 +- .../services/agentTools/agentToolsService.ts | 1 + .../src/main/services/ai/acpAuthProbe.ts | 4 + .../src/main/services/ai/acpExecutables.ts | 2 + .../main/services/ai/aiIntegrationService.ts | 227 +++++- .../src/main/services/ai/apiKeyStore.ts | 1 + .../src/main/services/ai/authDetector.ts | 27 +- .../src/main/services/ai/devinCloudClient.ts | 626 ++++++++++++++++ .../main/services/ai/providerRuntimeHealth.ts | 3 +- .../chat/acpHost/acpDialects/devin.ts | 107 +++ .../chat/acpHost/acpDialects/index.ts | 4 +- .../services/chat/acpHost/acpHost.test.ts | 22 +- .../main/services/chat/agentChatService.ts | 692 +++++++++++++++++- .../services/chat/devinCloudConversation.ts | 53 ++ .../services/chat/devinCloudFleetService.ts | 344 +++++++++ .../services/config/projectConfigService.ts | 8 + .../src/main/services/ipc/registerIpc.ts | 127 ++++ .../src/main/utils/terminalTuiMarkers.ts | 1 + apps/desktop/src/preload/global.d.ts | 41 +- apps/desktop/src/preload/preload.ts | 90 ++- .../renderer/assets/provider-logos/devin.svg | 5 + apps/desktop/src/renderer/browserMock.ts | 106 +++ .../components/app/DevinCloudFleetModal.tsx | 682 +++++++++++++++++ .../components/app/DevinCloudFleetRow.tsx | 483 ++++++++++++ .../app/DevinCloudQuickViewButton.tsx | 264 +++++++ .../src/renderer/components/app/TabNav.tsx | 8 +- .../src/renderer/components/app/TopBar.tsx | 3 + .../components/chat/AgentChatComposer.tsx | 48 +- .../components/chat/AgentChatPane.tsx | 568 +++++++++++++- .../components/chat/ChatDevinCloudPanel.tsx | 456 ++++++++++++ .../components/prs/shared/PrBotReviewCard.tsx | 6 +- .../components/prs/state/PrsContext.tsx | 4 +- .../settings/providers/acpProviders.tsx | 124 +++- .../components/settings/providers/types.ts | 5 +- .../shared/ModelPicker/ModelPickerContent.tsx | 1 + .../shared/ModelPicker/modelCatalog.ts | 1 + .../shared/ModelPicker/runtimeCatalogCache.ts | 2 + .../ModelPicker/useProviderAuthStatus.ts | 3 + .../components/shared/ProviderLogos.tsx | 7 + .../src/renderer/lib/devinCloudUtils.ts | 53 ++ .../src/renderer/lib/draftLaunchJobs.ts | 2 +- .../src/renderer/lib/nativeLaunchControls.ts | 3 +- apps/desktop/src/renderer/lib/sessions.ts | 13 +- .../desktop/src/shared/acpProviderMetadata.ts | 9 +- apps/desktop/src/shared/cliLaunch.ts | 81 +- .../src/shared/devinCloudFleetStatus.ts | 117 +++ apps/desktop/src/shared/ipc.ts | 11 + apps/desktop/src/shared/modelCatalog.test.ts | 1 + apps/desktop/src/shared/modelCatalog.ts | 5 + apps/desktop/src/shared/modelRegistry.ts | 117 ++- .../shared/orchestrationRuntimePolicy.test.ts | 1 + .../src/shared/orchestrationRuntimePolicy.ts | 1 + .../src/shared/providerRetryPresentation.ts | 2 +- .../src/shared/syncMobileCompatibility.ts | 5 + apps/desktop/src/shared/types/chat.ts | 22 +- apps/desktop/src/shared/types/config.ts | 244 +++++- apps/desktop/src/shared/types/sessions.ts | 7 +- apps/desktop/src/shared/types/sync.ts | 12 + docs/features/chat/README.md | 9 + docs/features/chat/agent-routing.md | 1 + docs/features/chat/composer-and-ui.md | 55 ++ 73 files changed, 6021 insertions(+), 89 deletions(-) create mode 100644 apps/desktop/src/main/services/ai/devinCloudClient.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts create mode 100644 apps/desktop/src/main/services/chat/devinCloudConversation.ts create mode 100644 apps/desktop/src/main/services/chat/devinCloudFleetService.ts create mode 100644 apps/desktop/src/renderer/assets/provider-logos/devin.svg create mode 100644 apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx create mode 100644 apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx create mode 100644 apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx create mode 100644 apps/desktop/src/renderer/lib/devinCloudUtils.ts create mode 100644 apps/desktop/src/shared/devinCloudFleetStatus.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 646686315b..e60fecaadd 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -347,7 +347,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 1989cd85f2..4cbaa32950 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -105,6 +105,7 @@ 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 { createDevinCloudFleetService } from "../../desktop/src/main/services/chat/devinCloudFleetService"; 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"; @@ -332,6 +333,7 @@ export type AdeRuntime = { aiIntegrationService?: ReturnType | null; agentChatService?: ReturnType | null; cursorCloudFleetService?: ReturnType | null; + devinCloudFleetService?: ReturnType | null; orchestrationService?: ReturnType | null; prService?: ReturnType; prSummaryService?: ReturnType | null; @@ -1773,6 +1775,34 @@ export async function createAdeRuntime(args: { return { state: status.state, lastEventAt: status.lastEventAt }; }, }); + const devinCloudFleetService = createDevinCloudFleetService({ + projectRoot, + logger, + listDevinCloudSessions: (args) => aiIntegrationService.listDevinCloudSessions(args), + getDevinCloudSession: (devinSessionId) => aiIntegrationService.getDevinCloudSession(devinSessionId), + laneService: { + list: (args) => laneService.list(args), + importBranch: (args) => laneService.importBranch(args), + }, + listDevinCloudSessionLinks: async () => { + if (!agentChatService) throw new Error("Agent chat service not available."); + const sessions = await agentChatService.listSessions(undefined, { includeArchived: true }); + return sessions + .filter((session) => Boolean(session.devinSessionId)) + .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)) + .map((session) => ({ + sessionId: session.sessionId, + devinSessionId: session.devinSessionId ?? "", + laneId: session.laneId, + title: session.title ?? null, + })) + .filter((link) => link.devinSessionId.length > 0); + }, + openDevinCloudChat: (args) => { + if (!agentChatService) throw new Error("Agent chat service not available."); + return agentChatService.openDevinCloudChat(args); + }, + }); const configReloadService = createConfigReloadService({ paths: { sharedPath: adeProjectService.paths.sharedConfigPath, @@ -2205,6 +2235,7 @@ export async function createAdeRuntime(args: { computerUseArtifactBrokerService, agentChatService, cursorCloudFleetService, + devinCloudFleetService, pushPublisherService, ctoStateService, ctoMemoryService, @@ -2352,6 +2383,7 @@ export async function createAdeRuntime(args: { aiIntegrationService, agentChatService, cursorCloudFleetService, + devinCloudFleetService, orchestrationService, ctoStateService, ctoMemoryService, diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index d3f9e943c9..1646ed66d3 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -242,6 +242,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/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index b3de22164e..5cd0296226 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3204,6 +3204,7 @@ const MODEL_CATALOG_REFRESH_PROVIDERS = new Set): AgentChatModelCatalogArgs { @@ -3727,6 +3728,7 @@ async function resolveChatCreateArgs( || payload.provider === "kimi" || payload.provider === "grok" || payload.provider === "copilot" + || payload.provider === "devin" ? { activateRuntime: true } : {} ), diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index faa42f12e9..bfcf460a71 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -23,6 +23,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 { createDevinCloudFleetService } from "../../../../desktop/src/main/services/chat/devinCloudFleetService"; 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"; @@ -144,6 +145,7 @@ type SyncServiceArgs = { >; agentChatService: ReturnType; cursorCloudFleetService?: ReturnType | null; + devinCloudFleetService?: ReturnType | null; personalChatScope?: PersonalChatScopeContract; /** Brain→push-relay publisher; threaded to the runtime remote-command service. */ pushPublisherService?: PushPublisherService | null; diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index b41ee33830..1ab94275b4 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -542,7 +542,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.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 35b87c1366..c9927d9bf6 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -30,16 +30,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] { @@ -167,6 +168,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 9109885a8f..64bb339fcb 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -105,6 +105,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 9836c0c082..0d93228960 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -286,6 +286,7 @@ import { createLinearAccessTokenGetter, createLinearIngressService } from "./ser import { buildLinearAutomationDispatches } from "./services/automations/linearAutomationDispatch"; import { createCursorCloudIngressService } from "./services/automations/cursorCloudIngressService"; import { createCursorCloudFleetService } from "./services/chat/cursorCloudFleetService"; +import { createDevinCloudFleetService } from "./services/chat/devinCloudFleetService"; import { buildCursorCloudAutomationDispatches } from "./services/automations/cursorCloudAutomationDispatch"; import { openCursorCloudCredentialStore } from "./services/chat/cursorCloudCreateOptions"; import { createReviewService } from "./services/review/reviewService"; @@ -4308,6 +4309,31 @@ app.whenReady().then(async () => { return { state: status.state, lastEventAt: status.lastEventAt }; }, }); + + const devinCloudFleetService = createDevinCloudFleetService({ + projectRoot, + logger, + listDevinCloudSessions: (args) => aiIntegrationService.listDevinCloudSessions(args), + getDevinCloudSession: (devinSessionId) => aiIntegrationService.getDevinCloudSession(devinSessionId), + laneService: { + list: (args) => laneService.list(args), + importBranch: (args) => laneService.importBranch(args), + }, + listDevinCloudSessionLinks: async () => { + const sessions = await agentChatService.listSessions(undefined, { includeArchived: true }); + return sessions + .filter((session) => Boolean(session.devinSessionId)) + .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)) + .map((session) => ({ + sessionId: session.sessionId, + devinSessionId: session.devinSessionId ?? "", + laneId: session.laneId, + title: session.title ?? null, + })) + .filter((link) => link.devinSessionId.length > 0); + }, + openDevinCloudChat: (args) => agentChatService.openDevinCloudChat(args), + }); automationService?.setCursorCloudIngressAvailable(() => { const status = cursorCloudIngressService.getStatus(); return status.state === "ready" || Boolean(status.webhookId && !status.lastError); @@ -4621,6 +4647,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, agentChatService, cursorCloudFleetService, + devinCloudFleetService, ctoStateService, linearCredentialService, getLinearIssueTracker: () => linearIssueTracker, @@ -5050,6 +5077,7 @@ app.whenReady().then(async () => { linearIngressService, cursorCloudIngressService, cursorCloudFleetService, + devinCloudFleetService, feedbackReporterService, usageTrackingService, storageInsightsService, diff --git a/apps/desktop/src/main/services/adeActions/actionPolicy.ts b/apps/desktop/src/main/services/adeActions/actionPolicy.ts index 3838003ab4..40c5f71c0e 100644 --- a/apps/desktop/src/main/services/adeActions/actionPolicy.ts +++ b/apps/desktop/src/main/services/adeActions/actionPolicy.ts @@ -609,6 +609,17 @@ export const ADE_ACTION_ALLOWLIST: Partial requireService(runtime.cursorCloudFleetService, "Cursor Cloud fleet not available.").getFleet({ - includeArchived: args?.includeArchived !== false, + includeArchived: args?.includeArchived === true, ...(args?.limit !== undefined ? { limit: args.limit } : {}), }), resolveCursorCloudAgentLane: (args?: { agentId?: string }) => @@ -2130,6 +2131,78 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { requireService(runtime.cursorCloudFleetService, "Cursor Cloud fleet not available.").stopAgentRun( requireNonEmptyString(args?.agentId, "agentId"), ), + getDevinCloudAuthStatus: () => aiIntegrationService.getDevinCloudAuthStatus(), + setDevinCloudCredentials: (args?: { apiKey?: string; orgId?: string | null }) => + aiIntegrationService.setDevinCloudCredentials({ + apiKey: args?.apiKey ?? "", + ...(args?.orgId !== undefined ? { orgId: args.orgId } : {}), + }), + getDevinCloudFleet: (args?: { force?: boolean; includeArchived?: boolean }) => + requireService(runtime.devinCloudFleetService, "Devin Cloud fleet not available.").getFleet({ + includeArchived: args?.includeArchived === true, + ...(args?.force !== undefined ? { force: args.force } : {}), + }), + pullDevinCloudSessionIntoLane: (args?: { devinSessionId?: string }) => + requireService(runtime.devinCloudFleetService, "Devin Cloud fleet not available.").pullIntoLane( + requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + ), + terminateDevinCloudSession: (args?: { devinSessionId?: string; archive?: boolean }) => + aiIntegrationService.terminateDevinCloudSession({ + devinSessionId: requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + ...(args?.archive !== undefined ? { archive: args.archive } : {}), + }), + archiveDevinCloudSession: (args?: { devinSessionId?: string }) => + aiIntegrationService.archiveDevinCloudSession( + requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + ), + unarchiveDevinCloudSession: (args?: { devinSessionId?: string }) => + aiIntegrationService.unarchiveDevinCloudSession( + requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + ), + devinCloudFollowUp: (args?: { devinSessionId?: string; message?: string }) => + requireService(runtime.agentChatService, "Agent chat service not available.").devinCloudFollowUp({ + devinSessionId: requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + message: requireNonEmptyString(args?.message, "message"), + }), + openDevinCloudChat: (args?: { + devinSessionId?: string; + laneId?: string; + sessionId?: string; + devinMode?: DevinCloudMode | null; + }) => + requireService(runtime.agentChatService, "Agent chat service not available.").openDevinCloudChat({ + devinSessionId: requireNonEmptyString(args?.devinSessionId, "devinSessionId"), + laneId: requireNonEmptyString(args?.laneId, "laneId"), + ...(args?.sessionId ? { sessionId: args.sessionId } : {}), + ...(args?.devinMode !== undefined ? { devinMode: args.devinMode } : {}), + }), + watchDevinCloudMirror: (args?: { sessionId?: string; watching?: boolean }) => { + if (typeof args?.watching !== "boolean") { + throw new Error("Expected 'watching' to be a boolean."); + } + requireService(runtime.agentChatService, "Agent chat service not available.").watchDevinCloudMirror({ + sessionId: requireNonEmptyString(args?.sessionId, "sessionId"), + watching: args.watching, + }); + }, + createDevinCloudSession: (args?: { + laneId?: string; + prompt?: string; + sessionId?: string | null; + title?: string | null; + devinMode?: DevinCloudMode | null; + projectId?: string | null; + bypassApproval?: boolean; + }) => + requireService(runtime.agentChatService, "Agent chat service not available.").createDevinCloudSessionForLane({ + laneId: requireNonEmptyString(args?.laneId, "laneId"), + prompt: requireNonEmptyString(args?.prompt, "prompt"), + ...(args?.sessionId ? { sessionId: args.sessionId } : {}), + ...(args?.title ? { title: args.title } : {}), + ...(args?.devinMode !== undefined ? { devinMode: args.devinMode } : {}), + ...(args?.projectId ? { projectId: args.projectId } : {}), + ...(args?.bypassApproval !== undefined ? { bypassApproval: args.bypassApproval } : {}), + }), }; } 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 3860ad09d7..1cc0fa7bc9 100644 --- a/apps/desktop/src/main/services/ai/acpAuthProbe.ts +++ b/apps/desktop/src/main/services/ai/acpAuthProbe.ts @@ -79,6 +79,10 @@ export function acpProbeConfigHome( return copilotConfigHome({ env }); case "grok": return null; + // 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 62c4178105..ed04277da4 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -22,6 +22,13 @@ import type { CursorCloudRunSummary, CursorAgentUsage, CursorAgentUsageRequest, + DevinCloudAttachment, + DevinCloudAuthStatus, + DevinCloudCreateSessionRequest, + DevinCloudSendMessageRequest, + DevinCloudSendMessageResult, + DevinCloudSessionSummary, + DevinCloudSetCredentialsRequest, } from "../../../shared/types"; import { decodeOpenCodeRegistryId, @@ -73,6 +80,7 @@ import { parseStructuredOutput } from "./utils"; import { deleteApiKey as deleteStoredApiKey, getAllApiKeys, + getApiKey as getStoredApiKey, getApiKeyStoreStatus, listStoredProviders, storeApiKey as storeStoredApiKey, @@ -98,6 +106,13 @@ import { resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; import { runProviderTask } from "./providerTaskRunner"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; import { loadCursorSdk } from "./cursorSdkLoader"; +import { + createDevinCloudClient, + detectDevinAuthMode, + normalizeDevinSessionId, + type DevinCloudClient, + type DevinCloudListSessionsArgs, +} from "./devinCloudClient"; import { cursorUsageCostUsd, mapCursorAgentUsageToTokenEntry, @@ -150,6 +165,7 @@ export type AiIntegrationStatus = { kimi?: boolean; grok?: boolean; copilot?: boolean; + devin?: boolean; }; models: { claude: AgentModelDescriptor[]; @@ -160,10 +176,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"; @@ -991,6 +1008,7 @@ export const ACP_STATUS_FAMILIES = { kimi: "moonshot", grok: "xai", copilot: "github-copilot", + devin: "devin", } as const; function buildStatusModelLists( @@ -1010,6 +1028,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) : [], }; } @@ -1126,6 +1145,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); @@ -1519,6 +1539,195 @@ export function createAiIntegrationService(args: { } }; + // ---- Devin Cloud ------------------------------------------------------- + // PAT (`cog_`, v3, self-serve on every account) primary; `apk_user_` v1 + // personal keys ride the same calls as a fallback for PAT-disabled + // enterprises. Org id auto-discovery means a v3 user pastes only the key. + + let devinCloudClientCache: { + apiKey: string; + orgId: string | null; + client: DevinCloudClient; + } | null = null; + + const requireDevinCloudApiKey = async (): Promise => { + const key = getStoredApiKey("devin"); + if (!key) { + throw new Error("Add a Devin API token before using Devin Cloud agents."); + } + return key; + }; + + const readDevinCloudOrgId = (): string | null => { + const snapshot = projectConfigService.get(); + const aiConfig = extractAiConfig(snapshot); + const orgId = typeof aiConfig.devinCloudOrgId === "string" ? aiConfig.devinCloudOrgId.trim() : ""; + return orgId || null; + }; + + const persistDevinCloudOrgId = (orgId: string | null): void => { + try { + const snapshot = projectConfigService.get(); + const localAi = { ...(snapshot.local?.ai ?? {}), devinCloudOrgId: orgId }; + projectConfigService.save({ + shared: snapshot.shared, + local: { ...(snapshot.local ?? {}), ai: localAi }, + }); + } catch (error) { + logger.warn("ai.devin_cloud.org_id_persist_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const devinCloudClient = async (): Promise => { + const apiKey = await requireDevinCloudApiKey(); + const orgId = readDevinCloudOrgId(); + if ( + devinCloudClientCache + && devinCloudClientCache.apiKey === apiKey + && devinCloudClientCache.orgId === orgId + ) { + return devinCloudClientCache.client; + } + const client = createDevinCloudClient({ apiKey, orgId, logger }); + devinCloudClientCache = { apiKey, orgId, client }; + return client; + }; + + const rememberDiscoveredDevinOrg = (client: DevinCloudClient): void => { + const resolved = client.getOrgId(); + if (resolved && resolved !== readDevinCloudOrgId()) { + persistDevinCloudOrgId(resolved); + } + }; + + const getDevinCloudAuthStatus = async (): Promise => { + const apiKey = getStoredApiKey("devin"); + if (!apiKey) { + return { configured: false, authMode: null, orgId: null, orgName: null, error: null }; + } + return { + configured: true, + authMode: detectDevinAuthMode(apiKey), + orgId: devinCloudClientCache?.orgId ?? readDevinCloudOrgId(), + orgName: null, + error: null, + }; + }; + + const setDevinCloudCredentials = async ( + args: DevinCloudSetCredentialsRequest, + ): Promise => { + const key = args.apiKey.trim(); + if (!key) { + deleteStoredApiKey("devin"); + persistDevinCloudOrgId(null); + devinCloudClientCache = null; + return { configured: false, authMode: null, orgId: null, orgName: null, error: null }; + } + const orgId = args.orgId?.trim() || null; + // Verify before persisting so a bad key never reaches the store. + const client = createDevinCloudClient({ apiKey: key, orgId, logger }); + const { orgName } = await client.verify(); + storeStoredApiKey("devin", key); + const resolvedOrgId = client.getOrgId() ?? orgId; + if (detectDevinAuthMode(key) === "v3") { + persistDevinCloudOrgId(resolvedOrgId); + } + devinCloudClientCache = { apiKey: key, orgId: resolvedOrgId, client }; + return { + configured: true, + authMode: detectDevinAuthMode(key), + orgId: resolvedOrgId, + orgName, + error: null, + }; + }; + + const listDevinCloudSessions = async ( + args: DevinCloudListSessionsArgs = {}, + ): Promise<{ items: DevinCloudSessionSummary[]; endCursor: string | null }> => { + const client = await devinCloudClient(); + const result = await client.listSessions(args); + rememberDiscoveredDevinOrg(client); + return result; + }; + + const getDevinCloudSession = async ( + devinSessionId: string, + ): Promise => { + const client = await devinCloudClient(); + const result = await client.getSession(devinSessionId); + rememberDiscoveredDevinOrg(client); + return result; + }; + + const createDevinCloudSession = async ( + args: DevinCloudCreateSessionRequest, + ): Promise => { + const client = await devinCloudClient(); + const result = await client.createSession(args); + rememberDiscoveredDevinOrg(client); + return result; + }; + + const listDevinCloudMessages = async (args: { + devinSessionId: string; + first?: number; + after?: string | null; + }) => { + const client = await devinCloudClient(); + return await client.listMessages(args.devinSessionId, { + first: args.first, + after: args.after, + }); + }; + + const listDevinCloudAttachments = async ( + devinSessionId: string, + ) => { + const client = await devinCloudClient(); + return await client.listAttachments(devinSessionId); + }; + + const downloadDevinCloudAttachment = async ( + attachment: DevinCloudAttachment, + ) => { + const client = await devinCloudClient(); + return await client.downloadAttachment(attachment); + }; + + const sendDevinCloudMessage = async ( + args: DevinCloudSendMessageRequest, + ): Promise => { + const client = await devinCloudClient(); + const id = normalizeDevinSessionId(args.devinSessionId); + await client.sendMessage(id, { + message: args.message, + ...(args.attachmentUrls?.length ? { attachmentUrls: args.attachmentUrls } : {}), + }); + return { delivered: true }; + }; + + const terminateDevinCloudSession = async (args: { + devinSessionId: string; + archive?: boolean; + }): Promise => { + const client = await devinCloudClient(); + await client.terminateSession(args.devinSessionId, { archive: args.archive }); + }; + + const archiveDevinCloudSession = async (devinSessionId: string): Promise => { + const client = await devinCloudClient(); + await client.archiveSession(devinSessionId); + }; + + const unarchiveDevinCloudSession = async (devinSessionId: string): Promise => { + const client = await devinCloudClient(); + await client.unarchiveSession(devinSessionId); + }; + const getMode = (): AiProviderMode => { const snapshot = projectConfigService.get(); return deriveMode({ snapshot }); @@ -2071,6 +2280,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 @@ -2085,6 +2295,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; })); @@ -2271,6 +2482,20 @@ export function createAiIntegrationService(args: { listCursorCloudArtifacts, downloadCursorCloudArtifact, + getDevinCloudAuthStatus, + setDevinCloudCredentials, + listDevinCloudSessions, + getDevinCloudSession, + createDevinCloudSession, + listDevinCloudMessages, + listDevinCloudAttachments, + downloadDevinCloudAttachment, + sendDevinCloudMessage, + terminateDevinCloudSession, + archiveDevinCloudSession, + unarchiveDevinCloudSession, + requireDevinCloudApiKey, + getAvailabilityAsync, resolveModelForTask, getConfiguredFeatureModel, diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index 31f4c7626a..d2ba713125 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -50,6 +50,7 @@ const ENV_KEY_PROVIDERS: Record = { together: "TOGETHER_API_KEY", openrouter: "OPENROUTER_API_KEY", cursor: "CURSOR_API_KEY", + devin: "DEVIN_API_KEY", moonshotai: "MOONSHOT_API_KEY", }; diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index e5522cfcd5..818f1f018a 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { homedir } from "node:os"; +import { homedir, platform } from "node:os"; import { spawnAsync } from "../shared/utils"; import { augmentProcessPathWithShellAndKnownCliDirs, @@ -32,13 +32,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 { @@ -118,6 +119,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"]], }; /** @@ -163,6 +167,23 @@ async function inspectAcpCliCredentials( return { authenticated: await fileExists(path.join(root, "config.toml")), verified: false }; } + if (cli === "devin") { + // Devin reads WINDSURF_API_KEY first, then stored creds under + // ~/.config/devin (%APPDATA%\devin on Windows). There is no config-home + // env override, so probe the fixed location. + if (env.WINDSURF_API_KEY?.trim()) return { authenticated: true, verified: false }; + const root = platform() === "win32" + ? path.join(env.APPDATA?.trim() || path.join(home, "AppData", "Roaming"), "devin") + : dir(env.XDG_CONFIG_HOME, ".config/devin"); + const candidates = ["auth.json", "credentials.json", "credentials", "config.json"]; + for (const name of candidates) { + if (await fileExists(path.join(root, name))) { + return { authenticated: true, verified: false }; + } + } + 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/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts new file mode 100644 index 0000000000..8035a212fd --- /dev/null +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -0,0 +1,626 @@ +/** + * Devin Cloud REST client. + * + * Two credential generations exist, and ADE supports both so every Devin + * account works: + * + * - **v3 / PAT** (`cog_...`): `https://api.devin.ai/v3/organizations/{org}/...`. + * PATs are user-identity, self-serve, and non-expiring on non-enterprise + * accounts. The org id can be configured (`ai.devinCloudOrgId`) or + * auto-discovered from `GET /v3/enterprise/organizations`. + * - **v1 / personal key** (`apk_user_...`): `https://api.devin.ai/v1/...`. + * Deprecated upstream but still accepted — the fallback for enterprises + * whose admins disable PATs. v1 has no org scoping, no `repos` binding on + * create, and no archive endpoint; those gaps degrade rather than error. + */ + +import type { + DevinCloudAttachment, + DevinCloudAuthMode, + DevinCloudCreateSessionRequest, + DevinCloudListMessagesResult, + DevinCloudMessage, + DevinCloudMode, + DevinCloudSessionStatus, + DevinCloudSessionSummary, +} from "../../../shared/types/config"; +import type { Logger } from "../logging/logger"; + +const DEFAULT_TIMEOUT_MS = 20_000; +const API_BASE = "https://api.devin.ai"; +const USER_AGENT = "ade-devin-cloud/1"; + +type FetchLike = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + }, +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; + arrayBuffer?: () => Promise; +}>; + +export type DevinCloudClientArgs = { + apiKey: string; + /** Configured org id; auto-discovered for v3 keys when null. */ + orgId: string | null; + logger?: Logger; + fetchImpl?: FetchLike; + timeoutMs?: number; +}; + +export class DevinCloudApiError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, body: string) { + super(`Devin API request failed (${status}): ${body.slice(0, 300)}`); + this.name = "DevinCloudApiError"; + this.status = status; + this.body = body; + } +} + +function isRecord(value: unknown): value is Record { + 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; + const millis = Date.parse(value); + if (Number.isFinite(millis)) return Math.floor(millis / 1000); + } + return null; +} + +/** + * v1 personal keys carry the `apk_user_` (or plain `apk_`) prefix. Everything + * else is treated as a v3 credential — PATs are `cog_`, but prefix sniffing + * should not reject a future token shape outright. + */ +export function detectDevinAuthMode(apiKey: string): DevinCloudAuthMode { + return apiKey.trim().startsWith("apk_") ? "v1" : "v3"; +} + +/** Devin's public session id arrives bare or with the `devin-` prefix; store bare. */ +export function normalizeDevinSessionId(raw: string | null | undefined): string { + const trimmed = (raw ?? "").trim(); + return trimmed.startsWith("devin-") ? trimmed.slice("devin-".length) : trimmed; +} + +const V3_STATUSES: ReadonlySet = new Set([ + "new", + "claimed", + "running", + "resuming", + "suspended", + "exit", + "error", +]); + +function normalizeV3Status(raw: unknown): DevinCloudSessionStatus | null { + const value = readString(raw)?.toLowerCase(); + return value && V3_STATUSES.has(value) ? (value as DevinCloudSessionStatus) : null; +} + +/** + * v1's `status`/`status_enum` vocabulary folded into the v3-shaped summary. + * `blocked` is the loud state and lands as running + waiting_for_user. + */ +function normalizeV1Session(record: Record): { + status: DevinCloudSessionStatus | null; + statusDetail: string | null; +} { + const statusEnum = readString(record.status_enum)?.toLowerCase() ?? ""; + const status = readString(record.status)?.toLowerCase() ?? ""; + if (statusEnum === "finished" || status === "finished") { + return { status: "exit", statusDetail: "finished" }; + } + if (statusEnum === "blocked") { + return { status: "running", statusDetail: "waiting_for_user" }; + } + if (statusEnum === "expired") { + return { status: "exit", statusDetail: "expired" }; + } + if (statusEnum.startsWith("suspend_requested")) { + return { status: "suspended", statusDetail: "user_request" }; + } + if (statusEnum.startsWith("resume_requested")) { + return { status: "resuming", statusDetail: null }; + } + if (statusEnum === "resumed" || statusEnum === "working") { + return { status: "running", statusDetail: "working" }; + } + if (status === "running") { + return { status: "running", statusDetail: "working" }; + } + return { status: statusEnum || status ? "running" : null, statusDetail: statusEnum || null }; +} + +function normalizeV3Session(record: Record): DevinCloudSessionSummary { + const prs = Array.isArray(record.pull_requests) ? record.pull_requests : []; + const mode = readString(record.devin_mode); + return { + sessionId: normalizeDevinSessionId( + readString(record.session_id) ?? readString(record.devin_id) ?? readString(record.id), + ), + title: readString(record.title), + status: normalizeV3Status(record.status), + statusDetail: readString(record.status_detail)?.toLowerCase() ?? null, + isArchived: record.is_archived === true, + url: readString(record.url), + pullRequests: prs.flatMap((entry) => { + if (!isRecord(entry)) return []; + const prUrl = readString(entry.pr_url) ?? readString(entry.url); + if (!prUrl) return []; + return [{ prUrl, prState: readString(entry.pr_state) }]; + }), + tags: Array.isArray(record.tags) ? record.tags.filter((t): t is string => typeof t === "string") : [], + repos: Array.isArray(record.repos) + ? record.repos.filter((t): t is string => typeof t === "string") + : [], + createdAt: readNumber(record.created_at), + updatedAt: readNumber(record.updated_at), + devinMode: (mode ?? null) as DevinCloudMode | null, + acusConsumed: readNumber(record.acus_consumed), + userId: readString(record.user_id), + parentSessionId: readString(record.parent_session_id), + origin: readString(record.origin), + }; +} + +function normalizeV1SessionSummary(record: Record): DevinCloudSessionSummary { + const state = normalizeV1Session(record); + const pr = isRecord(record.pull_request) ? readString(record.pull_request.url) : null; + const sessionId = normalizeDevinSessionId(readString(record.session_id)); + return { + sessionId, + title: readString(record.title), + status: state.status, + statusDetail: state.statusDetail, + isArchived: false, + url: sessionId ? `https://app.devin.ai/sessions/${sessionId}` : null, + pullRequests: pr ? [{ prUrl: pr, prState: null }] : [], + tags: Array.isArray(record.tags) ? record.tags.filter((t): t is string => typeof t === "string") : [], + repos: [], + createdAt: readNumber(record.created_at), + updatedAt: readNumber(record.updated_at), + devinMode: null, + acusConsumed: null, + userId: readString(record.requesting_user_email), + parentSessionId: null, + origin: "api", + }; +} + +function normalizeV3Message(record: Record): DevinCloudMessage | null { + const eventId = readString(record.event_id); + const message = readString(record.message); + const source = readString(record.source)?.toLowerCase(); + if (!eventId || message == null) return null; + return { + eventId, + source: source === "user" ? "user" : "devin", + message: record.message as string, + createdAt: readNumber(record.created_at) ?? 0, + }; +} + +function normalizeV1Message(record: Record): DevinCloudMessage | null { + const eventId = readString(record.event_id); + const message = typeof record.message === "string" ? record.message : null; + const type = readString(record.type)?.toLowerCase() ?? ""; + if (!eventId || message == null) return null; + return { + eventId, + source: type.startsWith("user") || type === "initial_user_message" ? "user" : "devin", + message, + createdAt: readNumber(record.timestamp) ?? 0, + }; +} + +export type DevinCloudListSessionsArgs = { + first?: number; + after?: string | null; + repoNames?: string[]; + tags?: string[]; + sessionIds?: string[]; + isArchived?: boolean; + createdAfter?: number; + updatedAfter?: number; +}; + +export type DevinCloudListSessionsResult = { + items: DevinCloudSessionSummary[]; + endCursor: string | null; + /** v1 has no cursor — page math continues via offset. */ + offset?: number; +}; + +export type DevinCloudClient = ReturnType; + +export function createDevinCloudClient(args: DevinCloudClientArgs) { + const apiKey = args.apiKey.trim(); + const authMode = detectDevinAuthMode(apiKey); + const fetchImpl: FetchLike = args.fetchImpl ?? (fetch as unknown as FetchLike); + const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS; + // The org a v3 key can see, resolved lazily: configured id first, then the + // single org `GET /v3/enterprise/organizations` reports. + let cachedOrgId = args.orgId?.trim() || null; + let orgLookupPromise: Promise | null = null; + + const request = async ( + path: string, + init: { method?: string; body?: unknown; timeoutMs?: number } = {}, + ): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs); + try { + const response = await fetchImpl(`${API_BASE}${path}`, { + method: init.method ?? "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + signal: controller.signal, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new DevinCloudApiError(response.status, text); + } + const text = await response.text(); + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return undefined as T; + } + } finally { + clearTimeout(timer); + } + }; + + const resolveOrgId = async (): Promise => { + if (authMode === "v1") { + throw new Error("Devin v1 personal keys are not org-scoped; this call needs a v3 PAT."); + } + if (cachedOrgId) return cachedOrgId; + if (!orgLookupPromise) { + orgLookupPromise = (async (): Promise => { + const page = await request( + "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify({ first: 2 })), + ); + const items = isRecord(page) && Array.isArray(page.items) ? page.items : []; + const first = items.find(isRecord); + const id = first ? readString(first.org_id) ?? readString(first.id) : null; + if (!id) { + throw new Error( + "Could not determine your Devin org. Add your org id (org-...) in Settings > Devin.", + ); + } + if (items.length > 1) { + args.logger?.warn?.("devin_cloud.multi_org_defaulting_to_first", { orgId: id }); + } + return id; + })().catch((error) => { + orgLookupPromise = null; + throw error; + }); + } + const resolved = await orgLookupPromise; + cachedOrgId = resolved; + return resolved!; + }; + + const orgPath = async (suffix: string): Promise => { + const orgId = await resolveOrgId(); + return `/v3/organizations/${encodeURIComponent(orgId)}${suffix}`; + }; + + const listSessions = async ( + listArgs: DevinCloudListSessionsArgs = {}, + ): Promise => { + const first = Math.min(Math.max(listArgs.first ?? 100, 1), 200); + if (authMode === "v1") { + const offset = listArgs.after ? Number(listArgs.after) || 0 : 0; + const params = new URLSearchParams({ limit: String(Math.min(first, 100)), offset: String(offset) }); + if (listArgs.tags?.length) params.set("tags", listArgs.tags.join(",")); + const page = await request(`/v1/sessions?${params.toString()}`); + const sessions = isRecord(page) && Array.isArray(page.sessions) ? page.sessions : []; + const items = sessions.filter(isRecord).map(normalizeV1SessionSummary); + const next = items.length >= Math.min(first, 100) ? String(offset + items.length) : null; + return { items, endCursor: next, offset: offset + items.length }; + } + const qs = { + first, + ...(listArgs.after ? { after: listArgs.after } : {}), + ...(listArgs.repoNames?.length ? { repo_names: listArgs.repoNames } : {}), + ...(listArgs.tags?.length ? { tags: listArgs.tags } : {}), + ...(listArgs.sessionIds?.length ? { session_ids: listArgs.sessionIds } : {}), + ...(listArgs.isArchived !== undefined ? { is_archived: listArgs.isArchived } : {}), + ...(listArgs.createdAfter ? { created_after: listArgs.createdAfter } : {}), + ...(listArgs.updatedAfter ? { updated_after: listArgs.updatedAfter } : {}), + }; + const page = await request( + `${await orgPath("/sessions")}?qs=${encodeURIComponent(JSON.stringify(qs))}`, + ); + const items = isRecord(page) && Array.isArray(page.items) ? page.items : []; + const endCursor = isRecord(page) ? readString(page.end_cursor) : null; + return { + items: items.filter(isRecord).map(normalizeV3Session), + endCursor, + }; + }; + + const getSession = async (devinSessionId: string): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + if (authMode === "v1") { + const record = await request(`/v1/sessions/${encodeURIComponent(id)}`); + return isRecord(record) ? normalizeV1SessionSummary(record) : null; + } + const record = await request(await orgPath(`/sessions/${encodeURIComponent(id)}`)); + return isRecord(record) ? normalizeV3Session(record) : null; + }; + + const createSession = async ( + input: DevinCloudCreateSessionRequest, + ): Promise => { + const prompt = input.prompt.trim(); + if (!prompt) throw new Error("Prompt is required."); + if (authMode === "v1") { + const body: Record = { + prompt, + ...(input.tags?.length ? { tags: input.tags } : {}), + ...(input.title?.trim() ? { title: input.title.trim() } : {}), + }; + const record = await request("/v1/sessions", { method: "POST", body }); + const sessionId = isRecord(record) ? normalizeDevinSessionId(readString(record.session_id)) : ""; + if (!sessionId) throw new Error("Devin did not return a session id."); + const fresh = await getSession(sessionId).catch(() => null); + return fresh ?? { + sessionId, + title: input.title ?? null, + status: "new", + statusDetail: null, + isArchived: false, + url: isRecord(record) ? readString(record.url) : `https://app.devin.ai/sessions/${sessionId}`, + pullRequests: [], + tags: input.tags ?? [], + repos: [], + createdAt: null, + updatedAt: null, + devinMode: null, + acusConsumed: null, + userId: null, + parentSessionId: null, + origin: "api", + }; + } + const body: Record = { + prompt, + ...(input.repoUrls?.length ? { repos: input.repoUrls } : {}), + ...(input.tags?.length ? { tags: input.tags } : {}), + ...(input.title?.trim() ? { title: input.title.trim() } : {}), + ...(input.devinMode ? { devin_mode: input.devinMode } : {}), + ...(input.resumable !== undefined ? { resumable: input.resumable } : {}), + ...(input.bypassApproval !== undefined ? { bypass_approval: input.bypassApproval } : {}), + }; + const record = await request(await orgPath("/sessions"), { method: "POST", body }); + if (!isRecord(record)) throw new Error("Devin did not return a session."); + const summary = normalizeV3Session(record); + if (!summary.sessionId) { + const id = normalizeDevinSessionId(readString(record.session_id) ?? readString(record.devin_id)); + if (!id) throw new Error("Devin did not return a session id."); + summary.sessionId = id; + } + return summary; + }; + + const listMessages = async ( + devinSessionId: string, + listArgs: { first?: number; after?: string | null } = {}, + ): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + const first = Math.min(Math.max(listArgs.first ?? 200, 1), 200); + if (authMode === "v1") { + // v1 has no messages endpoint; the session record carries them inline. + const record = await request(`/v1/sessions/${encodeURIComponent(id)}`); + const raw = isRecord(record) && Array.isArray(record.messages) ? record.messages : []; + const items = raw + .filter(isRecord) + .map(normalizeV1Message) + .filter((m): m is DevinCloudMessage => m !== null) + .slice(-first); + return { items, endCursor: null }; + } + const qs = { + first, + ...(listArgs.after ? { after: listArgs.after } : {}), + }; + const page = await request( + `${await orgPath(`/sessions/${encodeURIComponent(id)}/messages`)}?qs=${encodeURIComponent(JSON.stringify(qs))}`, + ); + const items = isRecord(page) && Array.isArray(page.items) ? page.items : []; + const endCursor = isRecord(page) ? readString(page.end_cursor) : null; + return { + items: items + .filter(isRecord) + .map(normalizeV3Message) + .filter((m): m is DevinCloudMessage => m !== null), + endCursor, + }; + }; + + /** + * List the files a session uploaded or produced (recordings, screenshots, + * exports). v3-only surface: v1 has no attachments endpoint, so a legacy key + * simply reports none. + */ + const listAttachments = async ( + devinSessionId: string, + ): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + if (authMode === "v1") return []; + const rows = await request( + await orgPath(`/sessions/${encodeURIComponent(id)}/attachments`), + ); + const items = Array.isArray(rows) ? rows : []; + return items + .filter(isRecord) + .map((row) => ({ + attachmentId: readString(row.attachment_id) ?? readString(row.id) ?? "", + name: readString(row.name) ?? "attachment", + url: readString(row.url) ?? "", + source: readString(row.source) === "user" ? "user" as const : "devin" as const, + contentType: readString(row.content_type), + })) + .filter((item) => item.attachmentId.length > 0 && item.url.length > 0); + }; + + /** + * Fetch attachment bytes. The URLs Devin hands out are short-lived signed + * links, so the file is fetched by attachment id through the same Bearer + * auth every other call uses — the caller saves it where it wants. + */ + const downloadAttachment = async ( + attachment: DevinCloudAttachment, + ): Promise => { + if (authMode === "v1") return null; + const path = await orgPath( + `/attachments/${encodeURIComponent(attachment.attachmentId)}/${encodeURIComponent(attachment.name)}`, + ); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "User-Agent": USER_AGENT, + }, + signal: controller.signal, + }); + if (!response.ok || !response.arrayBuffer) return null; + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } catch { + return null; + } finally { + clearTimeout(timer); + } + }; + + const sendMessage = async ( + devinSessionId: string, + input: { message: string; attachmentUrls?: string[] }, + ): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + const message = input.message.trim(); + if (!id) throw new Error("Devin session id is required."); + if (!message) throw new Error("Message is required."); + if (authMode === "v1") { + await request(`/v1/sessions/${encodeURIComponent(id)}/message`, { + method: "POST", + body: { message }, + }); + return; + } + await request(await orgPath(`/sessions/${encodeURIComponent(id)}/messages`), { + method: "POST", + body: { + message, + ...(input.attachmentUrls?.length ? { attachment_urls: input.attachmentUrls } : {}), + }, + }); + }; + + const terminateSession = async ( + devinSessionId: string, + options: { archive?: boolean } = {}, + ): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + if (authMode === "v1") { + await request(`/v1/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }); + return; + } + const params = options.archive !== undefined ? `?archive=${options.archive ? "true" : "false"}` : ""; + await request(await orgPath(`/sessions/${encodeURIComponent(id)}`) + params, { + method: "DELETE", + }); + }; + + const archiveSession = async (devinSessionId: string): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + if (authMode === "v1") { + throw new Error("Devin v1 keys cannot archive sessions; use a v3 PAT for archive."); + } + await request(await orgPath(`/sessions/${encodeURIComponent(id)}/archive`), { + method: "POST", + }); + }; + + const unarchiveSession = async (devinSessionId: string): Promise => { + const id = normalizeDevinSessionId(devinSessionId); + if (!id) throw new Error("Devin session id is required."); + if (authMode === "v1") { + throw new Error("Devin v1 keys cannot unarchive sessions; use a v3 PAT for unarchive."); + } + await request(await orgPath(`/sessions/${encodeURIComponent(id)}/unarchive`), { + method: "POST", + }); + }; + + /** Verify the credential: v3 lists orgs, v1 lists one session page. */ + const verify = async (): Promise<{ orgName: string | null }> => { + if (authMode === "v1") { + await request("/v1/sessions?limit=1"); + return { orgName: null }; + } + const page = await request( + "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify({ first: 50 })), + ); + const items = isRecord(page) && Array.isArray(page.items) ? page.items : []; + const first = items.find(isRecord); + const name = first ? readString(first.org_name) ?? readString(first.name) : null; + const id = first ? readString(first.org_id) ?? readString(first.id) : null; + if (id && !cachedOrgId) cachedOrgId = id; + return { orgName: name }; + }; + + return { + authMode, + listSessions, + getSession, + createSession, + listMessages, + listAttachments, + downloadAttachment, + sendMessage, + terminateSession, + archiveSession, + unarchiveSession, + verify, + /** Resolved org id when known (configured or discovered). */ + getOrgId: () => cachedOrgId, + }; +} 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/devin.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts new file mode 100644 index 0000000000..921cdc05bc --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/devin.ts @@ -0,0 +1,107 @@ +/** + * 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, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardClose, + standardLoad, + standardResume, + standardSetConfigOption, + standardSetModel, + transportGatedMcpInjection, + withOptionalEnv, +} from "./shared"; + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + return { + command: context.binaryPath, + args: ["acp"], + cwd: context.cwd, + env: withOptionalEnv(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, + + ignoredNotificationMethods: [], + + 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.", + ], + + usageSource: "usage_update", + usage: capability(({ usageUpdate }) => { + if (!usageUpdate) return null; + return { + contextUsedTokens: usageUpdate.used, + contextWindowTokens: usageUpdate.size, + ...(usageUpdate.cost && usageUpdate.cost.currency.toUpperCase() === "USD" + ? { costUsd: usageUpdate.cost.amount } + : {}), + }; + }), + + 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/index.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts index 696d0c815f..3ccfd9595b 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,7 @@ import type { AcpDialect, AcpProviderId } from "../acpHostTypes"; import { copilotDialect } from "./copilot"; +import { devinDialect } from "./devin"; import { grokDialect } from "./grok"; import { kimiDialect } from "./kimi"; import { qwenDialect } from "./qwen"; @@ -17,13 +18,14 @@ export const ACP_DIALECTS: Record = { kimi: kimiDialect, grok: grokDialect, copilot: copilotDialect, + devin: devinDialect, }; export function acpDialectFor(providerId: AcpProviderId): AcpDialect { return ACP_DIALECTS[providerId]; } -export { copilotDialect, grokDialect, kimiDialect, qwenDialect }; +export { copilotDialect, devinDialect, grokDialect, kimiDialect, qwenDialect }; export { COPILOT_TUI_ONLY_COMMANDS, includeCopilotSlashCommand } from "./copilot"; export { GROK_CLAUDE_MARKER_OVERRIDE_ENV, 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 7a3bcfd222..ccaa4fc63f 100644 --- a/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts +++ b/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts @@ -1783,24 +1783,24 @@ 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.22.3 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 reports no usage at all. - usage_fold: { qwen: "run", kimi: "degrade", grok: "run", copilot: "run" }, - mcp_injection: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + usage_fold: { qwen: "run", kimi: "degrade", 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/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 6eeb5f2f76..cf33df2329 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -425,6 +425,10 @@ import type { LaneLinearIssue, SessionLinearIssueLink, CursorCloudServiceTier, + DevinCloudAttachment, + DevinCloudMessage, + DevinCloudMode, + DevinCloudSessionSummary, } from "../../../shared/types"; import { applyClaudePlanModeTransition as applyClaudePlanModeTransitionShared, @@ -726,6 +730,7 @@ import type { CtoMemoryService } from "../cto/ctoMemoryService"; import type { IssueTracker } from "../cto/issueTracker"; import type { createPrService } from "../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; +import { createComputerUseArtifactPath } from "../computerUse/localComputerUse"; import { buildOpenCodePromptParts, mapPermissionModeToOpenCodeAgent, @@ -793,6 +798,21 @@ import { type CursorCloudMirrorRefreshResult, } from "./cursorCloudConversation"; import { createCursorCloudMirrorWatch } from "./cursorCloudMirrorWatch"; +import { + DEVIN_CLOUD_EMPTY_TERMINAL_READ_LIMIT, + DEVIN_CLOUD_MESSAGES_RETRY_ATTEMPTS, + DEVIN_CLOUD_MESSAGES_RETRY_MS, + DEVIN_CLOUD_PLACEHOLDER_NAME_READ_LIMIT, + DEVIN_CLOUD_REMOTE_NAME_READ_TTL_MS, + devinCloudMessageFingerprint, + isDevinCloudSessionLive, +} from "./devinCloudConversation"; +import { normalizeDevinSessionId } from "../ai/devinCloudClient"; +import { + buildDevinCloudAdeTags, + devinCloudFleetStatus, + normalizeDevinCloudMode, +} from "../../../shared/devinCloudFleetStatus"; import { acquireDroidSdkConnection, releaseDroidSdkConnection, @@ -1448,6 +1468,14 @@ 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 agent tier requested at create (`devin_mode`). */ + devinMode?: DevinCloudMode | null; + /** First turn id at which the session flipped to cloud (renders the system bubble). */ + devinPromotedTurnId?: string; recentConversationEntries?: PersistedRecentConversationEntry[]; continuitySummary?: string | null; continuitySummaryUpdatedAt?: string | null; @@ -8183,6 +8211,18 @@ function getCursorSdkApiKey(): string | null { return env || null; } +function getDevinCloudApiKey(): string | null { + try { + const stored = getApiKey("devin")?.trim(); + if (stored) return stored; + } catch { + // API key store is initialized by the Electron main process; unit tests may + // exercise chat helpers before that setup runs. + } + const env = process.env.DEVIN_API_KEY?.trim(); + return env || null; +} + function assertCursorChatModelCanUseSdk(args: { modelRef: string; descriptor?: ModelDescriptor | null; @@ -14865,6 +14905,18 @@ 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.devinMode !== undefined && managed.session.devinMode !== null + ? { devinMode: managed.session.devinMode } + : prevPersisted?.devinMode ? { devinMode: prevPersisted.devinMode } : {}), + ...(managed.session.devinPromotedTurnId + ? { devinPromotedTurnId: managed.session.devinPromotedTurnId } + : prevPersisted?.devinPromotedTurnId ? { devinPromotedTurnId: prevPersisted.devinPromotedTurnId } : {}), ...(managed.recentConversationEntries.length ? { recentConversationEntries: managed.recentConversationEntries.map((entry) => ({ @@ -15225,6 +15277,17 @@ 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 devinMode = normalizeDevinCloudMode(record.devinMode); + const devinPromotedTurnId = typeof record.devinPromotedTurnId === "string" && record.devinPromotedTurnId.trim().length + ? record.devinPromotedTurnId.trim() + : undefined; const codexTerminalTurnIds = Array.isArray(record.codexTerminalTurnIds) ? uniqueNonEmpty( record.codexTerminalTurnIds.map((turnId) => typeof turnId === "string" ? turnId : null), @@ -15352,6 +15415,10 @@ export function createAgentChatService(args: { ...(cursorCloudAgentId ? { cursorCloudAgentId } : {}), ...(cursorRuntime ? { cursorRuntime } : {}), ...(cursorPromotedTurnId ? { cursorPromotedTurnId } : {}), + ...(devinSessionId ? { devinSessionId } : {}), + ...(devinRuntime ? { devinRuntime } : {}), + ...(devinMode ? { devinMode } : {}), + ...(devinPromotedTurnId ? { devinPromotedTurnId } : {}), ...(approvalOverrides?.length ? { approvalOverrides } : {}), ...(pendingSteers?.length ? { pendingSteers } : {}), ...(recentConversationEntries?.length ? { recentConversationEntries } : {}), @@ -15475,7 +15542,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 {}; } }; @@ -15976,6 +16044,7 @@ export function createAgentChatService(args: { kimi: "curl -LsSf https://code.kimi.com/kimi-code/install.sh | bash", grok: "npm install -g @xai-official/grok", copilot: "npm install -g @github/copilot", + devin: "curl -fsSL https://cli.devin.ai/install.sh | bash", }; /** @@ -20351,6 +20420,10 @@ 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?.devinMode ? { devinMode: persisted.devinMode } : {}), + ...(persisted?.devinPromotedTurnId ? { devinPromotedTurnId: persisted.devinPromotedTurnId } : {}), ...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}), ...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}), ...(persisted?.surface ? { surface: persisted.surface } : {}), @@ -25926,6 +25999,9 @@ export function createAgentChatService(args: { case "copilot": return copilotConfigHome({ env }); // Grok reads `~/.grok` and nothing else, so ADE sets nothing. case "grok": return null; + // Devin reads `~/.config/devin` (%APPDATA%\devin on Windows) and honors + // no override env var, so ADE sets nothing. + case "devin": return null; } }; @@ -43072,6 +43148,583 @@ export function createAgentChatService(args: { return { sessionId: managed.session.id, session: managed.session }; }; + // ------------------------------------------------------------------ + // Devin cloud mirror + // + // Devin's surface is simpler than Cursor Cloud's: one flat GET messages + // poll — no runs, no attach lease, no webhook ingress. The mirror dedupes + // on event_id plus the same user:/text: fingerprints emitted events + // produce, so a restarted host does not double-print history and a + // composer send does not echo back from the next poll. + // ------------------------------------------------------------------ + + const devinCloudHydrateInFlight = new Set(); + const devinCloudHydratedEventIds = new Map>(); + const devinCloudRemoteNameReadAt = new Map(); + const devinCloudEmptyReads = new Map(); + const devinCloudPlaceholderNameReads = new Map(); + const devinCloudDoneAnnounced = new Set(); + /** Attachment ids already filed into the proof drawer, per ADE session. */ + const devinCloudSyncedAttachmentIds = new Map>(); + /** Sessions whose needs-you marker this mirror raised (so it can clear it without touching others'). */ + const devinCloudAttentionRaised = new Set(); + + const forgetDevinCloudHydrationState = (sessionId: string): void => { + devinCloudHydratedEventIds.delete(sessionId); + devinCloudRemoteNameReadAt.delete(sessionId); + devinCloudEmptyReads.delete(sessionId); + devinCloudPlaceholderNameReads.delete(sessionId); + devinCloudDoneAnnounced.delete(sessionId); + devinCloudSyncedAttachmentIds.delete(sessionId); + devinCloudAttentionRaised.delete(sessionId); + }; + + const clearAllDevinCloudHydrationState = (): void => { + devinCloudHydrateInFlight.clear(); + devinCloudHydratedEventIds.clear(); + devinCloudRemoteNameReadAt.clear(); + devinCloudEmptyReads.clear(); + devinCloudPlaceholderNameReads.clear(); + devinCloudDoneAnnounced.clear(); + devinCloudSyncedAttachmentIds.clear(); + devinCloudAttentionRaised.clear(); + }; + + /** File types the proof drawer can actually render; everything else is skipped, not errored. */ + const DEVIN_PROOF_IMPORTABLE_EXTENSIONS = new Set([ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "avif", "heic", + "mp4", "webm", "mov", "avi", "mkv", "heif", "tif", "tiff", "m4v", "ogv", + "zip", "har", "log", "txt", "md", + ]); + + const devinProofKind = (attachment: DevinCloudAttachment): string => { + const contentType = (attachment.contentType ?? "").toLowerCase(); + if (contentType.startsWith("image/")) return "screenshot"; + if (contentType.startsWith("video/")) return "video_recording"; + const ext = attachment.name.toLowerCase().split(".").pop() ?? ""; + if (["mp4", "webm", "mov", "avi", "mkv", "m4v", "ogv"].includes(ext)) return "video_recording"; + if (["zip", "har"].includes(ext)) return "browser_trace"; + if (["log", "txt", "md"].includes(ext)) return "console_logs"; + return "screenshot"; + }; + + /** + * File a Devin session's produced files (recordings, screenshots, exports) + * into the proof drawer. The bytes come through the authenticated API — the + * signed URLs in the API response are short-lived — and land in + * `.ade/artifacts/computer-use`, which the broker ingests as file records. + * Failures skip quietly: proof sync must never stall transcript hydration. + */ + const syncDevinCloudAttachments = async ( + managed: ManagedChatSession, + devinSessionId: string, + ): Promise => { + const broker = computerUseArtifactBrokerRef; + if (!broker) return; + let attachments: DevinCloudAttachment[]; + try { + attachments = await aiIntegrationService.listDevinCloudAttachments(devinSessionId); + } catch (error) { + logger.warn("agent_chat.devin_cloud_attachments_failed", { + sessionId: managed.session.id, + devinSessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + if (!attachments.length) return; + const seen = devinCloudSyncedAttachmentIds.get(managed.session.id) ?? new Set(); + devinCloudSyncedAttachmentIds.set(managed.session.id, seen); + for (const attachment of attachments) { + if (attachment.source !== "devin") continue; + if (seen.has(attachment.attachmentId)) continue; + seen.add(attachment.attachmentId); + const extension = attachment.name.toLowerCase().split(".").pop() ?? ""; + if (!DEVIN_PROOF_IMPORTABLE_EXTENSIONS.has(extension)) continue; + try { + const bytes = await aiIntegrationService.downloadDevinCloudAttachment(attachment); + if (!bytes?.length) continue; + const filePath = createComputerUseArtifactPath(projectRoot, `devin-${attachment.name}`, extension); + fs.writeFileSync(filePath, bytes); + broker.ingest({ + backend: { name: "devin", style: "external_cli" }, + inputs: [{ + path: filePath, + kind: devinProofKind(attachment), + title: attachment.name, + mimeType: attachment.contentType ?? undefined, + description: `Attachment from Devin session ${devinSessionId}`, + }], + owners: [{ kind: "chat_session", id: managed.session.id }], + }); + } catch (error) { + logger.warn("agent_chat.devin_cloud_attachment_sync_failed", { + sessionId: managed.session.id, + devinSessionId, + attachmentId: attachment.attachmentId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + const hydrateDevinCloudMessages = ( + managed: ManagedChatSession, + messages: DevinCloudMessage[], + meta: { turnId: string }, + ): boolean => { + if (!messages.length) return false; + const existingFingerprints = transcriptCloudFingerprints([ + ...(eventHistoryBySession.get(managed.session.id) ?? []), + ...readTranscriptEnvelopes(managed), + ]); + const hydratedIds = devinCloudHydratedEventIds.get(managed.session.id) ?? new Set(); + let emittedVisible = false; + for (const message of messages) { + if (!message.eventId || !message.message) continue; + if (hydratedIds.has(message.eventId)) continue; + hydratedIds.add(message.eventId); + const fingerprint = devinCloudMessageFingerprint(message); + if (fingerprint && fingerprintAlreadyHydrated(existingFingerprints, fingerprint)) continue; + if (fingerprint) existingFingerprints.add(fingerprint); + emittedVisible = true; + emitChatEvent(managed, { + type: message.source === "user" ? "user_message" : "text", + text: message.message, + turnId: meta.turnId, + runtime: "cloud", + }); + } + devinCloudHydratedEventIds.set(managed.session.id, hydratedIds); + return emittedVisible; + }; + + const attachAndHydrateDevinCloudChat = async (args: { + managed: ManagedChatSession; + devinSessionId: string; + }): Promise => { + const { managed, devinSessionId } = args; + if (devinCloudHydrateInFlight.has(managed.session.id)) return false; + devinCloudHydrateInFlight.add(managed.session.id); + const hydrateTurnId = randomUUID(); + let emittedVisible = false; + try { + // Devin owns the session title; a rename on app.devin.ai is not worth an + // API call per mirror tick, so the read is TTL'd like Cursor's. + const readRemoteSession = async (): Promise => { + devinCloudRemoteNameReadAt.set(managed.session.id, Date.now()); + try { + const remote = await aiIntegrationService.getDevinCloudSession(devinSessionId); + if (remote?.title) { + adoptCursorCloudSessionTitle(managed, remote.title, "devin_cloud_session"); + } + return remote; + } catch (error) { + logger.warn("agent_chat.devin_cloud_session_info_failed", { + sessionId: managed.session.id, + devinSessionId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + const lastRemoteReadAt = devinCloudRemoteNameReadAt.get(managed.session.id); + let remote: DevinCloudSessionSummary | null = null; + if ( + lastRemoteReadAt === undefined + || Date.now() - lastRemoteReadAt >= DEVIN_CLOUD_REMOTE_NAME_READ_TTL_MS + ) { + remote = await readRemoteSession(); + } + + let items: DevinCloudMessage[] = []; + let liveStatus = remote?.status ?? null; + for (let attempt = 0; attempt < DEVIN_CLOUD_MESSAGES_RETRY_ATTEMPTS; attempt += 1) { + try { + const page = await aiIntegrationService.listDevinCloudMessages({ + devinSessionId, + first: 200, + }); + items = page.items; + } catch (error) { + logger.warn("agent_chat.devin_cloud_messages_failed", { + sessionId: managed.session.id, + devinSessionId, + attempt, + error: error instanceof Error ? error.message : String(error), + }); + } + if (items.length > 0) break; + if (liveStatus == null) { + remote = remote ?? await aiIntegrationService.getDevinCloudSession(devinSessionId).catch(() => null); + liveStatus = remote?.status ?? null; + } + if (!isDevinCloudSessionLive(liveStatus)) break; + await sleepMs(DEVIN_CLOUD_MESSAGES_RETRY_MS); + } + + emittedVisible = hydrateDevinCloudMessages(managed, items, { turnId: hydrateTurnId }); + + // Attention mapping: a Devin session paused for the user joins the + // board's Needs-you tier like a local approval would. Only flips when + // the remote record was actually read this pass — a skipped remote read + // must not clear a state it cannot see. A local pending request always + // wins; it is the stricter signal. + if (remote && !hasLivePendingInput(managed)) { + const needsYou = devinCloudFleetStatus(remote) === "needs_you"; + if (needsYou && !devinCloudAttentionRaised.has(managed.session.id)) { + devinCloudAttentionRaised.add(managed.session.id); + sessionService.requestAttention( + managed.session.id, + "Devin session is waiting for input", + "provider_structured", + ); + } else if (!needsYou && devinCloudAttentionRaised.delete(managed.session.id)) { + // Only clear the marker this mirror raised — a user- or + // runtime-raised attention belongs to whoever raised it. + sessionService.clearAttentionRequest(managed.session.id); + } + } + + await syncDevinCloudAttachments(managed, devinSessionId).catch((error) => { + logger.warn("agent_chat.devin_cloud_proof_sync_failed", { + sessionId: managed.session.id, + devinSessionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + // Bound the empty-terminal retry the same way Cursor's does: a session + // that finished with no visible transcript never produces one, and an + // unbounded retry would refetch it on every mirror tick forever. + if (items.length === 0 && liveStatus != null && !isDevinCloudSessionLive(liveStatus)) { + const attempts = (devinCloudEmptyReads.get(managed.session.id) ?? 0) + 1; + devinCloudEmptyReads.set(managed.session.id, attempts); + } else if (items.length > 0) { + devinCloudEmptyReads.delete(managed.session.id); + } + + // Devin titles the session shortly after first output — after the read + // above. While the ADE title is still a default, re-read the name only + // on the tick that yields the first visible message or sees the session + // reach a terminal status, bounded like Cursor's placeholder reads. + const reachedTerminalThisPass = liveStatus != null && !isDevinCloudSessionLive(liveStatus); + if ( + (emittedVisible || reachedTerminalThisPass) + && sessionTitleIsDefault(managed) + ) { + const placeholderReads = devinCloudPlaceholderNameReads.get(managed.session.id) ?? 0; + if (placeholderReads < DEVIN_CLOUD_PLACEHOLDER_NAME_READ_LIMIT) { + devinCloudPlaceholderNameReads.set(managed.session.id, placeholderReads + 1); + remote = (await readRemoteSession()) ?? remote; + liveStatus = remote?.status ?? liveStatus; + } + } + + if (emittedVisible) { + flushBufferedReasoning(managed); + flushBufferedText(managed); + if (!isDevinCloudSessionLive(liveStatus) && !devinCloudDoneAnnounced.has(managed.session.id)) { + devinCloudDoneAnnounced.add(managed.session.id); + emitChatEvent(managed, { + type: "done", + turnId: hydrateTurnId, + status: "completed", + runtime: "cloud", + ...(managed.session.model ? { model: managed.session.model } : {}), + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + } + persistChatState(managed); + } + return emittedVisible; + } finally { + devinCloudHydrateInFlight.delete(managed.session.id); + } + }; + + const resolveManagedDevinCloudSession = (sessionId: string): ManagedChatSession | null => { + const existing = managedSessions.get(sessionId); + if (existing) return existing; + try { + return sessionService.get(sessionId) ? ensureManagedSession(sessionId) : null; + } catch { + return null; + } + }; + + const refreshWatchedDevinCloudMirror = async ( + sessionId: string, + ): Promise => { + const managed = resolveManagedDevinCloudSession(sessionId); + if (!managed) return "skipped"; + const devinSessionId = managed.session.devinSessionId?.trim(); + if (!devinSessionId) return "skipped"; + if (devinCloudHydrateInFlight.has(managed.session.id)) return "skipped"; + if (!getDevinCloudApiKey()) return "skipped"; + // An empty terminal transcript stops polling after a few reads — nothing + // more will ever arrive. + if ((devinCloudEmptyReads.get(managed.session.id) ?? 0) >= DEVIN_CLOUD_EMPTY_TERMINAL_READ_LIMIT) { + return "skipped"; + } + try { + const emitted = await attachAndHydrateDevinCloudChat({ managed, devinSessionId }); + return emitted ? "new" : "unchanged"; + } catch (error) { + logger.warn("agent_chat.devin_cloud_mirror_watch_failed", { + sessionId: managed.session.id, + devinSessionId, + error: error instanceof Error ? error.message : String(error), + }); + return "skipped"; + } + }; + + const devinCloudMirror = createCursorCloudMirrorWatch({ + refresh: refreshWatchedDevinCloudMirror, + }); + const watchDevinCloudMirror = devinCloudMirror.watch; + const clearDevinCloudMirrorWatches = devinCloudMirror.clearAll; + + const openDevinCloudChat = async (args: { + devinSessionId: string; + laneId: string; + sessionId?: string | null; + devinMode?: DevinCloudMode | null; + }): Promise<{ sessionId: string; session: AgentChatSession }> => { + const trimmedDevin = normalizeDevinSessionId(args.devinSessionId); + const trimmedLane = args.laneId.trim(); + if (!trimmedDevin) throw new Error("Devin cloud session id is required."); + if (!trimmedLane) throw new Error("Lane id is required."); + + if (!getDevinCloudApiKey()) { + throw new Error("Devin Cloud chat requires a Devin API token."); + } + + 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; + } + } + } + + const existedBefore = Boolean(managed); + if (!managed) { + const created = await createSession({ + laneId: trimmedLane, + provider: "devin", + model: "adaptive", + modelId: "devin/adaptive", + ...(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"; + if (args.devinMode !== undefined) { + managed.session.devinMode = normalizeDevinCloudMode(args.devinMode); + } + persistChatState(managed); + + // New links return immediately; reopening an existing empty cloud chat + // waits for hydrate so Retry/backfill does not time out. + const hydratePromise = attachAndHydrateDevinCloudChat({ + managed, + devinSessionId: trimmedDevin, + }).catch((error) => { + logger.warn("agent_chat.devin_cloud_open_chat_hydrate_failed", { + sessionId: managed.session.id, + devinSessionId: trimmedDevin, + error: error instanceof Error ? error.message : String(error), + }); + if (existedBefore) throw error; + }); + if (existedBefore) await hydratePromise; + + return { sessionId: managed.session.id, session: managed.session }; + }; + + /** + * POST a user message into a linked Devin cloud session and mirror it + * locally. Devin has no "follow-up run" verb — messages go straight into + * the session's mailbox, so the whole turn is one REST call plus the local + * transcript entry. + */ + const devinCloudSendTurn = async ( + managed: ManagedChatSession, + args: { + promptText: string; + userText?: string; + displayText: string; + attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; + metadata?: AgentChatEventMetadata | null | undefined; + laneDirectiveKey?: string | null; + turnId?: string; + onDispatched?: (() => void) | undefined; + onBackendDispatched?: (() => void) | undefined; + }, + ): Promise => { + const devinSessionId = managed.session.devinSessionId?.trim(); + if (!devinSessionId) throw new Error("This chat is not linked to a Devin cloud session."); + if (!getDevinCloudApiKey()) { + throw new Error("Devin Cloud requires a Devin API token. Add one in Settings > AI Providers or set DEVIN_API_KEY."); + } + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) throw new Error(validation.reason); + + const turnId = args.turnId ?? randomUUID(); + const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText; + const userText = args.userText?.trim().length ? args.userText.trim() : displayText; + setSessionActive(managed); + emitPreparedUserMessage(managed, { + text: userText, + displayText, + attachments: args.attachments, + contextAttachments: args.contextAttachments, + metadata: args.metadata, + turnId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { + type: "status", + turnStatus: "started", + turnId, + }); + try { + await aiIntegrationService.sendDevinCloudMessage({ + devinSessionId, + message: args.promptText, + }); + args.onBackendDispatched?.(); + } catch (error) { + emitChatEvent(managed, { + type: "status", + turnStatus: "failed", + turnId, + }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + runtime: "cloud", + terminalReason: error instanceof Error ? error.message : String(error), + }); + persistChatState(managed); + throw error; + } + // The user_message emitted above already carries this text's fingerprint, + // so the next poll's copy of it dedupes silently. + emitChatEvent(managed, { + type: "status", + turnStatus: "completed", + turnId, + }); + persistChatState(managed); + }; + + const devinCloudFollowUp = async (args: { + devinSessionId: string; + message: string; + }): Promise => { + const trimmedId = normalizeDevinSessionId(args.devinSessionId); + const message = args.message.trim(); + if (!trimmedId) throw new Error("Devin cloud session id is required."); + if (!message) throw new Error("Message is required."); + + const matched = (() => { + for (const [, managed] of managedSessions) { + if (managed.session.devinSessionId === trimmedId) return managed; + } + return null; + })(); + if (!matched) { + throw new Error( + `No active chat session is associated with Devin session '${trimmedId}'. Open the session before sending a follow-up.`, + ); + } + await devinCloudSendTurn(matched, { + promptText: message, + displayText: message, + attachments: [], + contextAttachments: [], + }); + }; + + /** + * Create a fresh Devin cloud session bound to a lane's repo, tagged with + * ADE provenance, and link it into a new or existing chat. + */ + const createDevinCloudSessionForLane = async (args: { + laneId: string; + prompt: string; + sessionId?: string | null; + title?: string | null; + devinMode?: DevinCloudMode | null; + projectId?: string | null; + bypassApproval?: boolean; + }): Promise<{ sessionId: string; session: AgentChatSession; devinSessionId: string }> => { + const trimmedLane = args.laneId.trim(); + const prompt = args.prompt.trim(); + if (!trimmedLane) throw new Error("Lane id is required."); + if (!prompt) throw new Error("Prompt is required."); + + const laneInfo = (() => { + try { + return laneService.getLaneBaseAndBranch(trimmedLane); + } catch { + return null; + } + })(); + if (!laneInfo) throw new Error(`Lane '${trimmedLane}' was not found.`); + + const repoUrl = await detectLaneGitRemoteUrl(laneInfo.worktreePath); + const tags = buildDevinCloudAdeTags({ + laneId: trimmedLane, + projectId: args.projectId, + sessionId: args.sessionId, + }); + const created = await aiIntegrationService.createDevinCloudSession({ + prompt, + ...(repoUrl ? { repoUrls: [repoUrl] } : {}), + tags, + ...(args.title?.trim() ? { title: args.title.trim() } : {}), + ...(args.devinMode ? { devinMode: args.devinMode } : {}), + ...(args.bypassApproval !== undefined ? { bypassApproval: args.bypassApproval } : {}), + }); + const opened = await openDevinCloudChat({ + devinSessionId: created.sessionId, + laneId: trimmedLane, + ...(args.sessionId ? { sessionId: args.sessionId } : {}), + ...(args.devinMode !== undefined ? { devinMode: args.devinMode } : {}), + }); + return { ...opened, devinSessionId: created.sessionId }; + }; + const droidPoolKeyFor = (managed: ManagedChatSession): string => [ "sdk", managed.session.id, @@ -43654,6 +44307,23 @@ export function createAgentChatService(args: { if (reasoningEffort !== undefined) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } + // Devin cloud-linked chats ship the text as a session message instead + // of an ACP turn; the local runtime only answers for local sessions. + if (managed.session.provider === "devin" && managed.session.devinRuntime === "cloud") { + await devinCloudSendTurn(managed, { + promptText, + userText: submittedText, + displayText: visibleText, + attachments, + contextAttachments, + metadata, + laneDirectiveKey, + turnId, + onDispatched, + onBackendDispatched, + }); + return; + } // 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. @@ -47459,6 +48129,18 @@ 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?.devinMode || persisted?.devinMode + ? { devinMode: liveSession?.devinMode ?? persisted?.devinMode } + : {}), + ...(liveSession?.devinPromotedTurnId || persisted?.devinPromotedTurnId + ? { devinPromotedTurnId: liveSession?.devinPromotedTurnId ?? persisted?.devinPromotedTurnId } + : {}), ...(liveSession?.cursorCloudServiceTier !== undefined || persisted?.cursorCloudServiceTier !== undefined ? { cursorCloudServiceTier: liveSession?.cursorCloudServiceTier ?? persisted?.cursorCloudServiceTier } : {}), @@ -48265,6 +48947,7 @@ export function createAgentChatService(args: { transcriptHistoryCacheBySession.delete(sessionId); resolvedTranscriptPathBySession.delete(sessionId); forgetCursorCloudHydrationState(sessionId); + forgetDevinCloudHydrationState(sessionId); }; const countActiveForLane = (laneId: string): number => { @@ -50221,6 +50904,7 @@ export function createAgentChatService(args: { teardownRuntime(managed, "ended_session"); managedSessions.delete(trimmedSessionId); forgetCursorCloudHydrationState(trimmedSessionId); + forgetDevinCloudHydrationState(trimmedSessionId); } else { clearSubagentSnapshots(trimmedSessionId); } @@ -50300,6 +50984,8 @@ export function createAgentChatService(args: { clearInterval(sessionCleanupTimer); clearCursorCloudMirrorWatches(); clearAllCursorCloudHydrationState(); + clearDevinCloudMirrorWatches(); + clearAllDevinCloudHydrationState(); scheduledWorkScheduler?.dispose(); autoResume.forgetAll(); for (const recovery of cancelledQueueRecoveries.values()) clearTimeout(recovery.timer); @@ -53896,6 +54582,10 @@ export function createAgentChatService(args: { handleCursorCloudStatusChange, openCursorCloudChat, watchCursorCloudMirror, + createDevinCloudSessionForLane, + devinCloudFollowUp, + openDevinCloudChat, + watchDevinCloudMirror, subscribeToEvents(callback: (event: AgentChatEventEnvelope) => void) { eventSubscribers.add(callback); return () => { diff --git a/apps/desktop/src/main/services/chat/devinCloudConversation.ts b/apps/desktop/src/main/services/chat/devinCloudConversation.ts new file mode 100644 index 0000000000..8e18f1398e --- /dev/null +++ b/apps/desktop/src/main/services/chat/devinCloudConversation.ts @@ -0,0 +1,53 @@ +/** + * Devin cloud transcript mirroring. + * + * Devin's surface is simpler than Cursor's: `GET .../sessions/{id}/messages` + * returns a flat, chronological list of `{ event_id, source, message }` rows — + * no runs, no attach lease, no per-run conversation payloads. The mirror + * dedupes on the same `user:`/`text:` fingerprints + * `transcriptCloudFingerprints` produces for emitted events, so rehydrating a + * chat after a restart does not double-print history. + */ + +import type { DevinCloudMessage } from "../../../shared/types/config"; + +/** How often a watched cloud chat re-reads the session's remote title. */ +export const DEVIN_CLOUD_REMOTE_NAME_READ_TTL_MS = 60_000; + +/** Empty transcript reads of a terminal session before ADE stops asking. */ +export const DEVIN_CLOUD_EMPTY_TERMINAL_READ_LIMIT = 3; + +/** + * Event-driven name reads a still-unnamed cloud chat may make on top of the + * TTL rule — Devin titles a session shortly after its first output lands. + */ +export const DEVIN_CLOUD_PLACEHOLDER_NAME_READ_LIMIT = 3; + +/** Bounded retries covering "session exists, transcript not materialized yet". */ +export const DEVIN_CLOUD_MESSAGES_RETRY_ATTEMPTS = 4; +export const DEVIN_CLOUD_MESSAGES_RETRY_MS = 2_000; + +/** + * Fingerprint matching `transcriptCloudFingerprints`' vocabulary so hydrated + * events and freshly polled rows dedupe against each other. + */ +export function devinCloudMessageFingerprint( + message: Pick, +): string | null { + const text = message.message.trim(); + if (!text) return null; + return message.source === "user" ? `user:${text}` : `text:${text}`; +} + +/** Terminal Devin statuses — a session that will not produce further output. */ +export function isDevinCloudSessionLive( + status: string | null | undefined, +): boolean { + const lower = status?.toLowerCase() ?? ""; + return ( + lower === "new" + || lower === "claimed" + || lower === "running" + || lower === "resuming" + ); +} diff --git a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts new file mode 100644 index 0000000000..0a2d3e81fd --- /dev/null +++ b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts @@ -0,0 +1,344 @@ +import { runGit } from "../git/git"; +import type { Logger } from "../logging/logger"; +import type { + DevinCloudFleetEntry, + DevinCloudFleetResult, + DevinCloudPullIntoLaneResult, + DevinCloudSessionSummary, +} from "../../../shared/types/config"; +import type { DevinCloudListSessionsArgs } from "../ai/devinCloudClient"; +import type { LaneSummary } from "../../../shared/types/lanes"; +import { repoMatchKey } from "../../../shared/cursorCloudRepoMatch"; +import { + devinCloudAdeLaneId, + devinCloudCreatedViaAde, + devinCloudFleetStatus, +} from "../../../shared/devinCloudFleetStatus"; +import type { createLaneService } from "../lanes/laneService"; + +type SessionLink = { + sessionId: string; + devinSessionId: string; + laneId: string; + title: string | null; +}; + +type FleetServiceDeps = { + projectRoot: string; + logger: Logger; + /** Client-side session listing (v3 org-scoped or v1 personal-key fallback). */ + listDevinCloudSessions: (args: DevinCloudListSessionsArgs) => Promise<{ + items: DevinCloudSessionSummary[]; + endCursor: string | null; + }>; + /** Single-session read for ids beyond the first list page. */ + getDevinCloudSession?: (devinSessionId: string) => Promise; + laneService: Pick, "list" | "importBranch">; + /** ADE chat sessions already linked to a Devin cloud session. */ + listDevinCloudSessionLinks: () => Promise; + openDevinCloudChat: (args: { + devinSessionId: string; + laneId: string; + }) => Promise<{ sessionId: string }>; +}; + +const ORIGIN_CACHE_TTL_MS = 60_000; +const FLEET_CACHE_TTL_MS = 2_000; +const PAGE_SIZE = 100; + +/** `https://github.com/o/r/pull/123` → "123"; anything else → null. */ +function githubPullNumber(prUrl: string | null): string | null { + if (!prUrl) return null; + const match = /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i.exec(prUrl.trim()); + return match ? match[1] : null; +} + +/** + * Guard a remote-reported ref before it reaches git argv or importBranch. + * A leading `-` would be parsed as an option by git (classic option + * injection through argv position), and empty refs are meaningless. + */ +function safeBranchRef(branch: string): string { + const trimmed = branch.trim(); + if (!trimmed || trimmed.startsWith("-")) { + throw new Error(`Devin reported an unusable branch name for this session.`); + } + return trimmed; +} + +export function createDevinCloudFleetService(deps: FleetServiceDeps) { + const { projectRoot, logger } = deps; + + let originCache: { key: string; at: number } | null = null; + let fleetCache: { at: number; result: DevinCloudFleetResult } | null = null; + + const originMatchKey = async (): Promise => { + if (originCache && Date.now() - originCache.at < ORIGIN_CACHE_TTL_MS) { + return originCache.key; + } + try { + const result = await runGit(["remote", "get-url", "origin"], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + const url = result.exitCode === 0 ? result.stdout.trim() : ""; + const key = repoMatchKey(url); + originCache = { key, at: Date.now() }; + return key; + } catch (error) { + logger.warn("devin_cloud_fleet.origin_probe_failed", { + error: error instanceof Error ? error.message : String(error), + }); + originCache = { key: "", at: Date.now() }; + return ""; + } + }; + + /** Local branch name ADE creates for a Devin session's pushed PR head. */ + const devinBranchFor = (sessionId: string): string => + `devin/${sessionId.trim().slice(0, 12).toLowerCase()}`; + + const buildEntries = async (includeArchived: boolean): Promise => { + const [originKey, links, lanes] = await Promise.all([ + originMatchKey(), + deps.listDevinCloudSessionLinks().catch((error) => { + logger.warn("devin_cloud_fleet.session_links_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return [] as SessionLink[]; + }), + deps.laneService.list({ includeArchived: true, includeStatus: false }).catch((error) => { + logger.warn("devin_cloud_fleet.list_lanes_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return [] as LaneSummary[]; + }), + ]); + + const linkByDevinId = new Map(); + for (const link of links) { + if (!link.devinSessionId) continue; + if (!linkByDevinId.has(link.devinSessionId)) linkByDevinId.set(link.devinSessionId, link); + } + const laneById = new Map(); + for (const lane of lanes) { + laneById.set(lane.id, lane); + } + + // Consume every cursor page so a long-lived fleet does not silently drop + // older sessions. v1 lists use the offset string as the cursor. + const listedItems: DevinCloudSessionSummary[] = []; + const seenCursors = new Set(); + let cursor: string | null = null; + do { + const page = await deps.listDevinCloudSessions({ + first: PAGE_SIZE, + ...(includeArchived ? {} : { isArchived: false }), + ...(cursor ? { after: cursor } : {}), + }); + listedItems.push(...page.items); + const next = page.endCursor?.trim() ?? ""; + if (!next || seenCursors.has(next)) break; + seenCursors.add(next); + cursor = next; + } while (true); + + return listedItems.map((session): DevinCloudFleetEntry => { + const link = linkByDevinId.get(session.sessionId) ?? null; + const laneIdFromTag = devinCloudAdeLaneId(session.tags); + const lane = + (link ? laneById.get(link.laneId) : undefined) + ?? (laneIdFromTag ? laneById.get(laneIdFromTag) : undefined) + ?? null; + const repoHit = + Boolean(originKey) + && (session.repos ?? []).some((repo) => repoMatchKey(repo) === originKey); + const createdViaAde = devinCloudCreatedViaAde(session.tags) || Boolean(link); + const matchedBy: DevinCloudFleetEntry["matchedBy"] = link + ? "session" + : createdViaAde || laneIdFromTag + ? "tag" + : repoHit + ? "repo" + : "org"; + return { + session, + fleetStatus: devinCloudFleetStatus(session), + prUrl: session.pullRequests[0]?.prUrl ?? null, + ownership: { + sessionId: link?.sessionId ?? null, + sessionTitle: link?.title ?? null, + laneId: lane?.id ?? null, + laneName: lane?.name ?? null, + linearIssueId: lane?.linearIssue?.identifier ?? null, + }, + createdViaAde, + adeLaneId: laneIdFromTag, + matchedBy, + }; + }); + }; + + const getFleet = async (args?: { force?: boolean; includeArchived?: boolean }): Promise => { + const includeArchived = args?.includeArchived === true; + if (!args?.force && !includeArchived && fleetCache && Date.now() - fleetCache.at < FLEET_CACHE_TTL_MS) { + return fleetCache.result; + } + const items = await buildEntries(includeArchived); + const result: DevinCloudFleetResult = { items, fetchedAt: new Date().toISOString() }; + if (!includeArchived) { + fleetCache = { at: Date.now(), result }; + } + return result; + }; + + const findSessionById = async (id: string): Promise => { + const seen = new Set(); + let cursor: string | null = null; + do { + const page = await deps.listDevinCloudSessions({ + first: PAGE_SIZE, + isArchived: false, + ...(cursor ? { after: cursor } : {}), + }); + const found = page.items.find((entry) => entry.sessionId === id); + if (found) return found; + const next = page.endCursor?.trim() ?? ""; + if (!next || seen.has(next)) break; + seen.add(next); + cursor = next; + } while (true); + if (!deps.getDevinCloudSession) return null; + try { + return await deps.getDevinCloudSession(id); + } catch { + return null; + } + }; + + const resolvePullTargetLane = async (args: { + linkedLaneId: string | null; + branch: string; + }): Promise<{ lane: LaneSummary; created: boolean }> => { + const lanes = await deps.laneService.list({ includeArchived: false, includeStatus: false }); + if (args.linkedLaneId) { + const linked = lanes.find((lane) => lane.id === args.linkedLaneId); + if (linked) return { lane: linked, created: false }; + } + const byBranch = lanes.find((lane) => (lane.branchRef ?? "").trim() === args.branch); + if (byBranch) return { lane: byBranch, created: false }; + const created = await deps.laneService.importBranch({ + branchRef: args.branch, + name: args.branch, + }); + return { lane: created, created: true }; + }; + + const assertCleanWorktree = async (worktreePath: string, laneName: string): Promise => { + const status = await runGit(["status", "--porcelain"], { + cwd: worktreePath, + timeoutMs: 10_000, + }); + if (status.exitCode === 0 && status.stdout.trim()) { + throw new Error( + `Lane '${laneName}' has uncommitted changes. Commit or stash them before pulling a Devin branch into it.`, + ); + } + }; + + /** + * Fetch a Devin session's PR head into a lane. + * + * Devin's API exposes `pull_requests[].pr_url` but never a branch name, so + * the branch arrives as a GitHub `refs/pull//head` fetch. For a new lane + * the fetch writes `refs/heads/devin/` directly; for an existing lane + * it lands on FETCH_HEAD and merges with the dirty-worktree refusal. + */ + const pullIntoLane = async (devinSessionId: string): Promise => { + const id = devinSessionId.trim(); + if (!id) throw new Error("Devin cloud session id is required."); + + const session = await findSessionById(id); + if (!session) throw new Error("Could not find this Devin session in your org."); + if (session.isArchived) throw new Error("Unarchive this session before pulling it into a lane."); + + const prNumber = githubPullNumber(session.pullRequests[0]?.prUrl ?? null); + if (!prNumber) { + throw new Error( + "This session has not opened a GitHub pull request yet, so there is nothing to pull.", + ); + } + + const links = await deps.listDevinCloudSessionLinks().catch(() => [] as SessionLink[]); + const link = links.find((entry) => entry.devinSessionId === id) ?? null; + const laneIdFromTag = devinCloudAdeLaneId(session.tags); + const safeBranch = safeBranchRef(devinBranchFor(id)); + + const { lane, created } = await resolvePullTargetLane({ + linkedLaneId: link?.laneId ?? laneIdFromTag, + branch: safeBranch, + }); + + await assertCleanWorktree(lane.worktreePath, lane.name); + + const fetchResult = await runGit( + ["fetch", "origin", `+refs/pull/${prNumber}/head`], + { cwd: projectRoot, timeoutMs: 60_000 }, + ); + if (fetchResult.exitCode !== 0) { + throw new Error( + `Could not fetch the session's PR head (refs/pull/${prNumber}/head): ${fetchResult.stderr.trim() || "fetch failed"}`, + ); + } + + const mergeResult = await runGit(["merge", "--no-edit", "FETCH_HEAD"], { + cwd: lane.worktreePath, + timeoutMs: 60_000, + }); + if (mergeResult.exitCode !== 0) { + await runGit(["merge", "--abort"], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + }).catch(() => undefined); + throw new Error( + `Merging the session's PR head into '${lane.branchRef}' conflicted; the merge was aborted. Resolve it manually in the lane worktree.`, + ); + } + + let sessionId: string | null = null; + try { + const opened = await deps.openDevinCloudChat({ + devinSessionId: id, + laneId: lane.id, + }); + sessionId = opened.sessionId; + } catch (error) { + logger.warn("devin_cloud_fleet.open_chat_after_pull_failed", { + devinSessionId: id, + laneId: lane.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + return { + status: created ? "created_lane" : "pulled", + laneId: lane.id, + laneName: lane.name, + sessionId, + mergedBranch: safeBranch, + }; + }; + + const invalidateCache = (): void => { + fleetCache = null; + }; + + return { + getFleet, + pullIntoLane, + findSessionById, + invalidateCache, + }; +} + +export type DevinCloudFleetService = ReturnType; diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 8350483fec..72d3a16245 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1679,6 +1679,9 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const apiKeys = asStringMap(value.apiKeys); if (apiKeys && Object.keys(apiKeys).length) out.apiKeys = apiKeys; + const devinCloudOrgId = asString(value.devinCloudOrgId)?.trim(); + if (devinCloudOrgId) out.devinCloudOrgId = devinCloudOrgId; + const localProviders = coerceAiLocalProviders(value.localProviders); if (localProviders) out.localProviders = localProviders; @@ -2050,6 +2053,10 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(sharedAi?.apiKeys ?? {}), ...(localAi?.apiKeys ?? {}) }; + // Explicit-null clears a configured org id; absent means keep. + const devinCloudOrgId = localAi?.devinCloudOrgId !== undefined + ? localAi.devinCloudOrgId + : sharedAi?.devinCloudOrgId; // Replace semantics (not union): the UI writes the full authoritative list, // and this merge also runs on the ai.updateConfig write-patch path — a union // would make removals impossible to persist. Absent = keep, [] = clear. @@ -2085,6 +2092,7 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(Object.keys(featureModelOverrides).length ? { featureModelOverrides } : {}), ...(Object.keys(featureReasoningOverrides).length ? { featureReasoningOverrides } : {}), ...(Object.keys(apiKeys).length ? { apiKeys } : {}), + ...(devinCloudOrgId ? { devinCloudOrgId } : {}), ...(customProviders.length ? { customProviders } : {}), ...(customModelSlugs.length ? { customModelSlugs } : {}), ...(disabledProviders.length ? { disabledProviders } : {}), diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b66440c27e..20da1b84e9 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -713,6 +713,16 @@ import type { CursorCloudFleetResult, CursorCloudFleetEvent, CursorCloudPullIntoLaneResult, + DevinCloudAuthStatus, + DevinCloudCreateSessionForLaneRequest, + DevinCloudCreateSessionForLaneResult, + DevinCloudFleetResult, + DevinCloudFollowUpRequest, + DevinCloudOpenChatRequest, + DevinCloudOpenChatResult, + DevinCloudPullIntoLaneResult, + DevinCloudSetCredentialsRequest, + DevinCloudWatchMirrorRequest, CursorAgentUsage, CursorAgentUsageRequest, AgentToolsCacheSnapshot, @@ -850,6 +860,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 { DevinCloudFleetService } from "../chat/devinCloudFleetService"; import type { createGithubPollingService } from "../automations/githubPollingService"; import { ADE_ACTION_ALLOWLIST, getAdeActionDomainServices, listAllowedAdeActionNames } from "../adeActions/registry"; import { createSessionBoardMoveActions } from "../adeActions/sessionBoardMove"; @@ -1147,6 +1158,7 @@ export type AppContext = { linearIngressService?: LinearIngressService | null; cursorCloudIngressService?: CursorCloudIngressService | null; cursorCloudFleetService?: CursorCloudFleetService | null; + devinCloudFleetService?: DevinCloudFleetService | null; githubPollingService?: ReturnType | null; orchestrationService?: ReturnType | null; projectConfigService: ReturnType | null; @@ -5586,6 +5598,121 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.aiDevinCloudFleet, + async (_event, arg?: { force?: boolean; includeArchived?: boolean }): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["devinCloudFleetService"] as const); + return await ctx.devinCloudFleetService.getFleet(arg); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudPullIntoLane, + async (_event, arg: { devinSessionId: string }): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["devinCloudFleetService"] as const); + return await ctx.devinCloudFleetService.pullIntoLane(arg.devinSessionId); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudOpenChat, + async (_event, arg: DevinCloudOpenChatRequest): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["agentChatService"] as const); + return await ctx.agentChatService.openDevinCloudChat({ + devinSessionId: arg.devinSessionId, + laneId: arg.laneId, + ...(arg.sessionId ? { sessionId: arg.sessionId } : {}), + ...(arg.devinMode !== undefined ? { devinMode: arg.devinMode } : {}), + }); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudWatchMirror, + async (_event, arg: DevinCloudWatchMirrorRequest): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["agentChatService"] as const); + ctx.agentChatService.watchDevinCloudMirror({ + sessionId: arg.sessionId, + watching: arg.watching, + }); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudFollowUp, + async (_event, arg: DevinCloudFollowUpRequest): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["agentChatService"] as const); + await ctx.agentChatService.devinCloudFollowUp(arg); + ctx.devinCloudFleetService?.invalidateCache(); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudCreateSession, + async (_event, arg: DevinCloudCreateSessionForLaneRequest): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["agentChatService"] as const); + const result = await ctx.agentChatService.createDevinCloudSessionForLane(arg); + ctx.devinCloudFleetService?.invalidateCache(); + return result; + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudTerminateSession, + async (_event, arg: { devinSessionId: string; archive?: boolean }): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["aiIntegrationService"] as const); + await ctx.aiIntegrationService.terminateDevinCloudSession(arg); + ctx.devinCloudFleetService?.invalidateCache(); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudArchiveSession, + async (_event, arg: { devinSessionId: string }): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["aiIntegrationService"] as const); + await ctx.aiIntegrationService.archiveDevinCloudSession(arg.devinSessionId); + ctx.devinCloudFleetService?.invalidateCache(); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudUnarchiveSession, + async (_event, arg: { devinSessionId: string }): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["aiIntegrationService"] as const); + await ctx.aiIntegrationService.unarchiveDevinCloudSession(arg.devinSessionId); + ctx.devinCloudFleetService?.invalidateCache(); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudGetAuthStatus, + async (): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["aiIntegrationService"] as const); + return await ctx.aiIntegrationService.getDevinCloudAuthStatus(); + }, + ); + + ipcMain.handle( + IPC.aiDevinCloudSetCredentials, + async (_event, arg: DevinCloudSetCredentialsRequest): Promise => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["aiIntegrationService"] as const); + const status = await ctx.aiIntegrationService.setDevinCloudCredentials(arg); + ctx.devinCloudFleetService?.invalidateCache(); + return status; + }, + ); + ipcMain.handle(IPC.syncGetStatus, async (event, arg?: SyncGetStatusArgs): Promise => { const params = { includeTransferReadiness: arg?.includeTransferReadiness === true, diff --git a/apps/desktop/src/main/utils/terminalTuiMarkers.ts b/apps/desktop/src/main/utils/terminalTuiMarkers.ts index deb3c2689e..7b829b6bc7 100644 --- a/apps/desktop/src/main/utils/terminalTuiMarkers.ts +++ b/apps/desktop/src/main/utils/terminalTuiMarkers.ts @@ -167,6 +167,7 @@ const PACKS: Record = { kimi: ACP_PACK, grok: ACP_PACK, copilot: ACP_PACK, + devin: ACP_PACK, }; export type TuiMarkerState = { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index f5c1dcf60a..a3d39d99c7 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -294,6 +294,15 @@ import type { CursorAgentUsageRequest, CursorCloudStreamRunRequest, CursorCloudStreamRunResult, + DevinCloudAuthStatus, + DevinCloudCreateSessionForLaneRequest, + DevinCloudCreateSessionForLaneResult, + DevinCloudFleetResult, + DevinCloudOpenChatRequest, + DevinCloudOpenChatResult, + DevinCloudPullIntoLaneResult, + DevinCloudSetCredentialsRequest, + DevinCloudWatchMirrorRequest, AdeCliInstallResult, AdeCliStatus, OpenCodeRuntimeSnapshot, @@ -1213,7 +1222,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; }) => Promise; opencodeAuthMethods: () => Promise<{ methods: OpenCodeProviderAuthMethods }>; @@ -1312,6 +1321,36 @@ declare global { onCursorCloudFleetEvent: ( cb: (event: CursorCloudFleetEvent) => void, ) => () => void; + devinCloudGetAuthStatus: () => Promise; + devinCloudSetCredentials: ( + args: DevinCloudSetCredentialsRequest, + ) => Promise; + devinCloudFleet: (args?: { + force?: boolean; + includeArchived?: boolean; + }) => Promise; + devinCloudPullIntoLane: ( + devinSessionId: string, + ) => Promise; + devinCloudTerminateSession: ( + devinSessionId: string, + options?: { archive?: boolean }, + ) => Promise; + devinCloudArchiveSession: (devinSessionId: string) => Promise; + devinCloudUnarchiveSession: (devinSessionId: string) => Promise; + devinCloudFollowUp: (args: { + devinSessionId: string; + message: string; + }) => Promise; + devinCloudOpenChat: ( + args: DevinCloudOpenChatRequest, + ) => Promise; + devinCloudCreateSession: ( + args: DevinCloudCreateSessionForLaneRequest, + ) => Promise; + devinCloudWatchMirror: ( + args: DevinCloudWatchMirrorRequest, + ) => Promise; }; transcription: { transcribe: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e23e62ff2e..53a2a8522b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -203,6 +203,15 @@ import type { CursorAgentUsageRequest, CursorCloudStreamRunRequest, CursorCloudStreamRunResult, + DevinCloudAuthStatus, + DevinCloudCreateSessionForLaneRequest, + DevinCloudCreateSessionForLaneResult, + DevinCloudFleetResult, + DevinCloudOpenChatRequest, + DevinCloudOpenChatResult, + DevinCloudPullIntoLaneResult, + DevinCloudSetCredentialsRequest, + DevinCloudWatchMirrorRequest, OpenCodeRuntimeSnapshot, SyncDesktopConnectionDraft, SyncCloudRelayStatus, @@ -4648,7 +4657,7 @@ const adeBridge = { ), ), acpProviderDiagnostics: async (args: { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; runDoctor?: boolean; }): Promise => // Deliberately not routed through a project runtime action: this reports @@ -4951,6 +4960,85 @@ const adeBridge = { ipcRenderer.removeListener(IPC.aiCursorCloudFleetEvent, listener); }; }, + devinCloudGetAuthStatus: async (): Promise => + callProjectRuntimeActionOr("ai", "getDevinCloudAuthStatus", {}, () => + ipcRenderer.invoke(IPC.aiDevinCloudGetAuthStatus), + ), + devinCloudSetCredentials: async ( + args: DevinCloudSetCredentialsRequest, + ): Promise => + callProjectRuntimeActionOr("ai", "setDevinCloudCredentials", { args }, () => + ipcRenderer.invoke(IPC.aiDevinCloudSetCredentials, args), + ), + devinCloudFleet: async ( + args?: { force?: boolean; includeArchived?: boolean }, + ): Promise => + callProjectRuntimeActionOr("ai", "getDevinCloudFleet", { args: args ?? {} }, () => + ipcRenderer.invoke(IPC.aiDevinCloudFleet, args ?? {}), + ), + devinCloudPullIntoLane: async ( + devinSessionId: string, + ): Promise => { + const result = await callProjectRuntimeActionOr( + "ai", + "pullDevinCloudSessionIntoLane", + { args: { devinSessionId } }, + () => ipcRenderer.invoke(IPC.aiDevinCloudPullIntoLane, { devinSessionId }), + ); + // Pull can create a lane (importBranch) and moves refs; lane caches must + // not serve pre-pull answers. + clearGitReadCaches(); + return result; + }, + devinCloudTerminateSession: async ( + devinSessionId: string, + options?: { archive?: boolean }, + ): Promise => + callProjectRuntimeActionOr( + "ai", + "terminateDevinCloudSession", + { args: { devinSessionId, ...(options?.archive !== undefined ? { archive: options.archive } : {}) } }, + () => ipcRenderer.invoke(IPC.aiDevinCloudTerminateSession, { devinSessionId, ...(options?.archive !== undefined ? { archive: options.archive } : {}) }), + ), + devinCloudArchiveSession: async (devinSessionId: string): Promise => + callProjectRuntimeActionOr( + "ai", + "archiveDevinCloudSession", + { args: { devinSessionId } }, + () => ipcRenderer.invoke(IPC.aiDevinCloudArchiveSession, { devinSessionId }), + ), + devinCloudUnarchiveSession: async (devinSessionId: string): Promise => + callProjectRuntimeActionOr( + "ai", + "unarchiveDevinCloudSession", + { args: { devinSessionId } }, + () => ipcRenderer.invoke(IPC.aiDevinCloudUnarchiveSession, { devinSessionId }), + ), + devinCloudFollowUp: async (args: { + devinSessionId: string; + message: string; + }): Promise => + callProjectRuntimeActionOr("ai", "devinCloudFollowUp", { args }, () => + ipcRenderer.invoke(IPC.aiDevinCloudFollowUp, args), + ), + devinCloudOpenChat: async ( + args: DevinCloudOpenChatRequest, + ): Promise => + callProjectRuntimeActionOr("ai", "openDevinCloudChat", { args }, () => + ipcRenderer.invoke(IPC.aiDevinCloudOpenChat, args), + ), + devinCloudCreateSession: async ( + args: DevinCloudCreateSessionForLaneRequest, + ): Promise => + callProjectRuntimeActionOr("ai", "createDevinCloudSession", { args }, () => + ipcRenderer.invoke(IPC.aiDevinCloudCreateSession, args), + ), + devinCloudWatchMirror: async ( + args: DevinCloudWatchMirrorRequest, + ): Promise => + callProjectRuntimeActionOr("ai", "watchDevinCloudMirror", { args }, () => + ipcRenderer.invoke(IPC.aiDevinCloudWatchMirror, args), + ), }, transcription: { // Hand the captured 16 kHz mono PCM to the main process as a transferable 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..f9d5758c37 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/devin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 0647199468..a7349aa402 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3871,6 +3871,111 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { onCursorAuthStatus: () => () => {}, cursorCloudOpenChat: resolvedArg({ sessionId: "", session: null } as any), cursorCloudWatchMirror: resolvedArg(undefined), + devinCloudGetAuthStatus: resolved({ + configured: true, + authMode: "v3", + orgId: "org-demo", + orgName: "Demo Org", + error: null, + } as any), + devinCloudSetCredentials: resolvedArg(undefined), + devinCloudFleet: resolved({ + fetchedAt: new Date().toISOString(), + items: [ + { + session: { + sessionId: "demo-session-running", + title: "Fix auth redirect loop", + status: "running", + statusDetail: "working", + isArchived: false, + url: "https://app.devin.ai/sessions/demo-session-running", + pullRequests: [], + tags: [], + repos: ["ade-demo/ade"], + createdAt: Date.now() - 42 * 60_000, + updatedAt: Date.now() - 3 * 60_000, + devinMode: "normal", + acusConsumed: 1.4, + userId: "user-demo", + parentSessionId: null, + origin: "mine", + }, + fleetStatus: "working", + prUrl: null, + ownership: { sessionId: null, sessionTitle: null, laneId: null, laneName: null, linearIssueId: null }, + createdViaAde: false, + adeLaneId: null, + matchedBy: "repo", + }, + { + session: { + sessionId: "demo-session-blocked", + title: "Migrate to pnpm workspaces", + status: "running", + statusDetail: "waiting_for_user", + isArchived: false, + url: "https://app.devin.ai/sessions/demo-session-blocked", + pullRequests: [], + tags: ["ade", "ade:lane:demo-lane"], + repos: ["ade-demo/ade"], + createdAt: Date.now() - 2 * 3_600_000, + updatedAt: Date.now() - 25 * 60_000, + devinMode: "ultra", + acusConsumed: 3.1, + userId: "user-demo", + parentSessionId: null, + origin: "mine", + }, + fleetStatus: "needs_you", + prUrl: null, + ownership: { sessionId: null, sessionTitle: null, laneId: "demo-lane", laneName: "demo-lane", linearIssueId: null }, + createdViaAde: true, + adeLaneId: "demo-lane", + matchedBy: "tag", + }, + { + session: { + sessionId: "demo-session-done", + title: "Add retry to sync backoff", + status: "finished", + statusDetail: "finished", + isArchived: false, + url: "https://app.devin.ai/sessions/demo-session-done", + pullRequests: [{ prUrl: "https://github.com/ade-demo/ade/pull/882", prState: "open" }], + tags: ["ade"], + repos: ["ade-demo/ade"], + createdAt: Date.now() - 26 * 3_600_000, + updatedAt: Date.now() - 20 * 3_600_000, + devinMode: "normal", + acusConsumed: 0.8, + userId: "user-demo", + parentSessionId: null, + origin: "mine", + }, + fleetStatus: "finished", + prUrl: "https://github.com/ade-demo/ade/pull/882", + ownership: { sessionId: null, sessionTitle: null, laneId: null, laneName: null, linearIssueId: null }, + createdViaAde: true, + adeLaneId: null, + matchedBy: "tag", + }, + ], + } as any), + devinCloudPullIntoLane: resolvedArg({ + status: "pulled", + laneId: "demo-lane", + laneName: "demo-lane", + sessionId: null, + mergedBranch: "devin/demo-branch", + } as any), + devinCloudTerminateSession: resolvedArg(undefined), + devinCloudArchiveSession: resolvedArg(undefined), + devinCloudUnarchiveSession: resolvedArg(undefined), + devinCloudFollowUp: resolvedArg(undefined), + devinCloudOpenChat: resolvedArg({ sessionId: "", session: null } as any), + devinCloudCreateSession: resolvedArg({ sessionId: "", session: null, devinSessionId: "demo-new" } as any), + devinCloudWatchMirror: resolvedArg(undefined), }, agentTools: { detect: resolved([]), @@ -5057,6 +5162,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/DevinCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx new file mode 100644 index 0000000000..defd9fc41f --- /dev/null +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx @@ -0,0 +1,682 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowSquareOut, + ArrowsClockwise, + CircleNotch, + Warning, + X, +} from "@phosphor-icons/react"; + +import type { + DevinCloudFleetEntry, + DevinCloudFleetResult, +} from "../../../shared/types"; +import devinMark from "../../assets/provider-logos/devin.svg"; +import { openExternalUrl } from "../../lib/openExternal"; +import { + DEVIN_BLUE, + devinCloudErrorMessage, + devinCloudRepoLabel, + formatDevinCloudAge, + repoMatchKey, +} from "../../lib/devinCloudUtils"; +import { announceWorkChatSessionCreated } from "../../lib/chatSessionEvents"; +import { settingsRouteFor } from "../settings/settingsManifest"; +import { useAppStore } from "../../state/appStore"; +import { cn } from "../ui/cn"; +import { FleetRow, SectionHeader, isDevinCloudFleetEntryActive } from "./DevinCloudFleetRow"; + +type FleetFilter = "all" | "active" | "needs_you" | "finished" | "failed"; +/** Locked provenance chips: org-wide rows, the caller's own, or ADE-launched. */ +type ProvenanceFilter = "all" | "mine" | "ade"; + +function filterMatches(entry: DevinCloudFleetEntry, filter: FleetFilter): boolean { + switch (filter) { + case "active": + return isDevinCloudFleetEntryActive(entry); + case "needs_you": + return entry.fleetStatus === "needs_you"; + case "finished": + return entry.fleetStatus === "finished"; + case "failed": + return entry.fleetStatus === "error"; + default: + return true; + } +} + +function provenanceMatches(entry: DevinCloudFleetEntry, provenance: ProvenanceFilter): boolean { + switch (provenance) { + case "ade": + return entry.createdViaAde; + case "mine": + return entry.createdViaAde || entry.session.origin === "mine"; + default: + return true; + } +} + +export function DevinCloudFleetModal({ + 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 [provenance, setProvenance] = useState("mine"); + const [laneFilter, setLaneFilter] = useState("all"); + const [showArchived, setShowArchived] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [busySessionId, setBusySessionId] = useState(null); + const [rowError, setRowError] = useState<{ sessionId: string; message: string } | null>(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [pulledNotice, setPulledNotice] = useState(null); + const [pulledTarget, setPulledTarget] = useState<{ laneId: string; laneName: string; title: string } | null>(null); + const [continueBusy, setContinueBusy] = useState(false); + const requestGeneration = useRef(0); + + const refreshLanes = useAppStore((s) => s.refreshLanes); + const lanes = useAppStore((s) => s.lanes); + + const refresh = useCallback(async (soft: boolean) => { + const generation = ++requestGeneration.current; + if (soft) setRefreshing(true); + else setLoading(true); + setError(null); + try { + // Devin lists every org session; archived rows are only returned when + // explicitly requested, matching the "Show archived" reveal below. + const next = await window.ade.ai.devinCloudFleet({ includeArchived: true, force: !soft }); + if (generation !== requestGeneration.current) return; + setResult(next); + setKeyMissing(false); + } catch (err) { + if (generation !== requestGeneration.current) return; + const message = devinCloudErrorMessage(err); + setKeyMissing(/api (key|token)|token|credential|configure/i.test(message)); + setError(message); + } finally { + if (generation === requestGeneration.current) { + setLoading(false); + setRefreshing(false); + } + } + }, []); + + useEffect(() => { + void refresh(false); + }, [refresh]); + + // Devin has no webhook feed — presence-gated polling while the modal is open + // keeps running rows fresh without a background poller for a closed surface. + useEffect(() => { + const tick = () => { + if (document.visibilityState !== "visible") return; + void refresh(true); + }; + const interval = window.setInterval(tick, 15_000); + const onVisible = () => tick(); + document.addEventListener("visibilitychange", onVisible); + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [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.session.isArchived) return false; + if (!provenanceMatches(entry, provenance)) 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, provenance, showArchived]); + + const grouped = useMemo(() => { + const active: DevinCloudFleetEntry[] = []; + const byLane = new Map(); + const unlinked = new Map(); + for (const entry of visibleEntries) { + if (isDevinCloudFleetEntryActive(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 = (entry.session.repos[0] ? repoMatchKey(entry.session.repos[0]) : null) ?? "unknown"; + const list = unlinked.get(key) ?? []; + list.push(entry); + unlinked.set(key, list); + } + } + const recency = (entry: DevinCloudFleetEntry): number => + entry.session.updatedAt ?? entry.session.createdAt ?? 0; + // needs_you floats above every other active status — it is the loud tier. + const needsYouWeight = (entry: DevinCloudFleetEntry): number => + entry.fleetStatus === "needs_you" ? 1 : 0; + active.sort((a, b) => needsYouWeight(b) - needsYouWeight(a) || 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]?.session.repos[0] + ? devinCloudRepoLabel(list[0].session.repos[0]) + : "No repo bound", + 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 totalAcus = useMemo(() => { + let sum = 0; + let any = false; + for (const entry of visibleEntries) { + const acus = entry.session.acusConsumed; + if (typeof acus === "number" && Number.isFinite(acus)) { + sum += acus; + any = true; + } + } + return any ? sum : null; + }, [visibleEntries]); + + const expandEntry = useCallback(async (devinSessionId: string) => { + setExpandedId((current) => (current === devinSessionId ? null : devinSessionId)); + setConfirmDeleteId(null); + }, []); + + const openInAde = useCallback(async (entry: DevinCloudFleetEntry) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + // Linked lanes win; the ade:lane tag carries the second-best link; an + // org row with neither opens against the project's primary lane — the + // mirror needs a lane for context, and primary is the repo itself. + const laneId = entry.ownership.laneId + ?? entry.adeLaneId + ?? lanes.find((lane) => lane.laneType === "primary")?.id + ?? null; + if (!laneId) throw new Error("No lane available to host this chat."); + const opened = await window.ade.ai.devinCloudOpenChat({ + devinSessionId, + laneId, + }); + if (opened.session) { + announceWorkChatSessionCreated(projectRoot ?? "", opened.session); + } + onClose(); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, [lanes, onClose, projectRoot]); + + const terminateSession = useCallback(async (entry: DevinCloudFleetEntry) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + await window.ade.ai.devinCloudTerminateSession(devinSessionId); + await refresh(true); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, [refresh]); + + const pullIntoLane = useCallback(async (entry: DevinCloudFleetEntry) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + const pulled = await window.ade.ai.devinCloudPullIntoLane(devinSessionId); + setPulledNotice( + pulled.status === "created_lane" + ? `Created lane '${pulled.laneName}' and merged ${pulled.mergedBranch}.` + : `Merged ${pulled.mergedBranch} into '${pulled.laneName}'.`, + ); + setPulledTarget({ + laneId: pulled.laneId, + laneName: pulled.laneName, + title: entry.session.title ?? devinSessionId, + }); + void refreshLanes(); + await refresh(true); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, [refresh, refreshLanes]); + + /** + * Continue in lane — the cloud→local reverse of hand-off: opens the local + * `devin` CLI in the lane that just received the session's branch, seeded + * with the session context as its kickoff prompt. + */ + const continueInLane = useCallback(async () => { + if (!pulledTarget || continueBusy) return; + setContinueBusy(true); + try { + await window.ade.agentChat.launchCli({ + laneId: pulledTarget.laneId, + provider: "devin", + kickoffPrompt: + `This lane carries the work pushed by Devin session "${pulledTarget.title}" — ` + + `the branch is already merged here. Review it and continue where the cloud session left off.`, + title: `Devin · ${pulledTarget.title}`, + disposition: "foreground", + }); + setPulledNotice(null); + setPulledTarget(null); + onClose(); + } catch (err) { + setError(devinCloudErrorMessage(err)); + } finally { + setContinueBusy(false); + } + }, [continueBusy, onClose, pulledTarget]); + + const toggleArchive = useCallback(async (entry: DevinCloudFleetEntry) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + if (entry.session.isArchived) await window.ade.ai.devinCloudUnarchiveSession(devinSessionId); + else await window.ade.ai.devinCloudArchiveSession(devinSessionId); + await refresh(true); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, [refresh]); + + const deleteSession = useCallback(async (entry: DevinCloudFleetEntry) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + // Devin's delete is terminate-and-archive: the session stops and leaves + // the fleet list, keeping its transcript reachable in app.devin.ai. + await window.ade.ai.devinCloudTerminateSession(devinSessionId, { archive: true }); + setConfirmDeleteId(null); + setResult((current) => current + ? { ...current, items: current.items.filter((item) => item.session.sessionId !== devinSessionId) } + : current); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, []); + + const archivedCount = useMemo( + () => entries.filter((entry) => entry.session.isArchived).length, + [entries], + ); + const noVisibleSessionsBecauseArchived = entries.length > 0 && visibleEntries.length === 0 && archivedCount > 0; + + const renderRow = (entry: DevinCloudFleetEntry) => ( + void expandEntry(entry.session.sessionId)} + onOpen={() => void openInAde(entry)} + onStop={() => void terminateSession(entry)} + onPull={() => void pullIntoLane(entry)} + onArchive={() => void toggleArchive(entry)} + onRequestDelete={() => setConfirmDeleteId(entry.session.sessionId)} + onConfirmDelete={() => void deleteSession(entry)} + /> + ); + + return createPortal( + <> + + ))} + + + {laneOptions.length > 0 ? ( + + ) : null} + + + + + + {result && !loading ? ( +
+ Devin has no live event feed — this list refreshes itself while open, and on demand. +
+ ) : null} + + {/* Body */} +
+ {loading ? ( +
+ + Loading Devin sessions… +
+ ) : error ? ( +
+ +
+ {keyMissing + ? "Connect Devin first — add an API token in Settings → AI providers." + : `Could not load your Devin sessions: ${error}`} +
+ {keyMissing ? ( + + ) : ( + + )} +
+ ) : entries.length === 0 ? ( +
+ + + +
No Devin sessions
+
+ Sessions you launch from a chat composer with Devin Cloud — and anything + started at app.devin.ai — will show up here. +
+
+ ) : noVisibleSessionsBecauseArchived ? ( +
+
All matching sessions are archived
+
+ Reveal archived sessions to inspect or unarchive them. +
+ +
+ ) : visibleEntries.length === 0 ? ( +
+
No sessions match these filters
+
+ Try the All chip — org sessions started by other people do not count as Mine. +
+
+ ) : ( +
+ {grouped.active.length > 0 ? ( +
+ +
+ {grouped.active.map(renderRow)} +
+
+ ) : null} + + {grouped.lanes.map((group) => ( +
+ +
+ {group.entries.map(renderRow)} +
+
+ ))} + + {grouped.unlinkedGroups.length > 0 ? ( +
+ n + g.entries.length, 0)} + /> +
+ {grouped.unlinkedGroups.map((group) => ( +
+
+ {group.label} +
+
+ {group.entries.map(renderRow)} +
+
+ ))} +
+
+ ) : null} + + {archivedCount > 0 && !showArchived ? ( +
+ +
+ ) : null} +
+ )} +
+ + {/* Footer */} +
+
+ + {visibleEntries.length} session{visibleEntries.length === 1 ? "" : "s"} + {totalAcus != null ? ` · ${Math.round(totalAcus * 10) / 10} ACU shown` : ""} + + {result ? · updated {formatDevinCloudAge(result.fetchedAt) ?? "just now"} : null} +
+ {showArchived ? ( + + ) : ( + { + event.preventDefault(); + openExternalUrl("https://app.devin.ai"); + }} + className="inline-flex items-center gap-1 transition-colors hover:text-fg/70" + > + All sessions on app.devin.ai + + + )} +
+ + {/* Pulled notice toast */} + {pulledNotice ? ( +
+ {pulledNotice} + {pulledTarget ? ( + + ) : null} + +
+ ) : null} + + , + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx new file mode 100644 index 0000000000..e6fc6e783f --- /dev/null +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx @@ -0,0 +1,483 @@ +import { useEffect, useRef, useState } from "react"; +import { + ArrowSquareOut, + CaretDown, + Desktop, + GitPullRequest, + Stop, + Trash, +} from "@phosphor-icons/react"; + +import type { DevinCloudFleetEntry, DevinCloudFleetStatus } from "../../../shared/types"; +import { navigateUrlInAdeBrowser, openExternalUrl } from "../../lib/openExternal"; +import { + DEVIN_BLUE, + devinCloudModeLabel, + devinCloudRepoLabel, + devinCloudStatusToneClass, + formatDevinCloudAge, +} from "../../lib/devinCloudUtils"; +import { cn } from "../ui/cn"; + +export function devinCloudFleetDisplayStatus(entry: DevinCloudFleetEntry): DevinCloudFleetStatus { + return entry.fleetStatus; +} + +export function isDevinCloudFleetEntryActive(entry: DevinCloudFleetEntry): boolean { + const status = entry.fleetStatus; + return status === "starting" || status === "working" || status === "needs_you"; +} + +function statusPillLabel(status: DevinCloudFleetStatus): string { + if (status === "needs_you") return "needs you"; + return status; +} + +export function StatusPill({ status }: { status: DevinCloudFleetStatus }) { + return ( + + {statusPillLabel(status)} + + ); +} + +function formatAcus(acusConsumed: number | null | undefined): string | null { + if (acusConsumed == null || Number.isNaN(acusConsumed)) return null; + return `${Math.round(acusConsumed * 10) / 10} ACU`; +} + +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: DevinCloudFleetEntry }) { + 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, + rowError, + onToggle, + onOpen, + onStop, + onPull, + onArchive, + onRequestDelete, + onConfirmDelete, +}: { + entry: DevinCloudFleetEntry; + expanded: boolean; + busy: boolean; + confirmingDelete: boolean; + rowError: string | null; + onToggle: () => void; + onOpen: () => void; + onStop: () => void; + onPull: () => void; + onArchive: () => void; + onRequestDelete: () => void; + onConfirmDelete: () => void; +}) { + const [liveUrlCopied, setLiveUrlCopied] = useState(false); + const { session } = entry; + const status = entry.fleetStatus; + const active = isDevinCloudFleetEntryActive(entry); + const age = formatDevinCloudAge(session.updatedAt ?? session.createdAt); + const acus = formatAcus(session.acusConsumed); + const finished = status === "finished"; + const repoLabel = session.repos[0] ? devinCloudRepoLabel(session.repos[0]) : null; + const sessionUrl = session.url?.trim() || null; + const needsYou = status === "needs_you"; + + return ( +
+ {/* Div, not button: the row hosts real interactive children (Terminate, 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 ? ( + <> + + + + ) : ( + + )} + + + + + {session.title || session.sessionId.slice(0, 12)} + + + {age ? {age} : null} + {acus ? ( + {acus} + ) : null} + {entry.createdViaAde ? ( + + via ADE + + ) : null} + + + {repoLabel ? {repoLabel} : null} + {session.devinMode ? ( + {devinCloudModeLabel(session.devinMode)} + ) : 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-sky-200/70 hover:text-sky-100" + title="Open pull request" + > + PR + + ) : null} + + + event.stopPropagation()} + > + {!session.isArchived ? ( + + ) : null} + {sessionUrl ? ( + + ) : null} + {active ? ( + + ) : null} + + +
+ + {rowError ? ( +
+ {rowError} +
+ ) : null} + + {expanded ? ( +
+ {session.statusDetail && session.statusDetail !== status ? ( +
{session.statusDetail}
+ ) : null} +
+ session {session.sessionId.slice(0, 14)}… + {sessionUrl ? ( + + ) : null} +
+ {sessionUrl ? ( +
+ + +
+ ) : null} +
+ {session.devinMode ? mode {session.devinMode} : null} + {acus ? usage {acus} : null} + matched by {entry.matchedBy} +
+ {session.tags.length > 0 ? ( +
+ {session.tags.slice(0, 8).map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ) : null} +
+ ); +} + +function RowMenu({ + entry, + busy, + confirmingDelete, + finished, + onPull, + onArchive, + onRequestDelete, + onConfirmDelete, + onConfirmDismiss, +}: { + entry: DevinCloudFleetEntry; + busy: boolean; + confirmingDelete: boolean; + finished: boolean; + onPull: () => void; + onArchive: () => void; + onRequestDelete: () => void; + onConfirmDelete: () => void; + onConfirmDismiss: () => void; +}) { + const [open, setOpen] = useState(false); + const [flipUp, setFlipUp] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) { + setFlipUp(false); + return; + } + const onDocClick = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + // Dismissing the menu without acting must also stand down an armed + // delete confirmation. + if (confirmingDelete) onConfirmDismiss(); + } + }; + // Flip the menu above the trigger when it would overflow the viewport + // bottom; both anchor and menu live in the same offset-parent space. + const flip = () => { + const menu = menuRef.current?.querySelector("[data-row-menu-list]") as HTMLElement | null; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + setFlipUp(window.innerHeight - rect.bottom < 8); + }; + document.addEventListener("mousedown", onDocClick); + requestAnimationFrame(flip); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open, 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"; + + const sessionUrl = entry.session.url?.trim() || null; + + return ( +
event.stopPropagation()}> + + {open ? ( +
+ {sessionUrl ? ( + + ) : null} + {finished && !entry.session.isArchived && entry.prUrl ? ( + + ) : null} + + {entry.prUrl ? ( + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx new file mode 100644 index 0000000000..39668d3158 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Diamond } from "@phosphor-icons/react"; + +import type { DevinCloudAuthStatus } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; +import { + ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, + ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, +} from "../../lib/workSidebarBrowserResize"; +import { DEVIN_BLUE } from "../../lib/devinCloudUtils"; +import { DevinCloudFleetModal } from "./DevinCloudFleetModal"; + +// Keep the entry point on the same visibility cadence as Linear and Cursor. +// Both integrations are connection-gated and should appear/disappear together +// while a provider key is being verified or a remote runtime reconnects. +const INITIAL_VISIBILITY_CHECK_DELAY_MS = 2_000; +const VISIBILITY_RETRY_INTERVAL_MS = 3_000; +const REMOTE_VISIBILITY_RETRY_INTERVAL_MS = 15_000; +const VISIBILITY_CONNECTED_CACHE_TTL_MS = 60_000; +const VISIBILITY_DISCONNECTED_CACHE_TTL_MS = 1_500; +const HEADER_STATUS_MENU_ROW_CLASS = + "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] font-medium text-muted-fg/80 transition-colors duration-150 hover:bg-white/[0.06] hover:text-fg/90"; + +type VisibilityCacheEntry = { + reader: unknown; + value: boolean; + checkedAtMs: number; + inFlight: Promise | null; +}; + +const visibilityCacheByProject = new Map(); + +/** + * The fleet entry point only exists while a Devin API token does. Reads the + * credential state through the cached reader so opening Work never pays an + * extra auth probe in its startup window. + */ +function readDevinVisibilityCached( + args: { + cacheKey: string | null | undefined; + reader: (() => Promise) | undefined; + force?: boolean; + }, +): Promise { + const { cacheKey, reader, force = false } = args; + if (!cacheKey || !reader) return Promise.resolve(false); + const now = Date.now(); + const existing = visibilityCacheByProject.get(cacheKey); + const entry = existing && existing.reader === reader + ? existing + : { reader, value: false, checkedAtMs: 0, inFlight: null }; + visibilityCacheByProject.set(cacheKey, entry); + if (entry.inFlight) return entry.inFlight; + const ttl = entry.value ? VISIBILITY_CONNECTED_CACHE_TTL_MS : VISIBILITY_DISCONNECTED_CACHE_TTL_MS; + if (!force && now - entry.checkedAtMs < ttl) return Promise.resolve(entry.value); + + entry.inFlight = Promise.resolve() + .then(() => reader()) + .then((status) => { + const nextValue = status.configured === true; + entry.value = nextValue; + entry.checkedAtMs = Date.now(); + return nextValue; + }) + .catch(() => { + entry.value = false; + entry.checkedAtMs = Date.now(); + return false; + }) + .finally(() => { + entry.inFlight = null; + }); + return entry.inFlight; +} + +export function DevinCloudQuickViewButton({ + variant = "icon", + onMenuActivate, +}: { + variant?: "icon" | "menu-row" | "sidebar-row"; + onMenuActivate?: () => void; +} = {}) { + const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); + const activeProjectRoot = + projectBinding?.kind === "remote" ? projectBinding.rootPath : project?.rootPath; + // Remote hosts can expose the same project root path. The binding key is the + // host identity, so a disconnected result from one machine must never hide a + // connected Devin Cloud entry on another machine. + const activeProjectVisibilityKey = projectBinding?.key ?? activeProjectRoot; + const projectName = project?.displayName ?? null; + + const [visible, setVisible] = useState(false); + const [open, setOpen] = useState(false); + const openRef = useRef(open); + openRef.current = open; + + const readDevinAuthStatus = useCallback( + () => window.ade.ai.devinCloudGetAuthStatus(), + [], + ); + + const loadVisibility = useCallback( + (force = false) => readDevinVisibilityCached({ + cacheKey: activeProjectVisibilityKey, + reader: typeof window !== "undefined" && typeof window.ade?.ai?.devinCloudGetAuthStatus === "function" + ? readDevinAuthStatus + : undefined, + force, + }), + [activeProjectVisibilityKey, readDevinAuthStatus], + ); + + const shouldAutoCheckVisibility = Boolean(activeProjectRoot); + const visibilityRetryIntervalMs = projectBinding?.kind === "remote" + ? REMOTE_VISIBILITY_RETRY_INTERVAL_MS + : VISIBILITY_RETRY_INTERVAL_MS; + + // Delayed, cached, bridge-triggered — never in the Work startup IPC window. + useEffect(() => { + setVisible(false); + setOpen(false); + if (!shouldAutoCheckVisibility) return undefined; + let cancelled = false; + const timer = window.setTimeout(() => { + void loadVisibility().then((next) => { + if (!cancelled) setVisible(next); + }); + }, INITIAL_VISIBILITY_CHECK_DELAY_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [activeProjectVisibilityKey, activeProjectRoot, loadVisibility, shouldAutoCheckVisibility]); + + useEffect(() => { + if (!shouldAutoCheckVisibility || visible) return undefined; + let cancelled = false; + let timer: number | null = null; + // Queue the same delayed re-check on bridge-ready rather than firing an + // immediate auth probe — this must never land in the Work startup IPC + // window. The timer is cancelled with the effect so a project switch + // cannot let a stale probe resolve. + const queue = () => { + if (timer != null) return; + timer = window.setTimeout(() => { + timer = null; + if (cancelled) return; + void loadVisibility(true).then((next) => { + if (!cancelled) setVisible(next); + }); + }, INITIAL_VISIBILITY_CHECK_DELAY_MS); + }; + if ((window as { __adeRuntimeBridge?: unknown }).__adeRuntimeBridge) queue(); + window.addEventListener("ade:runtime-bridge-ready", queue); + return () => { + cancelled = true; + if (timer != null) window.clearTimeout(timer); + window.removeEventListener("ade:runtime-bridge-ready", queue); + }; + }, [activeProjectVisibilityKey, activeProjectRoot, visible, loadVisibility, shouldAutoCheckVisibility]); + + useEffect(() => { + if (!shouldAutoCheckVisibility || !activeProjectRoot) return undefined; + let cancelled = false; + const refresh = () => { + void loadVisibility(true).then((next) => { + if (!cancelled) setVisible(next); + }); + }; + window.addEventListener("focus", refresh); + return () => { + cancelled = true; + window.removeEventListener("focus", refresh); + }; + }, [activeProjectVisibilityKey, activeProjectRoot, loadVisibility, shouldAutoCheckVisibility]); + + useEffect(() => { + if (!shouldAutoCheckVisibility || visible || !activeProjectRoot) return undefined; + let cancelled = false; + const interval = window.setInterval(() => { + void loadVisibility().then((next) => { + if (!cancelled && next) { + setVisible(true); + window.clearInterval(interval); + } + }); + }, visibilityRetryIntervalMs); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [activeProjectVisibilityKey, activeProjectRoot, loadVisibility, shouldAutoCheckVisibility, visibilityRetryIntervalMs, visible]); + + const occludesNativeBrowser = open; + + useEffect(() => { + if (!occludesNativeBrowser || typeof window === "undefined") return undefined; + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT)); + return () => { + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_END_EVENT)); + }; + }, [occludesNativeBrowser]); + + if (!visible) return null; + + const handleToggle = () => { + setOpen((current) => !current); + onMenuActivate?.(); + }; + + const iconSize = variant === "menu-row" ? 12 : variant === "sidebar-row" ? 15 : 12; + const icon = ( + + ); + + return ( + <> + + {open ? createPortal( + setOpen(false)} + />, + document.body, + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 1671d2daed..3bd31a4f74 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -34,6 +34,7 @@ import type { GitHubStatus } from "../../../shared/types"; import { readStoredPrsRoute } from "../prs/prsRouteState"; import { readStoredProjectSettingsRoute } from "./projectRouteStorage"; import { CursorCloudQuickViewButton } from "./CursorCloudQuickViewButton"; +import { DevinCloudQuickViewButton } from "./DevinCloudQuickViewButton"; type TabNavItem = { to: string; @@ -370,9 +371,10 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) ) : null} - {/* The fleet entry owns the same delayed, cached auth gate as the - top-bar control, so a disconnected Cursor integration leaves no - dead sidebar affordance. */} + {/* The fleet entries own the same delayed, cached auth gate as the + top-bar controls, so a disconnected integration leaves no dead + sidebar affordance. */} + {/* Spacer pushes settings to bottom */} diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 7a13cd60c3..50d45bd944 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -73,6 +73,7 @@ import { useDialogFocusTrap } from "./HeaderSheet"; import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { CursorCloudQuickViewButton } from "./CursorCloudQuickViewButton"; +import { DevinCloudQuickViewButton } from "./DevinCloudQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { ConnectionsPanel } from "./ConnectionsPanel"; import { @@ -2259,6 +2260,7 @@ export function TopBar({ if (menuLayout) { return (
+ + {connectionsChip} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 97ff219e0e..4ede8936fc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, Diamond, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -1791,9 +1791,15 @@ export function AgentChatComposer({ cursorCloudHasEligibleModels = true, cursorCloudModeActive = false, onSubmitToCloud, + cloudTargetLabel = "Cursor Cloud", cursorCloudPanelAvailable = false, cursorCloudPaneOpen = false, onToggleCursorCloudPanel, + devinCloudPanelAvailable = false, + devinCloudPaneOpen = false, + onToggleDevinCloudPanel, + devinCloudHandoffAvailable = false, + onHandoffToDevinCloud, showAppControlToggle = false, appControlOpen = false, onToggleAppControl, @@ -2039,16 +2045,27 @@ export function AgentChatComposer({ */ cursorCloudHasEligibleModels?: boolean; /** - * Cloud mode: the next send goes to Cursor Cloud instead of the local runtime. The composer - * sets it by picking "Cursor Cloud" in the launch shelf's machine picker. The same overflow - * menu exposes the all-agents Cursor Cloud panel. + * Cloud mode: the next send goes to a hosted cloud runtime instead of the local runtime. + * The composer sets it by picking a cloud row in the launch shelf's machine picker. The same + * overflow menu exposes the cloud sessions panel. */ cursorCloudModeActive?: boolean; onSubmitToCloud?: (promptText: string) => Promise | boolean; + /** + * Display name of the cloud runtime the next send targets ("Cursor Cloud", "Devin Cloud"). + * The composer uses it for the send button's label and tooltip text. + */ + cloudTargetLabel?: string; /** Whether the Cursor Cloud all-agents panel can be opened for this lane. */ cursorCloudPanelAvailable?: boolean; cursorCloudPaneOpen?: boolean; onToggleCursorCloudPanel?: () => void; + /** Whether the Devin Cloud sessions panel can be opened for this lane. */ + devinCloudPanelAvailable?: boolean; + devinCloudPaneOpen?: boolean; + onToggleDevinCloudPanel?: () => void; + devinCloudHandoffAvailable?: boolean; + onHandoffToDevinCloud?: () => void; showAppControlToggle?: boolean; appControlOpen?: boolean; onToggleAppControl?: () => void; @@ -5098,7 +5115,7 @@ export function AgentChatComposer({ } if (cloudModeActiveForSend) { if (cloudSendBlock) return cloudSendBlock.reason; - return "Send to Cursor Cloud"; + return `Send to ${cloudTargetLabel}`; } if (!modelId) return singleModelBlockedMessage ?? "Select a model first"; if (singleModelBlockedMessage) return singleModelBlockedMessage; @@ -6102,6 +6119,23 @@ export function AgentChatComposer({ onSelect: onToggleCursorCloudPanel, }] : []), + ...(devinCloudPanelAvailable && onToggleDevinCloudPanel + ? [{ + id: "devin-cloud-panel", + label: devinCloudPaneOpen ? "Close Devin Cloud sessions" : "Open Devin Cloud sessions", + icon: , + active: devinCloudPaneOpen, + onSelect: onToggleDevinCloudPanel, + }] + : []), + ...(devinCloudHandoffAvailable && onHandoffToDevinCloud + ? [{ + id: "devin-cloud-handoff", + label: "Hand off to Devin Cloud", + icon: , + onSelect: onHandoffToDevinCloud, + }] + : []), ...(showOrchestratorModeButton ? [{ id: "orchestrator", @@ -6250,12 +6284,12 @@ export function AgentChatComposer({ const label = parallelChatMode ? "Send to lanes" : cloudMode - ? "Send to Cursor Cloud" + ? `Send to ${cloudTargetLabel}` : "Send"; const description = parallelChatMode ? "Create child lanes and send this prompt with its attachments to every configured model." : cloudMode - ? "Launch a Cursor Cloud agent with this prompt and the panel's settings." + ? `Launch a ${cloudTargetLabel} session with this prompt and the panel's settings.` : "Send this prompt to the selected model."; const backgroundAvailable = Boolean(onSubmitInBackground) && !parallelChatMode && !cloudMode; const sendIcon = cloudMode diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 1a9e8a7815..81531f88e4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { ArrowLeft, CaretRight, CircleNotch, CloudArrowUp, Cube, Desktop, DeviceMobile, ArrowBendUpRight, DownloadSimple, GitFork, Lightning, Plus, Terminal, TreeStructure, X, type Icon } from "@phosphor-icons/react"; +import { ArrowLeft, CaretRight, CircleNotch, CloudArrowUp, Cube, Desktop, DeviceMobile, Diamond, ArrowBendUpRight, DownloadSimple, GitFork, Lightning, Plus, Terminal, TreeStructure, X, type Icon } from "@phosphor-icons/react"; import { inferAttachmentType, mergeAttachments, @@ -54,10 +54,11 @@ import { type LaneLinearIssue, type AiSettingsStatus, type CursorCloudOpenChatResult, + type DevinCloudOpenChatResult, type OpenProjectBinding, type TerminalSessionDetail, } from "../../../shared/types"; -import type { CursorCloudServiceTier } from "../../../shared/types/config"; +import type { CursorCloudServiceTier, DevinCloudMode } from "../../../shared/types/config"; import { isUnsupportedAgentChatRecoveryActionError, providerForkReplaysTranscript, @@ -203,6 +204,7 @@ import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./RewindFilesConfirmDialog"; import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; import { ChatCursorCloudPanel } from "./ChatCursorCloudPanel"; +import { ChatDevinCloudPanel } from "./ChatDevinCloudPanel"; import { getLaneAccent } from "../lanes/laneColorPalette"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; import { ChatTerminalDrawer } from "./ChatTerminalDrawer"; @@ -282,7 +284,8 @@ import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { WorkActivityModule } from "../usage/ActivityModule"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; import { cursorCloudAgentWebUrl, cursorCloudErrorMessage, resolveCursorCloudPrCreateFields, pushAutoCreatedLaneOriginForCursorCloud, ensureExistingLaneOriginReadyForCursorCloud } from "../../lib/cursorCloudUtils"; -import { openExternalUrl } from "../../lib/openExternal"; +import { devinCloudErrorMessage } from "../../lib/devinCloudUtils"; +import { navigateUrlInAdeBrowser, openExternalUrl } from "../../lib/openExternal"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { invalidateAgentChatSessionListCache, @@ -352,6 +355,11 @@ import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound * only marks "run this off-machine", which the pane stores as cloud mode. */ const CURSOR_CLOUD_MACHINE_ID = "__ade_cursor_cloud__"; +/** + * Synthetic machine id for the launch shelf's Devin Cloud row. Same role as the + * Cursor one — a cloud target marker, never a paired computer. + */ +const DEVIN_CLOUD_MACHINE_ID = "__ade_devin_cloud__"; const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; const LAST_LAUNCH_CONFIG_KEY_PREFIX = "ade.chat.lastLaunchConfig.v1"; @@ -972,10 +980,11 @@ function draftLaunchPromptSnippet(job: DraftLaunchJob): string { function draftLaunchJobMessage(job: DraftLaunchJob): string { const laneSuffix = job.laneName ? ` in ${job.laneName}` : ""; const warningSuffix = job.warning ? ` ${job.warning}` : ""; - if (job.target === "cursor-cloud") { + if (job.target === "cursor-cloud" || job.target === "devin-cloud") { + const cloudName = job.target === "devin-cloud" ? "Devin Cloud" : "Cursor Cloud"; const cursorCloudStatusLabels: Partial> = { - "creating-lane": "Sending to Cursor Cloud...", - "starting-session": "Connecting to Cursor Cloud...", + "creating-lane": `Sending to ${cloudName}...`, + "starting-session": `Connecting to ${cloudName}...`, }; const cloudLabel = cursorCloudStatusLabels[job.status]; if (cloudLabel) return `${cloudLabel}${warningSuffix}`; @@ -1628,7 +1637,8 @@ type ChatRuntimeProviderKey = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): ChatRuntimeProviderKey { return desc ? resolveProviderGroupForModel(desc) : "opencode"; @@ -3697,6 +3707,7 @@ export function AgentChatPane({ // simulator session is live and the drawer is closed. const [iosSimulatorSessionChip, setIosSimulatorSessionChip] = useState<{ deviceName: string | null } | null>(null); const [cursorCloudPaneOpen, setCursorCloudPaneOpen] = useState(false); + const [devinCloudPaneOpen, setDevinCloudPaneOpen] = useState(false); // Subagent drill-in: when set, the chat surface renders the named subagent's // transcript instead of the parent stream and the composer is disabled. const [subagentView, setSubagentView] = useState<{ @@ -3709,9 +3720,18 @@ export function AgentChatPane({ const [rewindConfirmDialog, setRewindConfirmDialog] = useState(null); /** One cloud launch at a time: lane creation and the remote push are not idempotent. */ const cursorCloudLaunchInFlightRef = useRef(false); + const devinCloudLaunchInFlightRef = useRef(false); /** Reused when the user retries the same failed cloud draft so Cursor adopts instead of duplicating. */ const cursorCloudIdempotencyByDraftRef = useRef(new Map()); const cursorCloudBackfillAttemptedRef = useRef(new Set()); + const devinCloudBackfillAttemptedRef = useRef(new Set()); + // Devin cloud composer state: armed by the "Devin Cloud" machine row. The + // token gate is read once per pane mount — the same lazy cadence the + // quick-view button uses — so a work tab never pays an auth probe at boot. + const [devinCloudMode, setDevinCloudMode] = useState(false); + const [devinCloudModeSel, setDevinCloudModeSel] = useState(null); + const [devinBypassApproval, setDevinBypassApproval] = useState(false); + const [devinCloudAuthConfigured, setDevinCloudAuthConfigured] = useState(null); const [cloudOverlayArmed, setCloudOverlayArmed] = useState(false); const [cloudHydrateFailed, setCloudHydrateFailed] = useState(false); const [cloudBackfillNonce, setCloudBackfillNonce] = useState(0); @@ -5781,16 +5801,69 @@ export function AgentChatPane({ // cloud row depends on: Cursor's repo list and this lane's git remote. Each // re-runs only when it actually failed, so opening a healthy picker costs // nothing. + // Devin Cloud availability is a stored-token check, read lazily once — the + // same cadence the fleet button uses — and re-read when the machine picker + // opens so a freshly pasted token appears without a reload. + const refetchDevinCloudAuth = useCallback(() => { + const read = window.ade.ai.devinCloudGetAuthStatus; + if (typeof read !== "function") return; + void read() + .then((status) => setDevinCloudAuthConfigured(status.configured === true)) + .catch(() => setDevinCloudAuthConfigured(false)); + }, []); + useEffect(() => { + if (devinCloudAuthConfigured !== null) return; + refetchDevinCloudAuth(); + }, [devinCloudAuthConfigured, refetchDevinCloudAuth]); const handleDraftMachinePickerOpen = useCallback(() => { refetchCursorCloudRepos(); if (laneGitRemoteStatus === "error") refetchLaneGitRemote(); - }, [laneGitRemoteStatus, refetchCursorCloudRepos, refetchLaneGitRemote]); + refetchDevinCloudAuth(); + }, [laneGitRemoteStatus, refetchCursorCloudRepos, refetchDevinCloudAuth, refetchLaneGitRemote]); + const devinCloudPanelAvailable = Boolean(laneId) && devinCloudAuthConfigured === true; + const devinCloudAvailable = devinCloudPanelAvailable; + // Devin Cloud launches have the same "fresh chat" rule as Cursor's: once any + // turns exist, or the chat is already promoted, the cloud target is closed. + const devinCloudCanLaunch = devinCloudAvailable + && selectedEvents.length === 0 + && !selectedSession?.devinSessionId; + // Devin needs no account repo list — a session binds the lane's remote URL + // directly — so the only reasons are lane/remote shaped or a missing token. + const devinCloudUnavailableReason = useMemo(() => { + if (!devinCloudAvailable) return null; + if (!laneId) return "Choose a lane before sending to Devin Cloud."; + if (laneGitRemoteStatus === "idle" || laneGitRemoteStatus === "loading") { + return "Checking this lane's git remote…"; + } + if (laneGitRemoteStatus === "error") { + const detail = laneGitRemoteError?.trim() || "The git remote read failed."; + return `Could not read this lane's git remote: ${detail}`; + } + if (!laneGitRemote) { + return "This lane has no GitHub remote, so there is nothing for Devin Cloud to clone."; + } + return null; + }, [devinCloudAvailable, laneGitRemote, laneGitRemoteError, laneGitRemoteStatus, laneId]); + useEffect(() => { + if (!devinCloudPanelAvailable && devinCloudPaneOpen) setDevinCloudPaneOpen(false); + }, [devinCloudPanelAvailable, devinCloudPaneOpen]); + useEffect(() => { + if (!devinCloudPaneOpen) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setDevinCloudPaneOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [devinCloudPaneOpen]); // Cloud mode drops the moment the chat stops being launchable — a chat that // has started or a lost Cursor connection. A model switch is handled by the // cloud eligibility hook instead of hiding the entry point. useEffect(() => { if (!cursorCloudCanLaunch && cursorCloudMode) setCursorCloudMode(false); }, [cursorCloudCanLaunch, cursorCloudMode, setCursorCloudMode]); + useEffect(() => { + if (!devinCloudCanLaunch && devinCloudMode && devinCloudUnavailableReason) setDevinCloudMode(false); + }, [devinCloudCanLaunch, devinCloudMode, devinCloudUnavailableReason]); const applyCursorCloudModelSwitch = useCallback((nextModelId: string) => { setModelId(nextModelId); setReasoningEffort(null); @@ -5816,6 +5889,12 @@ export function AgentChatPane({ // chevron) was removed when launches were funneled through the dedicated cloud composer surface. const cursorRuntime: "local" | "cloud" = selectedSession?.cursorRuntime ?? (selectedSession?.cursorCloudAgentId ? "cloud" : "local"); + // Same runtime derivation for Devin-linked chats — the composer takes one + // local|cloud value, so both cloud providers fold into it. + const devinRuntime: "local" | "cloud" = selectedSession?.devinRuntime + ?? (selectedSession?.devinSessionId ? "cloud" : "local"); + const composerCloudRuntime: "local" | "cloud" = + cursorRuntime === "cloud" || devinRuntime === "cloud" ? "cloud" : "local"; const handoffAvailableModelIds = useMemo(() => { const merged = new Set(availableModelIds); for (const id of runtimeCatalogModelIds(modelCatalogScopeKey)) merged.add(id); @@ -9911,6 +9990,22 @@ export function AgentChatPane({ void refreshSessions().catch(() => undefined); }, [notifySessionCreated, refreshSessions, touchSession]); + // Identical adoption path for a Devin Cloud chat: select the session the + // mirror is bound to and let the transcript hydrate. + const adoptDevinCloudChatSession = useCallback((result: DevinCloudOpenChatResult) => { + const { sessionId, session } = result; + if (!sessionId) return; + loadedHistoryRef.current.delete(sessionId); + optimisticSessionIdsRef.current.add(sessionId); + knownSessionIdsRef.current.add(sessionId); + pendingSelectedSessionIdRef.current = sessionId; + draftSelectionLockedRef.current = false; + touchSession(sessionId); + if (session) notifySessionCreated(session); + setSelectedSessionId(sessionId); + void refreshSessions().catch(() => undefined); + }, [notifySessionCreated, refreshSessions, touchSession]); + useEffect(() => { const session = selectedSession; if (!session?.cursorCloudAgentId) return; @@ -9940,6 +10035,64 @@ export function AgentChatPane({ selectedSession, ]); + // Devin-linked chats get the same cold-start backfill: `devinCloudOpenChat` + // attaches the daemon mirror and hydrates the transcript once per selection. + useEffect(() => { + const session = selectedSession; + if (!session?.devinSessionId) return; + if (chatHasMessages || selectedChatCold) return; + if (devinCloudBackfillAttemptedRef.current.has(session.sessionId)) return; + devinCloudBackfillAttemptedRef.current.add(session.sessionId); + setCloudHydrateFailed(false); + void window.ade.ai.devinCloudOpenChat({ + devinSessionId: session.devinSessionId, + laneId: session.laneId, + sessionId: session.sessionId, + }).then((result) => { + if (result.session) notifySessionCreated(result.session); + loadedHistoryRef.current.delete(session.sessionId); + void refreshSessions().catch(() => undefined); + }).catch((error) => { + setCloudHydrateFailed(true); + setCloudOverlayArmed(false); + setError(devinCloudErrorMessage(error)); + }); + }, [ + chatHasMessages, + cloudBackfillNonce, + notifySessionCreated, + refreshSessions, + selectedChatCold, + selectedSession, + ]); + + // Same presence-gated watch for Devin mirrors: visible pane polls, hidden + // pane suspends — the mirror watch is the only poller this surface needs. + useEffect(() => { + const sessionId = selectedSession?.sessionId; + const devinSessionId = selectedSession?.devinSessionId?.trim(); + const watchFn = window.ade.ai.devinCloudWatchMirror; + if (!sessionId || !devinSessionId || subagentView || typeof watchFn !== "function") return; + + let watching = false; + const sync = () => { + const shouldWatch = document.visibilityState !== "hidden"; + if (shouldWatch === watching) return; + watching = shouldWatch; + void watchFn({ sessionId, watching }).catch(() => undefined); + }; + document.addEventListener("visibilitychange", sync); + sync(); + return () => { + document.removeEventListener("visibilitychange", sync); + if (watching) void watchFn({ sessionId, watching: false }).catch(() => undefined); + }; + }, [ + selectedSession?.devinSessionId, + selectedSession?.sessionId, + subagentView, + ]); + useEffect(() => { const sessionId = selectedSession?.sessionId; const agentId = selectedSession?.cursorCloudAgentId?.trim(); @@ -9965,8 +10118,11 @@ export function AgentChatPane({ subagentView, ]); + const selectedCloudLinked = Boolean( + selectedSession?.cursorCloudAgentId || selectedSession?.devinSessionId, + ); useEffect(() => { - if (!selectedSession?.cursorCloudAgentId || chatHasMessages || selectedChatCold || subagentView) { + if (!selectedCloudLinked || chatHasMessages || selectedChatCold || subagentView) { setCloudOverlayArmed(false); if (chatHasMessages) setCloudHydrateFailed(false); return; @@ -9981,7 +10137,9 @@ export function AgentChatPane({ chatHasMessages, cloudBackfillNonce, selectedChatCold, + selectedCloudLinked, selectedSession?.cursorCloudAgentId, + selectedSession?.devinSessionId, selectedSession?.sessionId, subagentView, ]); @@ -10228,6 +10386,222 @@ export function AgentChatPane({ setDraftLaunchTargetId, ]); + /** + * Send the composer's prompt to Devin Cloud. + * + * Devin's create call takes the repo URL itself, so the whole launch + * context is the lane: lane resolves repo + branch, and ADE records + * provenance tags server-side. Same draft-launch-job reporting as Cursor's + * flow, minus the model and account-repo-list checks Devin does not have. + */ + const launchDevinCloudSession = useCallback(async (promptText: string): Promise => { + // A hand-off passes a synthesized prompt with an empty composer; the + // snapshot then comes from the prompt alone — same shape as a typed draft. + const snapshot = buildDraftLaunchSnapshotForCurrentState() + ?? (promptText.trim().length + ? ({ + text: promptText, + draft: promptText, + modelId, + reasoningEffort, + fastMode, + cursorCloudServiceTier, + executionMode, + interactionMode, + nativeControls: { + ...currentNativeControls, + cursorConfigValues: { ...currentNativeControls.cursorConfigValues }, + }, + attachments: [], + contextAttachments: [], + iosContextItems: [], + appControlContextItems: [], + builtInBrowserContextItems: [], + visualContextPrefix: "", + visualContextDisplayChips: "", + isLiteralSlashCommand: false, + } satisfies DraftLaunchSnapshot) + : null); + if (!snapshot) { + setError("Add a message before sending."); + return false; + } + const prompt = promptText.trim() || snapshot.text.trim(); + if (devinCloudUnavailableReason) { + setError(devinCloudUnavailableReason); + return false; + } + if (devinCloudLaunchInFlightRef.current) return false; + devinCloudLaunchInFlightRef.current = true; + setError(null); + + const jobId = createDraftLaunchJobId(); + setDraftLaunchJobs((current) => pruneDraftLaunchJobs([ + { + id: jobId, + mode: "foreground" as const, + draftKind: "chat" as const, + target: "devin-cloud" as const, + status: "creating-lane" as const, + title: buildDraftLaunchJobTitle("chat", snapshot), + laneId: null, + laneName: null, + sessionId: null, + namingModelId: null, + error: null, + warning: null, + autoOpen: false, + createdAtMs: Date.now(), + snapshot, + }, + ...current.map((entry) => (entry.mode === "foreground" ? { ...entry, autoOpen: false } : entry)), + ])); + + let createdLaneId: string | null = null; + let createdDevinSessionId: string | null = null; + let launchTimedOut = false; + const assertLaunchActive = () => { + if (launchTimedOut) { + throw new Error("Draft launch aborted after timeout."); + } + }; + const markLaunchTimedOut = () => { + launchTimedOut = true; + }; + try { + let targetLaneId = laneId; + if (draftLaunchTargetIsAutoCreate) { + // Lane-first, same as a local auto-create send: the branch it produces is the branch + // Devin works on. The remote push must land first or Devin's clone sees an empty ref. + const createdLane = await withDraftLaunchTimeout( + resolveDraftLaunchLane(snapshot, { + pin: draftExecutionBindingRef.current, + assertActive: assertLaunchActive, + }), + "Lane setup", + markLaunchTimedOut, + ); + createdLaneId = createdLane.autoCreated ? createdLane.laneId : null; + targetLaneId = createdLane.laneId; + patchDraftLaunchJob(jobId, { laneId: createdLane.laneId, laneName: createdLane.laneName }); + await pushAutoCreatedLaneOriginForCursorCloud({ + laneId: createdLane.laneId, + branchHint: createdLane.laneName, + git: window.ade.git, + }); + } else if (targetLaneId) { + await ensureExistingLaneOriginReadyForCursorCloud({ + laneId: targetLaneId, + git: window.ade.git, + }); + } + if (!targetLaneId) throw new Error("Select a lane before sending."); + const sessionId = crypto.randomUUID(); + const created = await window.ade.ai.devinCloudCreateSession({ + laneId: targetLaneId, + prompt, + sessionId, + title: buildDraftLaunchJobTitle("chat", snapshot), + devinMode: devinCloudModeSel, + bypassApproval: devinBypassApproval, + }); + createdDevinSessionId = created.devinSessionId; + // The Devin session exists; leave the draft pane immediately. The mirror + // hydrates the transcript as Devin's VM comes up. + patchDraftLaunchJob(jobId, { status: "starting-session", sessionId }); + let opened: Awaited>; + try { + opened = await window.ade.ai.devinCloudOpenChat({ + devinSessionId: created.devinSessionId, + laneId: targetLaneId, + sessionId, + devinMode: devinCloudModeSel, + }); + } catch { + opened = { sessionId }; + } + const openedSession = opened.session ?? { + id: opened.sessionId || sessionId, + laneId: targetLaneId, + provider: "devin" as const, + model: "devin", + modelId: "devin/devin", + status: "active" as const, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + devinRuntime: "cloud" as const, + devinSessionId: created.devinSessionId, + devinMode: devinCloudModeSel, + }; + setDevinCloudMode(false); + if (createdLaneId) { + invalidateAgentChatSessionListCache({ laneId: createdLaneId }); + await refreshLanesStore().catch(() => undefined); + onLaneChange?.(createdLaneId); + setDraftLaunchTargetId(null); + } + patchDraftLaunchJob(jobId, { + status: "ready", + sessionId: openedSession.id, + draftKind: "chat", + autoOpen: false, + }); + adoptDevinCloudChatSession({ + sessionId: openedSession.id, + session: openedSession, + }); + return true; + } catch (cloudError) { + let message = devinCloudErrorMessage(cloudError); + if (createdDevinSessionId) { + message = `${message} The Devin session is already running at https://app.devin.ai/sessions/${createdDevinSessionId}.`; + } + // The lane, if one was created, is left alone: it is a normal empty lane and deleting it + // would throw away a branch that may already be on the remote. + patchDraftLaunchJob(jobId, { status: "failed", error: message, autoOpen: false }); + setError(message); + return false; + } finally { + devinCloudLaunchInFlightRef.current = false; + } + }, [ + adoptDevinCloudChatSession, + buildDraftLaunchSnapshotForCurrentState, + currentNativeControls, + cursorCloudServiceTier, + devinBypassApproval, + devinCloudModeSel, + devinCloudUnavailableReason, + draftLaunchTargetIsAutoCreate, + executionMode, + fastMode, + interactionMode, + laneId, + modelId, + onLaneChange, + patchDraftLaunchJob, + reasoningEffort, + refreshLanesStore, + resolveDraftLaunchLane, + setDraftLaunchJobs, + setDraftLaunchTargetId, + ]); + + /** + * Hand off this chat to Devin Cloud: the prompt packages the lane + chat + * context so the cloud session starts oriented. The lane's repo binding + * comes from `devinCloudCreateSession` resolving the lane remote. + */ + const handleHandoffToDevinCloud = useCallback(() => { + const lines = [ + "This task was handed off from an ADE lane chat for continuation in the cloud.", + selectedSession?.title?.trim() ? `Task so far: ${selectedSession.title.trim()}` : null, + laneDisplayLabel ? `Lane: ${laneDisplayLabel}` : null, + "Continue the work against this lane's repository.", + ].filter((line): line is string => Boolean(line)); + void launchDevinCloudSession(lines.join("\n")); + }, [laneDisplayLabel, launchDevinCloudSession, selectedSession?.title]); + const handoffSession = useCallback(async (mode: "brief" | "fork" = "brief") => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked || handoffBusy) return; const sourceLaneId = selectedSession?.laneId ?? laneId; @@ -11993,7 +12367,7 @@ export function AgentChatPane({ name: option.name, })), ]; - if (!cursorCloudCanLaunch) return machines; + if (!cursorCloudCanLaunch && !devinCloudCanLaunch) return machines; const withLocal = machines.length ? machines : [{ id: boundLaneMachineId, name: THIS_MACHINE_NAME }]; @@ -12002,19 +12376,36 @@ export function AgentChatPane({ : selectedDraftMachineId !== boundLaneMachineId ? "Cursor Cloud launches from this computer." : cursorCloudUnavailableReason; + const devinUnavailableReason = parallelChatMode + ? "Parallel models runs locally." + : selectedDraftMachineId !== boundLaneMachineId + ? "Devin Cloud launches from this computer." + : devinCloudUnavailableReason; return [ ...withLocal, - { - id: CURSOR_CLOUD_MACHINE_ID, - name: "Cursor Cloud", - kind: "cloud" as const, - unavailableReason: cloudUnavailableReason, - }, + ...(cursorCloudCanLaunch + ? [{ + id: CURSOR_CLOUD_MACHINE_ID, + name: "Cursor Cloud", + kind: "cloud" as const, + unavailableReason: cloudUnavailableReason, + }] + : []), + ...(devinCloudCanLaunch + ? [{ + id: DEVIN_CLOUD_MACHINE_ID, + name: "Devin Cloud", + kind: "cloud" as const, + unavailableReason: devinUnavailableReason, + }] + : []), ]; }, [ boundLaneMachineId, cursorCloudCanLaunch, cursorCloudUnavailableReason, + devinCloudCanLaunch, + devinCloudUnavailableReason, draftMachineRecoveryAvailable, laneMachineOptions, parallelChatMode, @@ -12022,18 +12413,29 @@ export function AgentChatPane({ ]); const draftShelfMachineValue = cursorCloudMode ? CURSOR_CLOUD_MACHINE_ID - : selectedDraftMachineId; + : devinCloudMode + ? DEVIN_CLOUD_MACHINE_ID + : selectedDraftMachineId; const handleDraftShelfMachineChange = useCallback((nextMachineId: string) => { if (nextMachineId === CURSOR_CLOUD_MACHINE_ID) { setError(null); + setDevinCloudMode(false); setCursorCloudMode(true); return; } + if (nextMachineId === DEVIN_CLOUD_MACHINE_ID) { + setError(null); + setCursorCloudMode(false); + setDevinCloudMode(true); + return; + } setCursorCloudMode(false); + setDevinCloudMode(false); handleDraftMachineChange(nextMachineId); }, [handleDraftMachineChange, setCursorCloudMode]); const useThisComputerForDraft = useCallback(() => { setCursorCloudMode(false); + setDevinCloudMode(false); handleDraftMachineChange(THIS_MACHINE_ID); setError(null); }, [handleDraftMachineChange, setCursorCloudMode, setError]); @@ -12708,6 +13110,25 @@ export function AgentChatPane({ onMissingFields={(message) => setError(message)} /> ); + const devinCloudPanelContent = ( + setDevinCloudPaneOpen(false)} + onClose={() => setDevinCloudPaneOpen(false)} + onOpened={(result) => { + setDevinCloudPaneOpen(false); + adoptDevinCloudChatSession(result); + }} + onMissingFields={(message) => setError(message)} + /> + ); const terminalPanelContent = chatTerminalVisible ? ( Cursor Cloud ) : null} + {selectedSession?.devinSessionId ? ( + + ) : null} } onLaneChipClick={laneId ? () => navigate(openLaneInLanesTabPath(laneId)) : undefined} showCacheBadge={showClaudeCacheTimer} @@ -13290,6 +13729,7 @@ export function AgentChatPane({ const composerMachineBinding = activeComposerRuntimeBinding; const cursorCloudSessionActive = cursorCloudMode || cursorRuntime === "cloud"; + const devinCloudSessionActive = devinCloudMode || devinRuntime === "cloud"; const composerAvailableModelIds = cursorCloudSessionActive ? cursorCloudModelIds : effectiveAvailableModelIds; const composerConstrainModelSelection = modelSelectionConstrained || cursorCloudSessionActive; @@ -13330,7 +13770,7 @@ export function AgentChatPane({ onPromptHistoryNavigate={handlePromptHistoryNavigate} attachments={attachments} composerMachineBinding={composerMachineBinding} - cursorRuntime={cursorRuntime} + cursorRuntime={composerCloudRuntime} modelRuntimePin={activeComposerRuntimeBinding} attachmentPersistenceUnavailableReason={draftAttachmentUnavailableReason} onUseThisComputer={draftMachineRecoveryAvailable @@ -13632,6 +14072,7 @@ export function AgentChatPane({ setChatActionsOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); + setDevinCloudPaneOpen(false); } return next; }); @@ -13645,14 +14086,16 @@ export function AgentChatPane({ setChatActionsOpen(false); setIosSimulatorOpen(false); setCursorCloudPaneOpen(false); + setDevinCloudPaneOpen(false); } return next; }); }} - cursorCloudCanLaunch={cursorCloudCanLaunch} - cursorCloudModelReady={cursorCloudModelReady} - cursorCloudHasEligibleModels={cursorCloudModelIds.length > 0} - cursorCloudModeActive={cursorCloudSessionActive} + cursorCloudCanLaunch={devinCloudMode ? devinCloudCanLaunch : cursorCloudCanLaunch} + cursorCloudModelReady={devinCloudMode ? true : cursorCloudModelReady} + cursorCloudHasEligibleModels={devinCloudMode ? true : cursorCloudModelIds.length > 0} + cursorCloudModeActive={cursorCloudSessionActive || devinCloudSessionActive} + cloudTargetLabel={devinCloudSessionActive ? "Devin Cloud" : "Cursor Cloud"} cursorCloudPanelAvailable={cursorCloudPanelAvailable} cursorCloudPaneOpen={cursorCloudPaneOpen} onToggleCursorCloudPanel={() => { @@ -13663,13 +14106,37 @@ export function AgentChatPane({ setIosSimulatorOpen(false); setAppControlOpen(false); setTerminalDrawerOpen(false); + setDevinCloudPaneOpen(false); } return next; }); }} + devinCloudPanelAvailable={devinCloudPanelAvailable} + devinCloudPaneOpen={devinCloudPaneOpen} + onToggleDevinCloudPanel={() => { + setDevinCloudPaneOpen((current) => { + const next = !current; + if (next) { + setChatActionsOpen(false); + setIosSimulatorOpen(false); + setAppControlOpen(false); + setTerminalDrawerOpen(false); + setCursorCloudPaneOpen(false); + } + return next; + }); + }} + devinCloudHandoffAvailable={ + devinCloudPanelAvailable + && Boolean(selectedSession) + && devinRuntime !== "cloud" + } + onHandoffToDevinCloud={handleHandoffToDevinCloud} onSubmitToCloud={async (promptText) => { void copyPromptForLaunch(promptText); - return launchCursorCloudRun(promptText); + return devinCloudSessionActive + ? launchDevinCloudSession(promptText) + : launchCursorCloudRun(promptText); }} parallelChatMode={parallelChatMode} onParallelChatModeChange={(enabled) => { @@ -13678,7 +14145,10 @@ export function AgentChatPane({ setAttachments((prev) => prev.slice(0, PARALLEL_CHAT_MAX_ATTACHMENTS)); } setParallelChatMode(enabled); - if (enabled) setCursorCloudMode(false); + if (enabled) { + setCursorCloudMode(false); + setDevinCloudMode(false); + } if (!enabled) { setParallelModelSlots([]); setParallelConfiguringIndex(null); @@ -13778,8 +14248,9 @@ export function AgentChatPane({ || job.status === "naming-lane" || job.status === "creating-lane" // A cloud launch reports every stage: it is the only place the user can see that ADE is - // waiting on Cursor rather than idle. - || (job.target === "cursor-cloud" && !isDraftLaunchJobTerminal(job.status))) + // waiting on the cloud provider rather than idle. + || ((job.target === "cursor-cloud" || job.target === "devin-cloud") + && !isDraftLaunchJobTerminal(job.status))) : EMPTY_DRAFT_LAUNCH_JOBS; const restorableErrorDraftLaunchJob = error ? visibleDraftLaunchJobs.find((job) => job.status === "failed" && job.error === error) ?? null @@ -13910,6 +14381,7 @@ export function AgentChatPane({ // shrinks the hero and moves the composer below. const appPanelOpen = effectiveIosSimulatorOpen || effectiveAppControlOpen; const effectiveCursorCloudPaneOpen = cursorCloudPaneOpen && cursorCloudPanelAvailable; + const effectiveDevinCloudPaneOpen = devinCloudPaneOpen && devinCloudPanelAvailable; const terminalRightPaneOpen = chatTerminalVisible && !hasExternalTerminalPane && terminalDrawerOpen && Boolean(selectedSessionId); // Orchestration: derive runId / role from the active session. When set, mount // the right plan panel and (for "orchestrator-lead") wrap the chat surface in @@ -13917,7 +14389,7 @@ export function AgentChatPane({ const orchestrationRunId = selectedSession?.orchestrationRunId ?? null; const orchestrationRole = activeOrchestrationRole; const orchestrationPanelOpen = Boolean(orchestrationRunId); - const heavyRightPaneOpen = appPanelOpen || orchestrationPanelOpen || terminalRightPaneOpen || effectiveCursorCloudPaneOpen; + const heavyRightPaneOpen = appPanelOpen || orchestrationPanelOpen || terminalRightPaneOpen || effectiveCursorCloudPaneOpen || effectiveDevinCloudPaneOpen; const supportsSplit = layoutVariant !== "grid-tile"; const chatActionsFloating = chatActionsOpen && supportsSplit && !heavyRightPaneOpen; const chatActionsRightPaneOpen = chatActionsOpen && !chatActionsFloating; @@ -14488,6 +14960,7 @@ export function AgentChatPane({ {effectiveIosSimulatorOpen ? renderRightPane(iosSimulatorPanelContent) : null} {effectiveAppControlOpen ? renderRightPane(appControlPanelContent) : null} {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} + {effectiveDevinCloudPaneOpen ? renderRightPane(devinCloudPanelContent) : null} {terminalRightPaneOpen && terminalPanelContent ? renderRightPane(terminalPanelContent) : null} {orchestrationPanelOpen && orchestrationPanelContent ? renderRightPane(orchestrationPanelContent) : null} @@ -14612,6 +15085,46 @@ export function AgentChatPane({ onRememberChange={setRememberSecretNames} /> ) : null} + {devinCloudMode && !parallelChatMode ? ( +
+ + +
+ ) : null} {onOpenShellSession || onImportedSession ? (
{onOpenShellSession ? ( @@ -14722,6 +15235,7 @@ export function AgentChatPane({ {effectiveIosSimulatorOpen ? renderRightPane(iosSimulatorPanelContent) : null} {effectiveAppControlOpen ? renderRightPane(appControlPanelContent) : null} {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} + {effectiveDevinCloudPaneOpen ? renderRightPane(devinCloudPanelContent) : null} )} diff --git a/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx new file mode 100644 index 0000000000..7720b0ae3d --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx @@ -0,0 +1,456 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { + ArrowSquareOut, + ArrowsClockwise, + CloudArrowUp, + Desktop, +} from "@phosphor-icons/react"; + +import type { + DevinCloudFleetEntry, + DevinCloudFleetStatus, + DevinCloudMode, + DevinCloudOpenChatResult, +} from "../../../shared/types"; +import { navigateUrlInAdeBrowser, openExternalUrl } from "../../lib/openExternal"; +import { + DEVIN_BLUE, + devinCloudErrorMessage, + devinCloudModeLabel, + devinCloudRepoLabel, + devinCloudStatusToneClass, + formatDevinCloudAge, + repoMatchKey, +} from "../../lib/devinCloudUtils"; +import { cn } from "../ui/cn"; +import { SmartTooltip } from "../ui/SmartTooltip"; + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "finished", + "error", + "archived", +]); + +function isActiveStatus(status: DevinCloudFleetStatus): boolean { + return !TERMINAL_STATUSES.has(status); +} + +export const DEVIN_CLOUD_MODES: readonly DevinCloudMode[] = [ + "normal", + "fast", + "lite", + "ultra", + "fusion", +]; + +export type ChatDevinCloudPanelHandle = { + launchWithPrompt: (promptText: string) => Promise<{ devinSessionId: string } | null>; + hasRequiredFields: () => boolean; +}; + +type ChatDevinCloudPanelProps = { + devinSessionId: string | null; + laneId: string | null; + laneGitRemote?: string | null; + laneGitBranch?: string | null; + devinMode: DevinCloudMode | null; + onDevinModeChange: (mode: DevinCloudMode | null) => void; + bypassApproval: boolean; + onBypassApprovalChange: (value: boolean) => void; + onLaunched?: (devinSessionId: string) => void; + onClose: () => void; + onOpened?: (result: DevinCloudOpenChatResult) => void; + onMissingFields?: (message: string) => void; +}; + +/** + * Right-pane Devin Cloud surface: session settings for the next launch plus + * the org sessions that touch this lane's repo. Unlike Cursor, Devin binds a + * session to any repo URL at create time — there is no account repo list to + * pick from, so the target is the lane's own remote shown as the launch + * context it is. + */ +export const ChatDevinCloudPanel = forwardRef(function ChatDevinCloudPanel({ + devinSessionId, + laneId, + laneGitRemote, + laneGitBranch, + devinMode, + onDevinModeChange, + bypassApproval, + onBypassApprovalChange, + onLaunched, + onClose, + onOpened, + onMissingFields, +}, ref) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [busySessionId, setBusySessionId] = useState(null); + const [error, setError] = useState(null); + const repoKey = useMemo(() => (laneGitRemote ? repoMatchKey(laneGitRemote) : null), [laneGitRemote]); + + const refresh = useCallback(async (opts?: { soft?: boolean }) => { + if (opts?.soft) setRefreshing(true); + else setLoading(true); + setError(null); + try { + const result = await window.ade.ai.devinCloudFleet({}); + setEntries(result.items); + } catch (err) { + setError(devinCloudErrorMessage(err)); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + useEffect(() => { + void refreshRef.current(); + const interval = window.setInterval(() => { + if (document.visibilityState === "visible") void refreshRef.current({ soft: true }); + }, 15_000); + return () => window.clearInterval(interval); + }, []); + + /** Sessions touching this lane's repo — the fleet already covers the org. */ + const repoEntries = useMemo(() => { + if (!repoKey) return entries; + return entries.filter((entry) => + entry.session.repos.some((repo) => repoMatchKey(repo) === repoKey), + ); + }, [entries, repoKey]); + + const sessionEntry = useMemo(() => { + if (!devinSessionId) return null; + return entries.find((entry) => entry.session.sessionId === devinSessionId) ?? null; + }, [devinSessionId, entries]); + + const activeEntries = useMemo( + () => repoEntries.filter((entry) => isActiveStatus(entry.fleetStatus)), + [repoEntries], + ); + const recentEntries = useMemo( + () => repoEntries + .filter((entry) => !isActiveStatus(entry.fleetStatus)) + .filter((entry) => entry.session.sessionId !== devinSessionId) + .slice(0, 6), + [devinSessionId, repoEntries], + ); + + const launchWithPrompt = useCallback(async (rawPrompt: string): Promise<{ devinSessionId: string } | null> => { + const trimmedPrompt = rawPrompt.trim(); + if (!trimmedPrompt) { + onMissingFields?.("Type a prompt in the chat composer first."); + return null; + } + if (!laneId) { + onMissingFields?.("Choose a lane before sending work to Devin Cloud."); + return null; + } + setLoading(true); + setError(null); + try { + const created = await window.ade.ai.devinCloudCreateSession({ + laneId, + prompt: trimmedPrompt, + devinMode, + bypassApproval, + }); + onLaunched?.(created.devinSessionId); + onOpened?.({ sessionId: created.sessionId, session: created.session }); + await refresh({ soft: true }); + return { devinSessionId: created.devinSessionId }; + } catch (err) { + setError(devinCloudErrorMessage(err)); + return null; + } finally { + setLoading(false); + } + }, [bypassApproval, devinMode, laneId, onLaunched, onMissingFields, onOpened, refresh]); + + useImperativeHandle(ref, () => ({ + launchWithPrompt, + hasRequiredFields: () => Boolean(laneId && laneGitRemote?.trim()), + }), [laneId, laneGitRemote, launchWithPrompt]); + + const openChat = useCallback(async (targetDevinSessionId: string) => { + if (!laneId) { + setError("Open a lane to open this cloud chat."); + return; + } + setBusySessionId(targetDevinSessionId); + setError(null); + try { + const result = await window.ade.ai.devinCloudOpenChat({ + devinSessionId: targetDevinSessionId, + laneId, + }); + // Only close the panel after we've handed the new session id to the + // parent. If onClose ran first (or unconditionally) the panel would + // unmount before onOpened could navigate, leaving the user on the + // previous chat with no visible feedback. + onOpened?.(result); + onClose(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[devin-cloud] openChat failed", err); + setError(devinCloudErrorMessage(err)); + } finally { + setBusySessionId(null); + } + }, [laneId, onClose, onOpened]); + + return ( + <> + {/* Header */} +
+
+ + Devin Cloud sessions + {refreshing ? ( + + ) : null} +
+
+ + +
+
+ + {/* Body */} +
+ {error ? ( +
+ {error.includes("Devin API token") || error.includes("DEVIN_API_KEY") + ? "Add a Devin API token in Settings → AI providers." + : error} +
+ ) : null} + +
+ {/* Launch settings for the next send */} +
+ New session +
+
+ Target repo + + {laneGitRemote ? devinCloudRepoLabel(laneGitRemote) : "No GitHub remote"} + +
+ {laneGitBranch ? ( +
+ Base branch + {laneGitBranch} +
+ ) : null} +
+ Agent mode + +
+ +
+ Send in the composer launches a Devin cloud session tagged to this lane. +
+
+
+ + {/* Linked session */} + {sessionEntry ? ( +
+ This chat's session +
+ { + const url = sessionEntry.session.url?.trim(); + if (url) navigateUrlInAdeBrowser(url, { newTab: true }); + }} + /> +
+
+ ) : null} + + {/* Active */} + {activeEntries.filter((entry) => entry.session.sessionId !== devinSessionId).length > 0 ? ( +
+ Running in this repo +
+ {activeEntries + .filter((entry) => entry.session.sessionId !== devinSessionId) + .map((entry) => ( + void openChat(entry.session.sessionId)} + onOpenLive={() => { + const url = entry.session.url?.trim(); + if (url) navigateUrlInAdeBrowser(url, { newTab: true }); + }} + /> + ))} +
+
+ ) : null} + + {/* Recent */} +
+ Recent in this repo +
+ {loading && entries.length === 0 ? ( +
+ Loading… +
+ ) : recentEntries.length === 0 ? ( +
+ {repoKey ? "No Devin sessions on this repo yet." : "No Devin sessions yet."} +
+ ) : recentEntries.map((entry) => ( + void openChat(entry.session.sessionId)} + onOpenLive={() => { + const url = entry.session.url?.trim(); + if (url) navigateUrlInAdeBrowser(url, { newTab: true }); + }} + /> + ))} +
+
+
+
+ + ); +}); + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function DevinSessionRow({ + entry, + busy, + onOpen, + onOpenLive, +}: { + entry: DevinCloudFleetEntry; + busy: boolean; + onOpen?: () => void; + onOpenLive?: () => void; +}) { + const { session } = entry; + const age = formatDevinCloudAge(session.updatedAt ?? session.createdAt); + const webUrl = session.url?.trim() || null; + return ( +
+ + + + {session.title || session.sessionId.slice(0, 12)} + + + {entry.fleetStatus === "needs_you" ? "needs you" : entry.fleetStatus} + + {age ? {age} : null} + + + {onOpenLive && webUrl ? ( + + + + ) : null} + {webUrl ? ( + + ) : null} + {onOpen ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/prs/shared/PrBotReviewCard.tsx b/apps/desktop/src/renderer/components/prs/shared/PrBotReviewCard.tsx index febfdb2c38..4cfc788165 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrBotReviewCard.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrBotReviewCard.tsx @@ -5,6 +5,7 @@ import codexMark from "@lobehub/icons-static-svg/icons/codex.svg"; import copilotMark from "@lobehub/icons-static-svg/icons/githubcopilot.svg"; import greptileMark from "@lobehub/icons-static-svg/icons/greptile.svg"; import vercelMark from "@lobehub/icons-static-svg/icons/vercel.svg"; +import devinMark from "../../../assets/provider-logos/devin.svg"; import type { PrReview } from "../../../../shared/types"; import { COLORS, SANS_FONT, inlineBadge } from "../../lanes/laneDesignTokens"; @@ -23,7 +24,8 @@ export type BotProvider = | "cursor" | "vercel" | "linear" - | "codecov"; + | "codecov" + | "devin"; type ProviderVisual = { label: string; @@ -49,6 +51,7 @@ const PROVIDERS: Record = { vercel: { label: "Vercel", accent: COLORS.textPrimary, initial: "V", mark: vercelMark }, linear: { label: "Linear", accent: COLORS.accent, initial: "L" }, codecov: { label: "Codecov", accent: COLORS.danger, initial: "C" }, + devin: { label: "Devin", accent: "#2563EB", initial: "D", mark: devinMark }, }; const DETECTION_PATTERNS: Array<{ provider: BotProvider; test: (login: string) => boolean }> = [ @@ -69,6 +72,7 @@ const DETECTION_PATTERNS: Array<{ provider: BotProvider; test: (login: string) = { provider: "linear", test: (l) => l === "linear" || l.startsWith("linear-") }, { provider: "codecov", test: (l) => l.startsWith("codecov") }, { provider: "cursor", test: (l) => l.startsWith("cursor") }, + { provider: "devin", test: (l) => l === "devin" || l.startsWith("devin-") }, ]; export function detectBotProvider(authorLogin: string): BotProvider | null { diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 7e61352786..783bce5dde 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -262,7 +262,7 @@ function writeJsonLs(key: string, value: unknown): void { type ResolverPermissionFamily = Extract< ModelProviderGroup, - "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" + "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | "devin" >; type ResolverPermissionPreferences = Record; @@ -277,6 +277,7 @@ const DEFAULT_RESOLVER_PERMISSIONS: ResolverPermissionPreferences = { kimi: "default", grok: "default", copilot: "default", + devin: "default", }; function normalizeResolverPermissionMode(value: unknown): PrAgentPermissionMode | null { @@ -309,6 +310,7 @@ function readPersistedResolverPermissions(): ResolverPermissionPreferences { kimi: normalizeResolverPermissionMode(parsed?.kimi) ?? DEFAULT_RESOLVER_PERMISSIONS.kimi, grok: normalizeResolverPermissionMode(parsed?.grok) ?? DEFAULT_RESOLVER_PERMISSIONS.grok, copilot: normalizeResolverPermissionMode(parsed?.copilot) ?? DEFAULT_RESOLVER_PERMISSIONS.copilot, + devin: normalizeResolverPermissionMode(parsed?.devin) ?? DEFAULT_RESOLVER_PERMISSIONS.devin, }; } catch { return DEFAULT_RESOLVER_PERMISSIONS; diff --git a/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx b/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx index b3ea32f528..cbc69322ec 100644 --- a/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx +++ b/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx @@ -7,7 +7,8 @@ * honest-degradation note Kimi needs — so it lives in a table rather than in * four near-identical descriptors. */ -import React from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { CheckCircle, Info, XCircle } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../../lanes/laneDesignTokens"; import { ProviderLogo } from "../../shared/ProviderLogos"; import { listModelDescriptorsForProvider, providerTierIsPreview } from "../../../../shared/modelRegistry"; @@ -89,6 +90,16 @@ export const ACP_PROVIDER_SPECS: readonly AcpProviderSpec[] = [ credentialSource: "Signed in through `copilot login`; the free plan includes the CLI. ADE does not write ~/.copilot.", setup: "Install the Copilot CLI and run `copilot login`. ADE reuses that GitHub login and never writes Copilot's config.json. Cancelled turns can still look finished on Copilot's side; ADE marks them stopped.", }, + { + ...ACP_PROVIDER_METADATA.devin, + id: "devin", + tagline: "Uses your Devin account through the devin CLI.", + logoFamily: "devin", + installCommand: "curl -fsSL https://cli.devin.ai/install.sh | bash", + credentialSource: "Signed in through `devin auth login` (browser OAuth, any Devin account), or WINDSURF_API_KEY. ADE does not write Devin's config.", + setup: "Install the Devin CLI (`brew install --cask devin-cli`, or the install command) and run `devin auth login`. ADE reuses that login and never writes Devin's config. Devin discovers AGENTS.md and .agents/skills/ in the lane itself, so ADE guidance reaches it without prompt injection.", + degradation: "Devin CLI does not yet expose account Knowledge, Playbooks, or Secrets to local sessions.", + }, ]; function specFor(id: AcpSettingsProviderId): AcpProviderSpec { @@ -319,6 +330,114 @@ function KimiBody() { ); } +/** + * Devin Cloud credentials: the API token that powers the fleet, mirrored + * chats, and cloud sends — separate from the `devin auth login` session the + * local CLI keeps. Any Devin account can mint one in its settings. + */ +function DevinBody() { + const [auth, setAuth] = useState> | null>(null); + const [keyInput, setKeyInput] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + void window.ade.ai.devinCloudGetAuthStatus() + .then((status) => { if (alive) setAuth(status); }) + .catch(() => undefined); + return () => { alive = false; }; + }, []); + + const save = useCallback(async () => { + const apiKey = keyInput.trim(); + if (!apiKey || busy) return; + setBusy(true); + setError(null); + try { + const status = await window.ade.ai.devinCloudSetCredentials({ apiKey }); + setAuth(status); + if (status.configured) setKeyInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [busy, keyInput]); + + const clear = useCallback(async () => { + if (busy) return; + setBusy(true); + setError(null); + try { + setAuth(await window.ade.ai.devinCloudSetCredentials({ apiKey: "" })); + setKeyInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [busy]); + + return ( +
+ Devin Cloud +
+ The API token that powers the Devin fleet, mirrored chats, and sending + work to Devin's cloud VMs. Paste a Personal Access Token + (cog_…) from + app.devin.ai → Settings → API, or a legacy personal key + (apk_user_…) where PATs + are unavailable. +
+ {auth?.configured ? ( +
+ + + Connected{auth.orgName ? ` — ${auth.orgName}` : ""} + + {auth.orgId ? ( + + {auth.orgId} + + ) : null} + +
+ ) : ( +
+ setKeyInput(event.target.value)} + placeholder="cog_..." + type="password" + disabled={busy} + onKeyDown={(event) => { if (event.key === "Enter") void save(); }} + style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + +
+ )} + {auth && !auth.configured && auth.error ? ( +
+ + {auth.error} +
+ ) : null} + {error ? ( +
+ + {error} +
+ ) : null} +
+ ); +} + function buildAcpDescriptor(spec: AcpProviderSpec): ProviderDescriptor { return { id: spec.id, @@ -330,7 +449,7 @@ function buildAcpDescriptor(spec: AcpProviderSpec): ProviderDescriptor { // chip on its own. preview: providerTierIsPreview(spec.id), // All four share one permission vocabulary because they share one host. - permissions: { family: spec.id === "kimi" ? "moonshot" : spec.id === "grok" ? "xai" : spec.id === "copilot" ? "github-copilot" : "qwen", isCliWrapped: true, key: spec.id }, + permissions: { family: spec.id === "kimi" ? "moonshot" : spec.id === "grok" ? "xai" : spec.id === "copilot" ? "github-copilot" : spec.id === "devin" ? "devin" : "qwen", isCliWrapped: true, key: spec.id }, status: (ctx) => acpStatus(ctx, spec.id), models: (ctx) => acpModels(ctx, spec.id), version: (ctx) => acpVersion(ctx, spec.id), @@ -341,6 +460,7 @@ function buildAcpDescriptor(spec: AcpProviderSpec): ProviderDescriptor { ? { Diagnostics: ({ ctx }: { ctx: ProvidersViewContext }) => } : {}), ...(spec.id === "kimi" ? { Body: KimiBody } : {}), + ...(spec.id === "devin" ? { Body: DevinBody } : {}), }; } diff --git a/apps/desktop/src/renderer/components/settings/providers/types.ts b/apps/desktop/src/renderer/components/settings/providers/types.ts index 01baaf0a93..a3465d362c 100644 --- a/apps/desktop/src/renderer/components/settings/providers/types.ts +++ b/apps/desktop/src/renderer/components/settings/providers/types.ts @@ -40,9 +40,10 @@ export type SettingsProviderId = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; -/** The four providers ADE drives over the Agent Client Protocol. */ +/** The providers ADE drives over the Agent Client Protocol. */ export type AcpSettingsProviderId = AcpProviderId; /** diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index f02624398f..92b6b79a26 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -84,6 +84,7 @@ const PICKER_FAMILY_BY_GROUP: Record = { droid: "factory", kimi: "moonshot", qwen: "qwen", + devin: "devin", ollama: "ollama", lmstudio: "lmstudio", }; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 716edf6340..1e0c004e45 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -310,6 +310,7 @@ const PICKER_FAMILY_BY_CATALOG_GROUP: Record = kimi: "moonshot", grok: "xai", copilot: "github-copilot", + devin: "devin", ollama: "ollama", lmstudio: "lmstudio", }; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 93cfd5e93f..9f0c4c91a3 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -12,6 +12,7 @@ const REFRESH_PROVIDER_BY_FAMILY: Partial; @@ -54,6 +56,7 @@ const ACP_PICKER_FAMILIES = [ { provider: "kimi", family: "moonshot" }, { provider: "grok", family: "xai" }, { provider: "copilot", family: "github-copilot" }, + { provider: "devin", family: "devin" }, ] as const satisfies readonly { provider: string; family: ProviderFamily }[]; const EMPTY_AUTH_STATUS: AuthStatusMap = {}; diff --git a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx index efc24e63fe..d3102490ed 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx @@ -27,6 +27,7 @@ import { lobeProviderIconSrc } from "../../lib/lobeProviderIconSrc"; import { cn } from "../ui/cn"; import droidMarkSrc from "../../assets/provider-logos/droid.svg"; import piMarkSrc from "../../assets/provider-logos/pi.svg"; +import devinMarkSrc from "../../assets/provider-logos/devin.svg"; type LogoProps = { size?: number; className?: string }; @@ -79,6 +80,10 @@ export function PiLogo({ size = 16, className }: LogoProps) { return ; } +export function DevinLogo({ size = 16, className }: LogoProps) { + return ; +} + function CursorSubscriptionModelMark({ providerModelId, size, className }: { providerModelId: string; size: number; className?: string }) { const s = providerModelId.trim().toLowerCase(); const c = lobeMarkClass(className); @@ -174,6 +179,8 @@ export function ProviderLogo({ return ; case "pi": return ; + case "devin": + return ; case "opencode": return ; case "xai": diff --git a/apps/desktop/src/renderer/lib/devinCloudUtils.ts b/apps/desktop/src/renderer/lib/devinCloudUtils.ts new file mode 100644 index 0000000000..70750c5b62 --- /dev/null +++ b/apps/desktop/src/renderer/lib/devinCloudUtils.ts @@ -0,0 +1,53 @@ +// Shared helpers for Devin Cloud renderer components. + +import { stripElectronErrorWrapper } from "../../shared/codedError"; +import { repoMatchKey } from "../../shared/cursorCloudRepoMatch"; +import type { DevinCloudFleetStatus, DevinCloudMode } from "../../shared/types/config"; +import { formatCursorCloudAge } from "./cursorCloudUtils"; + +export { repoMatchKey }; + +export const DEVIN_BLUE = "#2563EB"; + +/** Fleet-status → status pill tone. needs_you is the loud attention tier. */ +export function devinCloudStatusToneClass(status: DevinCloudFleetStatus | string | undefined | null): string { + const s = (status ?? "").toLowerCase(); + if (s === "needs_you") return "border-amber-300/30 bg-amber-500/12 text-amber-100/90"; + if (s === "working") return "border-sky-300/25 bg-sky-500/10 text-sky-100/80"; + if (s === "starting") return "border-sky-300/20 bg-sky-500/[0.07] text-sky-100/70"; + if (s === "finished") return "border-emerald-400/22 bg-emerald-500/8 text-emerald-100/80"; + if (s === "error") return "border-red-400/22 bg-red-500/8 text-red-200/85"; + if (s === "suspended") return "border-white/[0.10] bg-white/[0.03] text-fg/45"; + if (s === "archived") return "border-white/[0.08] bg-transparent text-fg/40"; + return "border-white/[0.08] bg-white/[0.025] text-fg/55"; +} + +/** Human label for a `devin_mode` value (normal/fast/lite/ultra/fusion). */ +export function devinCloudModeLabel(mode: DevinCloudMode | null | undefined): string { + if (!mode) return "Normal"; + return mode.charAt(0).toUpperCase() + mode.slice(1); +} + +export const formatDevinCloudAge = formatCursorCloudAge; + +/** `https://github.com/owner/repo` or `owner/repo` → `owner/repo` for compact display. */ +export function devinCloudRepoLabel(ref: string): string { + const key = repoMatchKey(ref) ?? ref.replace(/\.git$/i, "").replace(/\/+$/, ""); + const parts = key.split("/"); + return parts.length >= 2 ? `${parts[parts.length - 2]}/${parts[parts.length - 1]}` : key; +} + +/** + * Strip Electron's `Error invoking remote method '…':` wrapper so Devin Cloud + * failures show the underlying message (token missing, org unresolved, etc.). + */ +export function devinCloudErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return stripElectronErrorWrapper(raw) || "Devin Cloud request failed."; +} + +/** app.devin.ai session deep link — opens the session incl. its live Desktop view. */ +export function devinCloudSessionWebUrl(url: string | null | undefined): string | null { + const trimmed = url?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} diff --git a/apps/desktop/src/renderer/lib/draftLaunchJobs.ts b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts index 2145206472..735e83d31e 100644 --- a/apps/desktop/src/renderer/lib/draftLaunchJobs.ts +++ b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts @@ -109,7 +109,7 @@ export type DraftLaunchJobStatus = "naming-lane" | "creating-lane" | "starting-s * lane, start the agent, hand over the prompt — but each stage takes visibly longer and happens * off this machine, so the status line says so rather than claiming a local session is starting. */ -export type DraftLaunchTarget = "local" | "cursor-cloud"; +export type DraftLaunchTarget = "local" | "cursor-cloud" | "devin-cloud"; export type DraftLaunchJob = { id: string; diff --git a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts index df85f42c9b..f33524baee 100644 --- a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts +++ b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts @@ -33,7 +33,8 @@ type ChatRuntimeProviderKey = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; type CliProvider = ChatRuntimeProviderKey; export function defaultNativeControls(profile: ChatSurfaceProfile = "standard"): NativeControlState { diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index a7ec29ebde..e9761709c9 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -46,7 +46,8 @@ export function isPtyContextInsertableToolType(toolType: TerminalSessionSummary[ || toolType === "qwen" || toolType === "kimi" || toolType === "grok" - || toolType === "copilot"; + || toolType === "copilot" + || toolType === "devin"; } /** @@ -110,7 +111,8 @@ export type KnownChatProvider = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; export const CHAT_TOOL_TYPE_BY_PROVIDER: Record = { claude: "claude-chat", @@ -123,6 +125,7 @@ export const CHAT_TOOL_TYPE_BY_PROVIDER: Record = { @@ -136,6 +139,7 @@ const CHAT_PROVIDER_BY_TOOL_TYPE: Record = { "kimi-chat": "kimi", "grok-chat": "grok", "copilot-chat": "copilot", + "devin-chat": "devin", }; /** @@ -212,10 +216,12 @@ export function defaultSessionLabel(toolType: string | null | undefined): string if (toolType === "kimi-chat") return "Kimi chat"; if (toolType === "grok-chat") return "Grok chat"; if (toolType === "copilot-chat") return "Copilot chat"; + if (toolType === "devin-chat") return "Devin chat"; if (toolType === "qwen") return "Qwen CLI session"; if (toolType === "kimi") return "Kimi CLI session"; if (toolType === "grok") return "Grok CLI session"; if (toolType === "copilot") return "Copilot CLI session"; + if (toolType === "devin") return "Devin CLI session"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; return "Session"; @@ -301,6 +307,7 @@ const SHORT_TOOL_TYPE_PREFIXES: readonly [string, string][] = [ ["kimi", "Kimi"], ["grok", "Grok"], ["copilot", "Copilot"], + ["devin", "Devin"], ]; /** Resolve a short label via exact match, prefix match, or hyphen-to-space fallback. */ @@ -335,10 +342,12 @@ export function formatToolTypeLabel(toolType: string | null | undefined): string if (toolType === "kimi-chat") return "Kimi chat"; if (toolType === "grok-chat") return "Grok chat"; if (toolType === "copilot-chat") return "Copilot chat"; + if (toolType === "devin-chat") return "Devin chat"; if (toolType === "qwen") return "Qwen CLI session"; if (toolType === "kimi") return "Kimi CLI session"; if (toolType === "grok") return "Grok CLI session"; if (toolType === "copilot") return "Copilot CLI session"; + if (toolType === "devin") return "Devin CLI session"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; if (toolType === "shell") return "Terminal session"; diff --git a/apps/desktop/src/shared/acpProviderMetadata.ts b/apps/desktop/src/shared/acpProviderMetadata.ts index 03f703bfef..375519c63d 100644 --- a/apps/desktop/src/shared/acpProviderMetadata.ts +++ b/apps/desktop/src/shared/acpProviderMetadata.ts @@ -4,7 +4,7 @@ * labels, login commands, and config-home names have one owner. */ -export const ACP_PROVIDER_IDS = ["qwen", "kimi", "grok", "copilot"] as const; +export const ACP_PROVIDER_IDS = ["qwen", "kimi", "grok", "copilot", "devin"] as const; export type AcpProviderId = (typeof ACP_PROVIDER_IDS)[number]; export type AcpProviderMetadata = { @@ -44,4 +44,11 @@ export const ACP_PROVIDER_METADATA: Readonly = kimi: "kimi", grok: "grok", copilot: "copilot", + devin: "devin", shell: "shell", }; @@ -205,6 +208,7 @@ export const LAUNCH_PROFILE_TITLE: Record = { kimi: "Kimi Code CLI", grok: "Grok CLI", copilot: "GitHub Copilot CLI", + devin: "Devin CLI", shell: "Shell", }; @@ -363,6 +367,7 @@ const LAUNCH_PROFILE_TOOL_TYPES: Record` — argv is a + // safe transport for it on Windows too. Devin discovers AGENTS.md and + // `.agents/skills/` in the lane itself, so no prompt injection rides here. + if (initialPrompt) { + commandArgs.push("--", initialPrompt); + } + return { + command: "devin", + args: commandArgs, + startupCommand: commandArrayToLine(["devin", ...commandArgs], { platform: "linux" }), + ...(agentSkillEnv ? { env: agentSkillEnv } : {}), + }; + } + // Only the user's own text rides `--prompt`. OpenCode submits that value as a // real user message and renders it in the TUI, so the ADE preamble that used // to be prepended here was displayed to the user verbatim on every launch — @@ -1290,6 +1324,10 @@ export function resolveCopilotCliModelForLaunch(model: string | null | undefined return stripRegistryPrefix(model, "github-copilot"); } +export function resolveDevinCliModelForLaunch(model: string | null | undefined): string | null { + return stripRegistryPrefix(model, "devin"); +} + function qwenModelFlags(model: string | null | undefined): string[] { const resolved = resolveQwenCliModelForLaunch(model); return resolved ? ["-m", resolved] : []; @@ -1310,6 +1348,11 @@ function copilotModelFlags(model: string | null | undefined): string[] { return resolved ? ["--model", resolved] : []; } +function devinModelFlags(model: string | null | undefined): string[] { + const resolved = resolveDevinCliModelForLaunch(model); + return resolved ? ["--model", resolved] : []; +} + const GROK_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const; export function grokReasoningEffortFlags(reasoningEffort: string | null | undefined): string[] { @@ -1391,6 +1434,24 @@ export function permissionModeToCopilotFlags( return []; } +/** + * Devin's `--permission-mode` accepts the mode names its `/mode` command + * documents: normal, accept-edits, smart, plan, bypass. ADE's `auto` maps to + * `smart` — a fast model auto-approves clearly-safe actions and prompts on + * anything else, the closest honest tier. `config-toml` is already rejected + * upstream because Devin has no raw-config passthrough. + */ +export function permissionModeToDevinFlags( + permissionMode: AgentChatPermissionMode | null | undefined, +): string[] { + if (permissionMode == null) return []; + if (permissionMode === "full-auto") return ["--permission-mode", "bypass"]; + if (permissionMode === "auto") return ["--permission-mode", "smart"]; + if (permissionMode === "edit") return ["--permission-mode", "accept-edits"]; + if (permissionMode === "plan") return ["--permission-mode", "plan"]; + return ["--permission-mode", "normal"]; +} + function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--force"]; if (permissionMode === "plan") return ["--mode", "plan"]; @@ -1978,6 +2039,24 @@ export function buildTrackedCliResumeLaunchCommand( }; } + if (metadata.provider === "devin") { + const parts = [ + "devin", + ...devinModelFlags(model), + ...permissionModeToDevinFlags(permissionMode), + ]; + // `-r ` resumes a specific session; `-c` resumes the most recent one + // in the current directory. The same `--` argv prompt as a fresh launch. + if (targetId) parts.push("--resume", targetId); + else parts.push("--continue"); + if (prompt) parts.push("--", prompt); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + }; + } + const opencode = buildOpenCodeCommandParts({ permissionMode, model, diff --git a/apps/desktop/src/shared/devinCloudFleetStatus.ts b/apps/desktop/src/shared/devinCloudFleetStatus.ts new file mode 100644 index 0000000000..4a5c879fe8 --- /dev/null +++ b/apps/desktop/src/shared/devinCloudFleetStatus.ts @@ -0,0 +1,117 @@ +import type { + DevinCloudFleetStatus, + DevinCloudMode, + DevinCloudSessionSummary, +} from "./types/config"; + +/** + * Canonical Devin fleet-row status logic, shared by the main-process fleet + * service and the renderer so section placement, Stop-button visibility, and + * filter results can never drift between layers. + * + * Devin reports a coarse `status` plus a `status_detail`. The detail carries + * the attention signal ADE cares about: `waiting_for_user` and + * `waiting_for_approval` are the two loud states — a session that needs the + * human must not sit quietly in a fleet row. + */ +export function devinCloudFleetStatus( + session: Pick, +): DevinCloudFleetStatus { + if (session.isArchived) return "archived"; + const status = session.status?.toLowerCase() ?? ""; + const detail = session.statusDetail?.toLowerCase() ?? ""; + if (status === "error" || detail === "error") return "error"; + if (status === "exit" || detail === "finished") return "finished"; + if (status === "suspended") return "suspended"; + if (detail === "waiting_for_user" || detail === "waiting_for_approval") { + return "needs_you"; + } + if (status === "running") return "working"; + // new / claimed / resuming — the VM is coming up or the first turn has not + // landed yet. + return "starting"; +} + +/** True when the session is still doing something (or wants the human). */ +export function isDevinCloudSessionActive( + session: Pick, +): boolean { + const status = devinCloudFleetStatus(session); + return status === "starting" || status === "working" || status === "needs_you"; +} + +/** True when the row should surface in the loud "Needs you" tier. */ +export function devinCloudSessionNeedsYou( + session: Pick, +): boolean { + return devinCloudFleetStatus(session) === "needs_you"; +} + +/** Display string: archived wins over run state. */ +export function devinCloudFleetDisplayStatus( + session: Pick, +): DevinCloudFleetStatus { + return devinCloudFleetStatus(session); +} + +/** Coerce a persisted/create-time devin_mode value; unknown → null. */ +export function normalizeDevinCloudMode(value: unknown): DevinCloudMode | null { + return value === "normal" || value === "fast" || value === "lite" + || value === "ultra" || value === "fusion" + ? value + : null; +} + +// --------------------------------------------------------------------------- +// Provenance tags +// --------------------------------------------------------------------------- + +/** + * Tag stamped on every session ADE creates so the fleet can badge "via ADE" + * rows and filter From ADE, and so pull/continue can find the owning lane. + */ +export const DEVIN_CLOUD_ADE_TAG = "ade"; +export const DEVIN_CLOUD_LANE_TAG_PREFIX = "ade:lane:"; +export const DEVIN_CLOUD_PROJECT_TAG_PREFIX = "ade:project:"; +export const DEVIN_CLOUD_SESSION_TAG_PREFIX = "ade:session:"; + +export function devinCloudAdeLaneId(tags: readonly string[]): string | null { + for (const tag of tags) { + if (tag.startsWith(DEVIN_CLOUD_LANE_TAG_PREFIX)) { + const id = tag.slice(DEVIN_CLOUD_LANE_TAG_PREFIX.length).trim(); + if (id) return id; + } + } + return null; +} + +export function devinCloudAdeSessionTag(tags: readonly string[]): string | null { + for (const tag of tags) { + if (tag.startsWith(DEVIN_CLOUD_SESSION_TAG_PREFIX)) { + const id = tag.slice(DEVIN_CLOUD_SESSION_TAG_PREFIX.length).trim(); + if (id) return id; + } + } + return null; +} + +export function devinCloudCreatedViaAde(tags: readonly string[]): boolean { + return tags.includes(DEVIN_CLOUD_ADE_TAG); +} + +export function buildDevinCloudAdeTags(args: { + laneId?: string | null; + projectId?: string | null; + sessionId?: string | null; + extra?: readonly string[]; +}): string[] { + const tags = new Set([DEVIN_CLOUD_ADE_TAG]); + if (args.laneId?.trim()) tags.add(`${DEVIN_CLOUD_LANE_TAG_PREFIX}${args.laneId.trim()}`); + if (args.projectId?.trim()) tags.add(`${DEVIN_CLOUD_PROJECT_TAG_PREFIX}${args.projectId.trim()}`); + if (args.sessionId?.trim()) tags.add(`${DEVIN_CLOUD_SESSION_TAG_PREFIX}${args.sessionId.trim()}`); + for (const extra of args.extra ?? []) { + const trimmed = extra.trim(); + if (trimmed) tags.add(trimmed); + } + return [...tags]; +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 6c426e4d3f..6014392852 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -760,6 +760,17 @@ export const IPC = { aiCursorCloudResolveLane: "ade.ai.cursorCloud.resolveLane", aiCursorCloudStopRun: "ade.ai.cursorCloud.stopRun", aiCursorCloudFleetEvent: "ade.ai.cursorCloud.fleetEvent", + aiDevinCloudFleet: "ade.ai.devinCloud.fleet", + aiDevinCloudPullIntoLane: "ade.ai.devinCloud.pullIntoLane", + aiDevinCloudOpenChat: "ade.ai.devinCloud.openChat", + aiDevinCloudWatchMirror: "ade.ai.devinCloud.watchMirror", + aiDevinCloudFollowUp: "ade.ai.devinCloud.followUp", + aiDevinCloudCreateSession: "ade.ai.devinCloud.createSession", + aiDevinCloudTerminateSession: "ade.ai.devinCloud.terminateSession", + aiDevinCloudArchiveSession: "ade.ai.devinCloud.archiveSession", + aiDevinCloudUnarchiveSession: "ade.ai.devinCloud.unarchiveSession", + aiDevinCloudGetAuthStatus: "ade.ai.devinCloud.getAuthStatus", + aiDevinCloudSetCredentials: "ade.ai.devinCloud.setCredentials", syncGetStatus: "ade.sync.getStatus", syncGetLocalStatus: "ade.sync.getLocalStatus", syncRefreshDiscovery: "ade.sync.refreshDiscovery", diff --git a/apps/desktop/src/shared/modelCatalog.test.ts b/apps/desktop/src/shared/modelCatalog.test.ts index 60a8431c62..dee0a208ff 100644 --- a/apps/desktop/src/shared/modelCatalog.test.ts +++ b/apps/desktop/src/shared/modelCatalog.test.ts @@ -12,6 +12,7 @@ describe("model picker provider order", () => { "claude", "codex", "cursor", + "devin", "opencode", "pi", "copilot", diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts index 74e28dab58..a1cf2993f1 100644 --- a/apps/desktop/src/shared/modelCatalog.ts +++ b/apps/desktop/src/shared/modelCatalog.ts @@ -75,6 +75,7 @@ export const MODEL_PICKER_PROVIDER_ORDER = [ "claude", "codex", "cursor", + "devin", "opencode", "pi", "copilot", @@ -107,6 +108,7 @@ const PROVIDER_LABELS: Record = { meta: "Meta", qwen: "Qwen", moonshot: "Moonshot", + devin: "Devin", }; export const PROVIDER_BADGE_COLORS: Record = { @@ -127,6 +129,7 @@ export const PROVIDER_BADGE_COLORS: Record = { lmstudio: "#64748B", groq: "#06B6D4", together: "#22C55E", + devin: "#2563EB", meta: "#3B82F6", qwen: "#6D4AFF", moonshot: "#1F1F1F", @@ -168,6 +171,7 @@ export const PROVIDER_GROUP_COLORS: Record = { kimi: "#1F1F1F", grok: "#DC2626", copilot: "#8B5CF6", + devin: "#2563EB", opencode: "#2563EB", ollama: "#71717A", lmstudio: "#64748B", @@ -225,6 +229,7 @@ const PROVIDER_GROUP_LABELS: Record = { kimi: "Kimi", grok: "Grok", copilot: "GitHub Copilot", + devin: "Devin", opencode: "OpenCode", ollama: "Ollama", lmstudio: "LM Studio", diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 62b6cac7be..73597c026e 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -22,7 +22,8 @@ export type ProviderFamily = | "pi" | "qwen" | "moonshot" - | "github-copilot"; + | "github-copilot" + | "devin"; export type LocalProviderFamily = Extract; @@ -119,7 +120,8 @@ export type ModelProviderGroup = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; /** Every provider group, in the order surfaces list them. */ export const MODEL_PROVIDER_GROUPS = [ @@ -133,6 +135,7 @@ export const MODEL_PROVIDER_GROUPS = [ "droid", "kimi", "qwen", + "devin", ] as const satisfies readonly ModelProviderGroup[]; /** Select a valid reasoning tier without duplicating fallback policy in each UI. */ @@ -891,6 +894,97 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ previewTier: true, }, + // ---- Devin (CLI-wrapped via `devin`, ACP, preview) ---- + // Devin's `--model` takes short family names that always resolve to the + // latest release ("adaptive" is the router it recommends by default). The + // whole server-side catalog arrives through live ACP discovery; these rows + // are the durable picks a person reaches for. + { + id: "devin/adaptive", + shortId: "devin-adaptive", + aliases: ["adaptive"], + displayName: "Adaptive (Devin)", + family: "devin", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + color: "#2563EB", + providerRoute: "devin-acp", + providerModelId: "adaptive", + cliCommand: "devin", + isCliWrapped: true, + previewTier: true, + }, + { + id: "devin/swe", + shortId: "devin-swe", + aliases: ["swe", "devin-swe-1-6"], + displayName: "SWE (Devin)", + family: "devin", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + color: "#1D4ED8", + providerRoute: "devin-acp", + providerModelId: "swe", + cliCommand: "devin", + isCliWrapped: true, + previewTier: true, + }, + { + id: "devin/opus", + shortId: "devin-opus", + aliases: ["devin-opus"], + displayName: "Opus (Devin)", + family: "devin", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + color: "#1E40AF", + providerRoute: "devin-acp", + providerModelId: "opus", + cliCommand: "devin", + isCliWrapped: true, + previewTier: true, + }, + { + id: "devin/gpt", + shortId: "devin-gpt", + aliases: ["gpt", "devin-gpt-5-5"], + displayName: "GPT (Devin)", + family: "devin", + authTypes: ["cli-subscription"], + contextWindow: 400_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + color: "#172554", + providerRoute: "devin-acp", + providerModelId: "gpt", + cliCommand: "devin", + isCliWrapped: true, + previewTier: true, + }, + { + id: "devin/fable", + shortId: "devin-fable", + aliases: ["devin-fable"], + displayName: "Fable (Devin)", + family: "devin", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + color: "#3B82F6", + providerRoute: "devin-acp", + providerModelId: "fable", + cliCommand: "devin", + isCliWrapped: true, + previewTier: true, + }, + // ---- Cursor SDK models: discovered at runtime via @cursor/sdk (see cursorModelsDiscovery + getResolvedAvailableModels) ---- // ---- Local (Ollama) ---- @@ -1496,9 +1590,9 @@ export function getDynamicOpenCodeModelDescriptors(): ModelDescriptor[] { // --------------------------------------------------------------------------- /** Provider groups whose models can arrive from a live ACP session. */ -export type AcpModelProviderGroup = "qwen" | "kimi" | "grok" | "copilot"; +export type AcpModelProviderGroup = "qwen" | "kimi" | "grok" | "copilot" | "devin"; -const ACP_MODEL_PROVIDER_GROUPS = ["qwen", "kimi", "grok", "copilot"] as const; +const ACP_MODEL_PROVIDER_GROUPS = ["qwen", "kimi", "grok", "copilot", "devin"] as const; /** Family, route prefix, and brand color for each ACP provider group. */ const ACP_GROUP_METADATA: Record< @@ -1515,6 +1609,13 @@ const ACP_GROUP_METADATA: Record< color: "#8B5CF6", previewTier: true, }, + devin: { + family: "devin", + providerRoute: "devin-acp", + cliCommand: "devin", + color: "#2563EB", + previewTier: true, + }, }; /** @@ -2099,6 +2200,7 @@ export function getAvailableModels( moonshot: "kimi", xai: "grok", "github-copilot": "copilot", + devin: "devin", }; const hasMappedCli = (family: ProviderFamily): boolean => { @@ -2269,7 +2371,7 @@ export function resolveModelIdForProvider( */ export function resolveCliProviderForModel( descriptor: ModelDescriptor, -): "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | null { +): "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | "devin" | null { if (descriptor.providerRoute === "pi-sdk") return "pi"; if (!descriptor.isCliWrapped) return null; if (descriptor.family === "cursor") return "cursor"; @@ -2278,6 +2380,7 @@ export function resolveCliProviderForModel( if (descriptor.family === "moonshot") return "kimi"; if (descriptor.family === "xai") return "grok"; if (descriptor.family === "github-copilot") return "copilot"; + if (descriptor.family === "devin") return "devin"; if (descriptor.family === "anthropic") return "claude"; if (descriptor.family === "openai") return "codex"; return null; @@ -2323,6 +2426,7 @@ export function getRuntimeModelRefForDescriptor( || provider === "kimi" || provider === "grok" || provider === "copilot" + || provider === "devin" ) { return descriptor.providerModelId; } @@ -2353,6 +2457,7 @@ function listProviderModelsInternal(provider: ModelProviderGroup): ModelDescript if (provider === "kimi") return descriptor.isCliWrapped && descriptor.family === "moonshot"; if (provider === "grok") return descriptor.isCliWrapped && descriptor.family === "xai"; if (provider === "copilot") return descriptor.isCliWrapped && descriptor.family === "github-copilot"; + if (provider === "devin") return descriptor.isCliWrapped && descriptor.family === "devin"; return !descriptor.isCliWrapped; }); // Curated rows first, then anything a live ACP session reported. The @@ -2486,7 +2591,7 @@ function pickDefaultModelForProvider( if (discovered) return models.find((model) => model.id === discovered.id) ?? models[0]; return models[0]; } - if (provider === "kimi" || provider === "grok" || provider === "copilot") { + if (provider === "kimi" || provider === "grok" || provider === "copilot" || provider === "devin") { return models[0]; } return pickDefaultOpenCodeModel(models); diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts index 679e7f8378..42ab451f3a 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts @@ -32,6 +32,7 @@ const PROVIDER_PROFILE_EXPECTATIONS: Record { diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts index 32532aa961..dfb8d71d54 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts @@ -497,6 +497,7 @@ export function applyOrchestrationPermissionProfile( case "kimi": case "grok": case "copilot": + case "devin": return { acpPermissionMode: "yolo" satisfies AgentChatAcpPermissionMode, permissionMode: "full-auto", diff --git a/apps/desktop/src/shared/providerRetryPresentation.ts b/apps/desktop/src/shared/providerRetryPresentation.ts index 2c5dd4a97d..ae0b6c2e4e 100644 --- a/apps/desktop/src/shared/providerRetryPresentation.ts +++ b/apps/desktop/src/shared/providerRetryPresentation.ts @@ -136,7 +136,7 @@ export function isProviderRetryTurnBoundary(event: AgentChatEvent): boolean { function providerFromRetryText(message: string): string { const lower = message.toLowerCase(); - for (const provider of ["claude", "codex", "opencode", "cursor", "droid", "pi", "qwen", "kimi", "grok", "copilot"]) { + for (const provider of ["claude", "codex", "opencode", "cursor", "droid", "pi", "qwen", "kimi", "grok", "copilot", "devin"]) { if (lower.includes(provider)) return provider; } return "provider"; diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index bd32f87e0e..20330f45f3 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -80,6 +80,11 @@ export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ "ai.cursorCloudResolveLane", "ai.cursorCloudPullIntoLane", "ai.cursorCloudStopRun", + // Devin Cloud fleet view — same optional gating as Cursor's. + "ai.getDevinCloudFleet", + "ai.pullDevinCloudSessionIntoLane", + "ai.openDevinCloudChat", + "ai.getDevinCloudAuthStatus", // Per-project prompt stash. iOS gates the overflow-menu items on these // descriptors so an older brain simply omits stash instead of going limited. "chat.listPromptStashes", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 09bc65ceb2..9650c67772 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -16,7 +16,7 @@ import type { SubagentCapability } from "../subagentCapabilities"; import { providerDisplayLabel } from "../pendingInputLabels"; import type { AgentChatStopMode as CanonicalAgentChatStopMode } from "../chatStopModes"; import type { ClaudeContextCategoryKind } from "../claudeContextUsage"; -import type { CursorCloudServiceTier } from "./config"; +import type { CursorCloudServiceTier, DevinCloudMode } from "./config"; export type AgentChatProvider = | "codex" @@ -36,7 +36,7 @@ export type AgentChatProvider = * one session-config shape, so surfaces branch on this list instead of naming * the four providers again. */ -export const ACP_CHAT_PROVIDERS = ["qwen", "kimi", "grok", "copilot"] as const; +export const ACP_CHAT_PROVIDERS = ["qwen", "kimi", "grok", "copilot", "devin"] as const; export type AcpChatProvider = (typeof ACP_CHAT_PROVIDERS)[number]; export function isAcpChatProvider( @@ -2071,6 +2071,14 @@ export type AgentChatSession = { cursorRuntime?: AgentChatRuntime; /** Turn id at which the session was first promoted to cloud (renders the system bubble). */ cursorPromotedTurnId?: string; + /** Durable Devin cloud session id once this session has been promoted to cloud. */ + devinSessionId?: string; + /** Default runtime for new turns in this session (set on promotion). */ + devinRuntime?: AgentChatRuntime; + /** Devin agent tier requested at create (`devin_mode`); null = Devin's default. */ + devinMode?: DevinCloudMode | null; + /** Turn id at which the session was first promoted to cloud (renders the system bubble). */ + devinPromotedTurnId?: string; identityKey?: AgentChatIdentityKey; surface?: AgentChatSurface; automationId?: string | null; @@ -2192,6 +2200,10 @@ export type AgentChatSessionSummary = { cursorCloudAgentId?: string; cursorRuntime?: AgentChatRuntime; cursorPromotedTurnId?: string; + devinSessionId?: string; + devinRuntime?: AgentChatRuntime; + devinMode?: DevinCloudMode | null; + devinPromotedTurnId?: string; identityKey?: AgentChatIdentityKey; /** * The spawning chat's identity, when it had one — `"cto"` for work the CTO @@ -2617,7 +2629,8 @@ export type AgentChatModelCatalogRefreshProvider = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; export type AgentChatModelCatalogMode = "cached" | "refresh-stale" | "force"; @@ -3015,7 +3028,8 @@ export type AgentChatCliLaunchProvider = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; /** * Launch a tracked CLI/terminal agent (not the in-process chat SDK) with one or diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 8e6e34170c..898eb7ad13 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1037,7 +1037,7 @@ export type AiFeatureUsageRow = { export type AiDetectedAuth = { 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"; @@ -1071,7 +1071,7 @@ export type AiProviderConnectionSource = { }; export type AiProviderConnectionStatus = { - provider: "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot"; + provider: "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | "devin"; authAvailable: boolean; runtimeDetected: boolean; runtimeAvailable: boolean; @@ -1102,6 +1102,7 @@ export type AiProviderConnections = { kimi?: AiProviderConnectionStatus; grok?: AiProviderConnectionStatus; copilot?: AiProviderConnectionStatus; + devin?: AiProviderConnectionStatus; }; /** @@ -1112,7 +1113,7 @@ export type AiProviderConnections = { * meaningful next to one that does. */ export type AcpProviderDiagnostics = { - provider: "qwen" | "kimi" | "grok" | "copilot"; + provider: "qwen" | "kimi" | "grok" | "copilot" | "devin"; /** Null when nothing was found — the bare command name is a guess, not a path. */ binaryPath: string | null; binarySource: "env" | "auth" | "path" | "common-dir" | "fallback-command"; @@ -1362,6 +1363,233 @@ export type CursorCloudOpenChatResult = { session?: AgentChatSession; }; +// --------------------------------------------------------------------------- +// Devin Cloud +// --------------------------------------------------------------------------- + +/** Devin's agent tier, chosen at session create (`devin_mode`). */ +export type DevinCloudMode = "normal" | "fast" | "lite" | "ultra" | "fusion"; + +export type DevinCloudAuthMode = "v3" | "v1"; + +export type DevinCloudSessionStatus = + | "new" + | "claimed" + | "running" + | "resuming" + | "suspended" + | "exit" + | "error"; + +/** + * One Devin cloud session, normalized from the v3 `SessionResponse` + * (or the v1 equivalent on the personal-key fallback path). + */ +export type DevinCloudSessionSummary = { + /** Devin's session id — the bare id, without the `devin-` prefix. */ + sessionId: string; + title: string | null; + status: DevinCloudSessionStatus | null; + /** + * Raw `status_detail` (working, waiting_for_user, waiting_for_approval, + * finished, or a suspension reason). `devinCloudFleetStatus` interprets it. + */ + statusDetail: string | null; + isArchived: boolean; + /** app.devin.ai deep link — opens the session incl. its live Desktop view. */ + url: string | null; + pullRequests: Array<{ prUrl: string; prState: string | null }>; + tags: string[]; + /** Repo names in `owner/repo` form, from the v3 create payload. */ + repos: string[]; + createdAt: number | null; + updatedAt: number | null; + devinMode: DevinCloudMode | null; + acusConsumed: number | null; + userId: string | null; + parentSessionId: string | null; + /** "mine" when the API caller created it, when the API reports it. */ + origin: string | null; +}; + +export type DevinCloudMessage = { + eventId: string; + source: "devin" | "user"; + message: string; + createdAt: number; +}; + +/** + * One file a Devin session uploaded or produced (recordings, screenshots, + * exports) — the raw material ADE files into the proof drawer. + */ +export type DevinCloudAttachment = { + attachmentId: string; + name: string; + url: string; + source: "devin" | "user"; + contentType: string | null; +}; + +export type DevinCloudListMessagesResult = { + items: DevinCloudMessage[]; + endCursor: string | null; +}; + +export type DevinCloudCreateSessionRequest = { + prompt: string; + /** Remote repo URLs bound to the session (e.g. the lane's origin). */ + repoUrls?: string[]; + /** Extra tags beyond ADE's provenance tags. */ + tags?: string[]; + title?: string | null; + devinMode?: DevinCloudMode | null; + /** Resume-able session (Devin keeps the VM snapshot warm). */ + resumable?: boolean; + /** ADE chat session id to mirror into; not sent to Devin. */ + sessionId?: string | null; + /** ADE lane id used for provenance tags + mirror ownership. */ + laneId?: string | null; + /** Canonical ADE projects.id; not sent to Devin. */ + projectId?: string | null; + /** Linear identifier such as ADE-12. Kept on the ADE session. */ + linearIssueId?: string | null; + /** Skip Devin's approval gate (maps to `bypass_approval`). */ + bypassApproval?: boolean; +}; + +export type DevinCloudCreateSessionResult = { + session: DevinCloudSessionSummary; +}; + +export type DevinCloudSendMessageRequest = { + devinSessionId: string; + message: string; + attachmentUrls?: string[]; +}; + +export type DevinCloudSendMessageResult = { + delivered: true; +}; + +/** + * Fleet-row status, resolved from `status` + `status_detail`. "needs_you" is + * the loud attention tier (waiting_for_user / waiting_for_approval); it maps + * onto ADE's two-tier attention model so Devin work is not a silent channel. + */ +export type DevinCloudFleetStatus = + | "starting" + | "working" + | "needs_you" + | "finished" + | "suspended" + | "error" + | "archived"; + +export type DevinCloudFleetOwnership = { + sessionId: string | null; + sessionTitle: string | null; + laneId: string | null; + laneName: string | null; + /** Linear identifier such as ADE-12, from the owning lane. */ + linearIssueId: string | null; +}; + +export type DevinCloudFleetEntry = { + session: DevinCloudSessionSummary; + fleetStatus: DevinCloudFleetStatus; + /** Branch pulled from the session's first PR URL when it is a branch link. */ + prUrl: string | null; + ownership: DevinCloudFleetOwnership; + /** True when ADE launched this session (`ade` provenance tag). */ + createdViaAde: boolean; + /** Lane id parsed from the `ade:lane:` provenance tag. */ + adeLaneId: string | null; + /** + * Why this entry is in the fleet: a linked ADE session ("session"), a repo + * match against the project origin ("repo"), an `ade:` tag ("tag"), or an + * org-level row unrelated to this project ("org"). + */ + matchedBy: "session" | "repo" | "tag" | "org"; +}; + +export type DevinCloudFleetResult = { + items: DevinCloudFleetEntry[]; + fetchedAt: string; +}; + +export type DevinCloudPullIntoLaneResult = { + status: "pulled" | "created_lane"; + laneId: string; + laneName: string; + sessionId: string | null; + mergedBranch: string; +}; + +export type DevinCloudOpenChatRequest = { + devinSessionId: string; + laneId: string; + /** Predetermined ADE session id (same id stamped at create). */ + sessionId?: string | null; + modelId?: string | null; + /** Agent mode the cloud session runs under (normal/fast/lite/ultra/fusion). */ + devinMode?: DevinCloudMode | null; +}; + +export type DevinCloudOpenChatResult = { + sessionId: string; + session?: AgentChatSession; +}; + +export type DevinCloudAuthStatus = { + configured: boolean; + /** Which API generation the stored credential can drive. */ + authMode: DevinCloudAuthMode | null; + /** Resolved org id (configured or auto-discovered), null for v1 keys. */ + orgId: string | null; + /** Devin account/org label for the settings page, when verified. */ + orgName: string | null; + error: string | null; +}; + +export type DevinCloudSetCredentialsRequest = { + /** PAT (`cog_...`) or legacy personal key (`apk_user_...`). Empty clears. */ + apiKey: string; + /** `org-...` id for v3 PATs; auto-discovered when omitted. */ + orgId?: string | null; +}; + +export type DevinCloudWatchMirrorRequest = { + /** ADE chat session id whose Devin transcript mirror should poll. */ + sessionId: string; + watching: boolean; +}; + +export type DevinCloudFollowUpRequest = { + devinSessionId: string; + message: string; +}; + +export type DevinCloudFollowUpResult = { + sessionId: string; +}; + +export type DevinCloudCreateSessionForLaneRequest = { + laneId: string; + prompt: string; + sessionId?: string | null; + title?: string | null; + devinMode?: DevinCloudMode | null; + projectId?: string | null; + bypassApproval?: boolean; +}; + +export type DevinCloudCreateSessionForLaneResult = { + sessionId: string; + session?: AgentChatSession; + devinSessionId: string; +}; + export type CursorCloudWatchMirrorRequest = { sessionId: string; watching: boolean; @@ -1641,6 +1869,7 @@ export type AiSettingsStatus = { kimi?: boolean; grok?: boolean; copilot?: boolean; + devin?: boolean; }; models: { claude: AiModelDescriptor[]; @@ -1651,6 +1880,7 @@ export type AiSettingsStatus = { kimi?: AiModelDescriptor[]; grok?: AiModelDescriptor[]; copilot?: AiModelDescriptor[]; + devin?: AiModelDescriptor[]; }; features: AiFeatureUsageRow[]; detectedAuth?: AiDetectedAuth[]; @@ -1719,6 +1949,7 @@ export type AiProviderPermissions = { kimi?: AgentChatPermissionMode; grok?: AgentChatPermissionMode; copilot?: AgentChatPermissionMode; + devin?: AgentChatPermissionMode; codexSandbox?: "read-only" | "workspace-write" | "danger-full-access"; writablePaths?: string[]; allowedTools?: string[]; @@ -1843,6 +2074,11 @@ export type AiConfig = { // OpenCode/runtime-backed fields defaultModel?: ModelId; apiKeys?: Record; + /** + * Devin Cloud org id (`org-...`) for v3 PAT calls. Auto-discovered from + * `GET /v3/enterprise/organizations` when unset; not a secret. + */ + devinCloudOrgId?: string | null; localProviders?: AiLocalProviderConfigs; /** User-defined OpenAI-compatible providers injected into the OpenCode server config. */ customProviders?: AiCustomProviderConfig[]; @@ -1889,6 +2125,7 @@ export type AiIntegrationStatus = { kimi?: boolean; grok?: boolean; copilot?: boolean; + devin?: boolean; }; models: { claude: AgentChatModelInfo[]; @@ -1899,6 +2136,7 @@ export type AiIntegrationStatus = { kimi?: AgentChatModelInfo[]; grok?: AgentChatModelInfo[]; copilot?: AgentChatModelInfo[]; + devin?: AgentChatModelInfo[]; }; // OpenCode/runtime-backed fields detectedAuth?: AiDetectedAuth[]; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 6faf8013cd..bc54030a90 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -59,10 +59,12 @@ export type TerminalToolType = | "kimi" | "grok" | "copilot" + | "devin" | "qwen-chat" | "kimi-chat" | "grok-chat" | "copilot-chat" + | "devin-chat" | "aider" | "continue" | "other"; @@ -78,6 +80,7 @@ export type TrackedAgentCliToolType = | "kimi" | "grok" | "copilot" + | "devin" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated"; @@ -118,6 +121,7 @@ export function isTrackedAgentCliToolType( || toolType === "kimi" || toolType === "grok" || toolType === "copilot" + || toolType === "devin" || toolType === "claude-orchestrated" || toolType === "codex-orchestrated" || toolType === "opencode-orchestrated"; @@ -183,7 +187,8 @@ export type TerminalResumeProvider = | "qwen" | "kimi" | "grok" - | "copilot"; + | "copilot" + | "devin"; export type TerminalResumeTargetKind = "session" | "thread"; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 44599ac0ac..00eb2c93b7 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1709,6 +1709,7 @@ export type SyncCliLaunchProvider = | "kimi" | "grok" | "copilot" + | "devin" | "shell"; export type SyncStartCliSessionArgs = { @@ -2042,6 +2043,17 @@ export type SyncRemoteCommandAction = | "ai.cursorCloudResolveLane" | "ai.cursorCloudPullIntoLane" | "ai.cursorCloudStopRun" + | "ai.getDevinCloudAuthStatus" + | "ai.setDevinCloudCredentials" + | "ai.getDevinCloudFleet" + | "ai.pullDevinCloudSessionIntoLane" + | "ai.terminateDevinCloudSession" + | "ai.archiveDevinCloudSession" + | "ai.unarchiveDevinCloudSession" + | "ai.devinCloudFollowUp" + | "ai.openDevinCloudChat" + | "ai.watchDevinCloudMirror" + | "ai.createDevinCloudSession" | "orchestration.runCreate" | "prs.list" | "prs.listOpenForRepo" diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 32b911cce3..d82b8b3a94 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -76,6 +76,9 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/cursorCloudConversation.ts` | Cursor Cloud conversation unwrap, turn fingerprints, live-run status, and presence-gated inbound-sync helpers. `run.conversation()` is per-run, not full agent history; fingerprints plus prefix/suffix matching let hydrate skip turns ADE already has. `nextCursorCloudMirrorDelay` walks `3s → 8s → 20s → 45s` while a watched chat is quiet and resets to 3 s on new turns. `releaseCursorCloudAttachLease` drops a failed `cloud.run.attach` so watches can poll again. | | `apps/desktop/src/main/services/chat/cursorCloudMirrorWatch.ts` | Per-session watch refcount + backoff scheduler extracted from `agentChatService`. First watch hydrates immediately; later ticks poll only that session; last unwatch clears the timer. Clients call `ai.watchCursorCloudMirror` (`cursorCloudWatchMirror` in preload). Desktop watches while the selected cloud chat is visible, TUI while that session is active, iOS while the scene is active. The sync host registers `ai.watchCursorCloudMirror` and `ai.openCursorCloudChat` so a web/remote client watching a cloud chat on that machine is a real host command, not an adapter fallback. Cursor Cloud has no create-time webhook, so this poll is the inbound path for an **open cloud chat**. The account-level **fleet view** deliberately does not join this timer: its freshness comes from the Cursor Cloud ingress relay (`cursorCloudIngressService`) re-broadcasting each terminal FINISHED/ERROR delivery as the `ade.ai.cursorCloud.fleetEvent` project event (`main.ts` dispatch), so open fleet surfaces refresh when agents finish and otherwise wait for the manual refresh button. | | `apps/desktop/src/main/services/chat/cursorCloudFleetService.ts` | Account-wide Cursor Cloud **fleet view** backend behind `ade.ai.cursorCloud.fleet` / `.pullIntoLane` / `.resolveLane` / `.stopRun` (registered as ADE actions on the `ai` domain and as sync remote commands, so iOS/web reach the same host implementation). The service follows every Cursor page at the API's 100-item cap and keeps short-lived cached results. Each row reports ADE ownership (`matchedBy: "session" \| "repo" \| "both" \| "account"`) when a session or repo matches the current project; repo matching remains scoped for branch pull/merge safety. Only live rows are enriched with their latest run (concurrency 4), so finished rows cost nothing until a pull or expansion asks. Pull-into-lane resolves the target lane as linked session's lane → any local lane already on the pushed branch → a fresh lane imported from the remote branch, refuses dirty worktrees, fetches + merges `FETCH_HEAD`, aborts the merge and says exactly where things stand on conflict, scopes multi-repo agents to branches pushed to *this* project's repo (branches attributed to other repos refuse instead of falling back to a name-only fetch), and guards remote-reported refs against git argv injection (`safeBranchRef`). `resolveLaneForAgent` is the same resolution without touching git; `stopAgentRun` cancels an agent's latest run even when no ADE chat exists. | +| `apps/desktop/src/main/services/ai/devinCloudClient.ts` | Thin v3 Devin REST client (`https://api.devin.ai/v3/organizations/{org}`): session list/detail/messages/create/terminate/archive, attachment list + authenticated byte download. Resolves credentials from the Devin cloud token store (v3 PAT `cog_` primary, v1 `apk_user_` fallback with the org id collected once), redacts tokens from every thrown error, and maps HTTP statuses onto ADE error kinds. | +| `apps/desktop/src/main/services/chat/devinCloudConversation.ts` | Devin cloud transcript mirror: converts v3 session messages into ADE chat turns and composes outbound sends as `POST messages`. | +| `apps/desktop/src/main/services/chat/devinCloudFleetService.ts` | Org-wide Devin **fleet view** backend behind `ade.ai.devinCloud.*` (ADE actions on the `ai` domain + sync remote commands, so iOS/web reach the same host). Lists org sessions with the v3 `qs` JSON filter, reports ADE ownership (`matchedBy: "session" \| "repo" \| "tag" \| "org"`) and launch provenance (`createdViaAde`, `adeLaneId`), pulls a finished session's pushed branch into its owning/matching/new lane under the same refusal rules as Cursor's pull, terminates/archives/unarchives, and creates lane-bound sessions tagged `ade` + `ade:lane:`. Attention mapping (`waiting_for_user`/`waiting_for_approval` → `requestAttention`) and proof sync (Devin-source attachments downloaded into the computer-use artifact store and ingested into the proof drawer, deduped by attachment id) run from the hydrate loop in `agentChatService`. | | `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. It creates the SDK local agent platform with the lane workspace/state root, configures local agents to use HTTP/1 by default (`ADE_CURSOR_SDK_USE_HTTP1_FOR_AGENT=0` disables it), enables SDK local agent retries, passes ADE mode/idempotency keys on sends, and tolerates stream-iteration failures long enough to call `run.wait()` and emit a structured terminal result. The SDK's `local.force` send option (expire the currently active persisted run before starting this message as a new follow-up) is wired to the explicit `forceExpireActiveRun` payload flag and is set **only** on ADE's automatic recovery re-send — a normal send that expired a genuinely running turn would discard its output. User images are materialized here from attachment paths or URLs (`workerAttachmentImages.ts`) rather than as base64 on the JSON IPC pipe — several large screenshots on `child.send` can stall the turn so Cursor never sees the message. | | `apps/desktop/src/main/services/chat/cursorSdkErrors.ts` | Cursor SDK error normalization helpers shared by the worker: extracts `code`, `status`, `requestId`, `operation`, and `endpoint` from SDK errors/results, reads terminal run details through the public local store API, and classifies resource/backoff vs transport failures without reaching into private SDK run fields. Classification yields a bare `CursorSdkErrorKind`; there is no companion `retryable` bit, because what a caller does about a failure (recycle the thread, surface a rate limit, re-auth) is decided per call site rather than encoded in the classifier. | | `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, hook decisions, hook requests, `CursorSdkModelParameterValue`, `CursorSdkWorkerInit`, local/cloud send payloads, SDK request ids, and `CursorSdkErrorDetail`. User images on those payloads are path/URL references (`CursorSdkUserImage`), not inlined screenshot bytes. It exports Cursor-specific error classifiers for transport (`nghttp2`, dropped sockets, stream closures, plus the socket-side cousins `ECANCELED` / `EPIPE` / `write after end`, which poison the server-side agent thread the same way) and backoff/resource exhaustion (`resource_exhausted`, `rate_limited`, `NGHTTP2_ENHANCE_YOUR_CALM`, 429-style text) so UI/service paths present rate-limit and network failures consistently. `classifyCursorSdkErrorText` returns a bare `CursorSdkErrorKind` (`auth` / `rate_limit` / `network` / `busy` / `not_found` / `configuration` / `unknown`); the `configuration` kind is the shared sandbox-unsupported predicate `isSandboxUnsupportedFailureText` from `shared/chatErrorPresentation.ts`, not a second copy of the same terms. The expired-short-lived-access-token signature lives here too, as one greppable literal (`CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT`) plus `isCursorSdkStaleAccessTokenText` (matches the sentence's two halves independently, so a reflowed clause or a request-id suffix still matches, while a genuinely bad API key does not) and `readCursorSdkStaleTokenFailure`, which reads the worker's synthetic terminal `status: ERROR` event into a `CursorSdkStaleTokenFailure` (`turnId`, message, optional code and request id) in one pass, or returns `null` for any other error. `CursorSdkPermissionPolicy.fullAuto` is a permission-mode marker only — it separates full-auto sessions into their own worker pool and labels logs, and deliberately does **not** map onto the SDK's `local.force`; run expiry is the separate recovery-only `CursorSdkSendPrompt.forceExpireActiveRun`. | @@ -178,6 +181,9 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/cursorCloudUtils.ts` | Cloud launch helpers: origin-push (`pushAutoCreatedLaneOriginForCursorCloud` / `ensureExistingLaneOriginReadyForCursorCloud`), PR create fields, agent URL, and Electron error-wrapper stripping. | | `apps/desktop/src/renderer/components/chat/useLaneGitRemote.ts`, `useCursorCloudModelEligibility.ts`, `draftModelControls.ts` | Cloud-draft readiness: tri-state git remote probe, catalog eligibility, and reconciling draft reasoning/fast controls when the selected model changes. | | `apps/desktop/src/renderer/components/app/CursorCloudQuickViewButton.tsx`, `CursorCloudFleetModal.tsx`, `CursorCloudFleetRow.tsx` | Top-bar Cursor Cloud **fleet view** (see [Composer and chat UI › Cursor Cloud fleet view](composer-and-ui.md#cursor-cloud-fleet-view) for the surface contract). The button mounts beside `LinearQuickViewButton` only while a Cursor connection exists — read through a per-project cached `ai.getStatus` reader (60 s connected / 1.5 s disconnected TTLs, checked after a 2 s delay and re-queued on bridge-ready) so it never lands in the Work startup IPC window — and counts finishes from `fleetEvent` pushes into an unread badge while the modal is closed. The modal owns filters (status / lane / archived), the active/lane/unlinked grouping, cached lazy row expansion with usage/artifacts, all row actions, and the honest relay/key-missing/empty/error states; `FleetRow` renders one row plus its overflow menu. Status derivation lives in shared `cursorCloudFleetStatus.ts` so section placement, Stop-button visibility, and filter results cannot drift between main process and renderer. Opening the modal occludes the built-in browser's `WebContentsView` so native content cannot paint over it. | +| `apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx`, `DevinCloudFleetModal.tsx`, `DevinCloudFleetRow.tsx` | Top-bar and sidebar Devin **fleet view** (see [Composer and chat UI › Devin Cloud fleet view](composer-and-ui.md#devin-cloud-fleet-view)). The button mounts beside the Cursor quick-view only while `devinCloudGetAuthStatus().configured` is true. The modal owns the provenance chips (Mine / From ADE / All), status and lane filters, the active/unlinked grouping, row actions (open mirrored chat, live `session.url` in the built-in browser, stop, pull into lane, archive/unarchive, PR, delete), and the pull-into-lane toast's **Continue in lane** action that launches the local `devin` CLI in the merged lane with seeded context. | +| `apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx` | Chat-drawer panel drafting a lane-bound Devin cloud session: target repo + base branch from the lane, `devin_mode` picker, approval-gate skip, and recent sessions on this repo. | +| `apps/desktop/src/renderer/lib/devinCloudUtils.ts`, `apps/desktop/src/shared/devinCloudFleetStatus.ts` | Renderer launch/format helpers plus the single session-status → fleet-status derivation shared by the modal, rows, and the attention mapping. | | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | | `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → near-black, droid/factory → burnt orange, opencode → periwinkle, pi → near-black, etc.) and `providerChatAccent(provider)`, plus `NEUTRAL_CHAT_ACCENT` and the single synchronous resolver `chatAccentForRenderedChat({ sessionProvider, lockSessionProvider, modelFamily, modelColor })` — best evidence first, caller-owned staleness, neutral gray rather than a borrowed color (see [composer-and-ui.md](composer-and-ui.md#resolving-the-accent-for-the-chat-on-screen)). The user bubble shades from `--chat-accent` itself rather than mixing toward a fixed violet, so two runtimes with different accents no longer come out the same purple; Claude and Codex are pinned to the original gradient (`ACCENTS_KEEPING_ORIGINAL_BUBBLE`) because they already read correctly, and near-black accents take a lifted gradient (`isDeepChatAccent`, luminance < 0.22) so the bubble does not disappear into the transcript background. iOS mirrors this table in `ADEDesignSystem.swift`. | | `apps/desktop/src/renderer/components/chat/AskQuestionComposer.tsx` | The ask-question surface, anchored **in the composer** — it replaces the textarea inside the same prompt-box frame while a question blocks (there is no longer a separate `AgentQuestionModal`, no `InlineQuestionRequestCard`, and no question-kind `pendingBanner`). Header is the provider mark + a kind-derived verb (`{Provider} asks` / `{Provider} · Plan ready` via `pendingInputHeaderLabel`) plus a dot rail for paged sets, a minimize `⌄`, and a decline `×`; body shows the question's `header` kicker then the question text once; options render as a one-column ledger with radio/checkbox a11y roles and a flush-right `✓`; option previews render through `QuestionOptionPreview` — a column-preserving monospace `
` for wireframes/ASCII (detected via `looksLikeWireframe`) and the code-fence-aware `ChatMarkdown` for prose — inside a natural-height, capped option region, disclosed by an explicit click rather than hover. Only genuinely long option content scrolls; header, note row, and footer stay pinned. Chrome inherits `--chat-accent` (per-provider), used in exactly two places plus one structural hairline. Keyboard: `1-9` pick, `↵` next/send, `←→` page, `esc` decline. Selecting marks and never submits; a pick and a typed note both travel (see `shared/pendingInputAnswers.ts`). Nothing is preselected. `QuestionReceipts.tsx` renders the transcript record: an "awaiting you" row while open, a one-line expandable receipt once resolved. |
@@ -2365,6 +2371,9 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi
 | `ade.ai.cursorCloud.fleet` | invoke | Account-wide fleet read for the top-bar and sidebar Cursor Cloud views: every cloud agent returned by Cursor, with latest-run status, pushed branch/PR, model, ownership (session/lane/Linear id), and `matchedBy`. Returns `relayState` + `lastEventAt` so clients can state honestly whether live updates are configured. Backed by `cursorCloudFleetService.ts`; repository matching remains scoped to safe branch pull/merge actions. |
 | `ade.ai.cursorCloud.pullIntoLane` / `.resolveLane` / `.stopRun` | invoke | Fleet row actions. Pull merges a finished agent's pushed branch into its owning/matching/new lane (dirty worktrees refused; conflicts abort the merge); resolve maps an unlinked agent to a lane without touching git; stop cancels the agent's latest run host-side. |
 | `ade.ai.cursorCloud.fleetEvent` | push | Per-project re-broadcast of Cursor Cloud relay deliveries carrying terminal statuses (FINISHED/ERROR). Wakes open fleet surfaces (no polling timer), lights the top-bar unread-finishes badge while the modal is closed, and carries `agentId`, `status`, `summary`, `branchName`, `prUrl`, and relay event identity. |
+| `ade.ai.devinCloud.getAuthStatus` / `.setCredentials` | invoke | Reads `{configured, authMode, orgId, orgName}` for the visibility-gated quick-view and settings row; stores the v3 PAT (`cog_`) or v1 personal key (`apk_user_`) plus the org id in the credential store. |
+| `ade.ai.devinCloud.fleet` / `.pullIntoLane` / `.terminateSession` / `.archiveSession` / `.unarchiveSession` | invoke | Fleet read (org sessions with status, PRs, tags, ACUs, ownership, `matchedBy`, `createdViaAde`) and row actions. Pull merges a finished session's pushed branch into its owning/matching/new lane (dirty worktrees refused; conflicts abort); terminate/archive round out the reverse states. |
+| `ade.ai.devinCloud.openChat` / `.followUp` / `.createSession` / `.watchMirror` | invoke | Mirrored-chat seam: open mirrors the session transcript (`GET messages`) into an ADE chat, followUp sends `POST messages`, createSession launches a lane-bound session with `repos`, `devin_mode`, `bypass_approval`, and `ade`/`ade:lane:` provenance tags, and watchMirror drives the open-chat hydrate tick (also the attention + proof-sync pump). |
 
 ## Fragile and tricky wiring
 
diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md
index dbf52e946c..3571e905e7 100644
--- a/docs/features/chat/agent-routing.md
+++ b/docs/features/chat/agent-routing.md
@@ -40,6 +40,7 @@ for vendored runtimes without changing the union.
 | `codex` | Pinned `@openai/codex` 0.153.4 `codex app-server` subprocess, JSON-RPC protocol. Spawn failures surface as error events. | `agentChatService.ts` (Codex adapter and thread config); executable resolution via `services/ai/codexExecutable.ts`. |
 | `opencode` | OpenCode server runtime: Anthropic/OpenAI/Google/Mistral/DeepSeek/xAI/Groq/Together AI API keys, OpenRouter, and local (Ollama, LM Studio, vLLM). | `agentChatService.ts` (OpenCode adapter); model discovery in `localModelDiscovery.ts` and `modelsDevService.ts`. |
 | `cursor` | Official `@cursor/sdk` running in a Node worker pool. ADE owns permissions, hooks, and the system prompt; the SDK owns the model + tool execution. Slash commands are discovered from `.cursor/commands/`, `.cursor/agents/`, built-in subagents, and Agent Skill roots via `cursorSlashCommandDiscovery.ts`. A transport failure can wedge the server-side agent thread while the worker process stays alive, so every local turn carries a 90 s first-event watchdog and one automatic recycle-and-resend — see [Cursor thread recycling and the first-event watchdog](README.md#cursor-thread-recycling-and-the-first-event-watchdog). | `cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`, `cursorSlashCommandDiscovery.ts`. |
+| `devin` | The user's `devin` CLI spawned as `devin acp` over the shared ACP host (JSON-RPC stdio), plus an org-wide cloud fleet over the v3 Sessions API — mirrored transcript chats, lane-bound session creation, terminate/archive, pull-into-lane, attention mapping, and proof sync. The same provider id covers the tracked `devin` CLI for PTY sessions. | `acpHost/acpDialects/devin.ts`; cloud in `services/ai/devinCloudClient.ts`, `services/chat/devinCloudFleetService.ts`, `devinCloudConversation.ts`. |
 | `droid` | Factory Droid models exposed as dynamic `droid/` descriptors and driven through the official `@factory/droid-sdk` running in a forked Node worker pool. The legacy ACP bridge (`droidAcpPool.ts`) has been retired. | `droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts`, `droidModelsDiscovery.ts`; model helpers in `modelRegistry.ts`. |
 | `pi` | The user's own Pi installation, loaded as a library inside a forked Node worker (never a static import — the worker resolves the installation only after init validation). The worker owns the Pi agent session, its model runtime, its tool registry, and its sign-in; ADE owns the cards the session blocks on. | `piSdkPool.ts`, `piSdkWorker.ts`, `piSdkProtocol.ts`, `piSdkEventMapper.ts`, `piSdkUiBridge.ts`, `piSdkEnvironment.ts`; the shared native session store in `piSessionStore.ts` (resolving the tree, reading headers, authorizing files), `piSessionLease.ts` (the live-writer lock), and `piSessionOwnership.ts` (the durable ownership claim); installation and sign-in in `services/ai/piInstallation.ts` and `services/ai/piAuthService.ts`. |
 
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index a4f11b8819..2f97fe4640 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -2030,6 +2030,61 @@ configured yet — this list updates on refresh and when agents finish") rather
 than letting a stale list look current. A missing Cursor key renders a connect
 prompt linking Settings → AI connections instead of an empty list.
 
+## Devin Cloud fleet view
+
+Devin is a single provider id covering local (`devin acp` chats and the tracked
+`devin` CLI) and cloud (the session fleet); capability gates decide which parts
+light up per surface. Cloud auth is a pasted token in Settings → AI connections:
+a v3 Personal Access Token (`cog_`, primary — self-serve on every Devin account)
+or a legacy v1 personal key (`apk_user_`) for enterprises where PATs are
+admin-disabled. The org id is collected once and auto-discovered from the token
+when possible.
+
+The top bar and left sidebar carry auth-gated Devin quick-view buttons
+(`DevinCloudQuickViewButton`, mounted beside the Cursor quick-view). They render
+only while `devinCloudGetAuthStatus().configured` is true and open
+`DevinCloudFleetModal`, an org-wide fleet surface listing Devin sessions.
+Provenance chips (**Mine / From ADE / All**) sit beside the status and lane
+filters; ADE-created sessions carry `ade` + `ade:lane:` tags at launch and
+show a "via ADE" badge. Status derives once in `shared/devinCloudFleetStatus.ts`
+(archived → archived, error → error, exit/finished → finished, suspended →
+suspended, waiting_for_user/waiting_for_approval → needs_you, running →
+working, else starting), so the modal, rows, and the attention mapping cannot
+disagree. Devin has no live event feed: the list self-refreshes while the modal
+is open and on the manual refresh control, and the footer says so.
+
+Row actions: **Open live session** mirrors the session into an ADE chat
+(transcript via `GET /v3/organizations/{org}/sessions/{id}/messages`, sends via
+`POST .../messages`), **Live** opens `session.url` in ADE's built-in browser —
+the only live Desktop/VM view, since no provider exposes VM control over an API
+— **Stop** terminates, **Pull into lane…** merges a finished session's pushed
+branch into its owning/matching/new lane (same refusal rules as Cursor: dirty
+worktrees refused, conflicts abort and report), and the ⋯ menu offers
+Archive/Unarchive, Open PR, and Delete with confirmation. A successful pull
+offers **Continue in lane**, which launches the local `devin` CLI in that lane
+seeded with the session's task context — the reverse of handing off to cloud.
+
+The chat drawer's **Devin Cloud sessions** panel drafts a new session bound to
+the selected lane's repository: target repo and base branch come from the lane,
+**Agent mode** maps to `devin_mode` (Devin default / normal / fast / lite /
+ultra / fusion), and **Skip Devin's approval gate** sets `bypass_approval`.
+Sending from the composer launches the session with the provenance tags. From
+any non-cloud chat, the attach menu's **Hand off to Devin Cloud** packages the
+lane context into a prompt and launches the same path.
+
+Cloud sessions join ADE's attention system: `waiting_for_user` /
+`waiting_for_approval` raise a "Needs you" marker
+(`requestAttention`, provider_structured source) that clears when the session
+leaves that state or the user answers. Session attachments marked as Devin's own
+output (recordings, screenshots) are downloaded into the computer-use artifact
+store and ingested into the chat's proof drawer, deduped by attachment id.
+
+Known limits: cloud sessions run on Devin's VMs — no local file access and no
+API for screen/exec, so the deep-link live view is the interactive surface.
+There is no third-party OAuth for the REST API; the token paste is the only
+path (same as Cursor). The Devin CLI does not yet expose account
+Knowledge/Playbooks/Secrets to local sessions.
+
 ## Fragile and tricky wiring
 
 - **Draft launch job lifecycle.** `DraftLaunchJob` tracks multi-step

From 57ab006b74b31c40e2e1f093ea41e07cc914556d Mon Sep 17 00:00:00 2001
From: Arul Sharma 
Date: Thu, 17 Sep 2026 08:19:26 -0700
Subject: [PATCH 02/45] feat(devin): use Cognition mark for all Devin surfaces,
 drop sidebar fleet row, fix review findings

Problem
- Devin surfaces used a generic diamond icon instead of the Cognition mark,
  and the Devin fleet duplicated a sidebar row that lives only in the
  top header for Linear/Cursor.
- Devin Review flagged correctness/security issues: seconds-vs-ms
  timestamps re-sorted remote events, unparseable API bodies were
  treated as empty success, attachments downloaded without a size cap,
  cloud sends ran a runtime-backed readiness gate and stayed 'active',
  pull-into-lane imported a ref that was never fetched, and credential
  mutation was missing from the CTO-only action policy.

Change and boundary
- Swap every Devin glyph (top header button, fleet modal, cloud panel,
  chat header, composer menus, provider logos) to the Cognition mark via
  devin.svg; remove the sidebar 'Devin Cloud' nav row (header button
  only); tighten fleet modal to match the Cursor modal.
- Cloud client: parse ISO and seconds/ms epochs, throw
  DevinCloudResponseError on unparseable non-empty 2xx bodies, verify
  org id + record/items shape, cap attachment downloads at 50MB.
- Cloud sends: cloud-specific readiness (disposed/pending-input/
  in-flight), in-flight dedup set, idle transition on success/failure.
- Pull-into-lane: fetch refs/pull//head into refs/heads/
  before importBranch for new lanes; fetch into the target worktree so
  FETCH_HEAD resolves for existing lanes.
- Persisted-link lookup in openDevinCloudChat so reopened links reuse
  the original chat; attachment sync marks 'seen' only after ingest.
- Add 'devin-chat' toolType mappings; gate setDevinCloudCredentials as
  CTO-only; restore cursor-fleet default includeArchived behavior.

Verification
- npm --prefix apps/desktop run typecheck: clean.
- vitest ModelPicker.test.tsx: 73 passed.
- eslint on changed files: 0 errors.

Built with Devin (Cognition AI).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
 .../main/services/adeActions/actionPolicy.ts  |  2 +-
 .../src/main/services/adeActions/registry.ts  |  2 +-
 .../src/main/services/ai/devinCloudClient.ts  | 83 ++++++++++++++---
 .../main/services/chat/agentChatService.ts    | 58 +++++++++++-
 .../services/chat/devinCloudFleetService.ts   | 92 ++++++++++++-------
 .../renderer/assets/provider-logos/devin.svg  | 11 ++-
 .../components/app/DevinCloudFleetModal.tsx   | 15 +--
 .../app/DevinCloudQuickViewButton.tsx         | 29 ++----
 .../src/renderer/components/app/TabNav.tsx    |  2 -
 .../components/chat/AgentChatComposer.tsx     |  6 +-
 .../components/chat/AgentChatPane.tsx         |  5 +-
 .../components/chat/ChatDevinCloudPanel.tsx   |  5 +-
 .../shared/ModelPicker/ModelPicker.test.tsx   |  1 +
 13 files changed, 209 insertions(+), 102 deletions(-)

diff --git a/apps/desktop/src/main/services/adeActions/actionPolicy.ts b/apps/desktop/src/main/services/adeActions/actionPolicy.ts
index 40c5f71c0e..22acc8de39 100644
--- a/apps/desktop/src/main/services/adeActions/actionPolicy.ts
+++ b/apps/desktop/src/main/services/adeActions/actionPolicy.ts
@@ -82,7 +82,7 @@ export const ADE_ACTION_CTO_ONLY: Partial>
   // cancelScheduledCleanup can silently defeat a cleanup policy another
   // automation scheduled, so it is operator-only like the webhook lifecycle.
   automations: { only: ["setWebhookGatewayPublicUrl", "linearIngressSetup", "linearIngressTeardown", "cancelScheduledCleanup"] },
-  ai: { only: ["updateConfig", "storeApiKey", "deleteApiKey", "opencodeOAuthStart", "opencodeOAuthCancel", "setOpencodeProviderKey", "clearOpencodeProviderKey", "refreshModelsDev", "piLoginStart", "piLoginSubmit", "piLoginCancel", "cursorAuthLogin", "cursorAuthLogout", "cursorAuthCancel"] },
+  ai: { only: ["updateConfig", "storeApiKey", "deleteApiKey", "opencodeOAuthStart", "opencodeOAuthCancel", "setOpencodeProviderKey", "clearOpencodeProviderKey", "refreshModelsDev", "piLoginStart", "piLoginSubmit", "piLoginCancel", "cursorAuthLogin", "cursorAuthLogout", "cursorAuthCancel", "setDevinCloudCredentials"] },
   budget: { only: ["updateConfig"] },
   feedback: { only: ["submitPreparedDraft"] },
   // `applyAccountRollups` writes another machine's history into a
diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts
index c7b1d2fe18..1b85e8ccf9 100644
--- a/apps/desktop/src/main/services/adeActions/registry.ts
+++ b/apps/desktop/src/main/services/adeActions/registry.ts
@@ -2116,7 +2116,7 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null {
     },
     getCursorCloudFleet: (args?: { includeArchived?: boolean; limit?: number }) =>
       requireService(runtime.cursorCloudFleetService, "Cursor Cloud fleet not available.").getFleet({
-        includeArchived: args?.includeArchived === true,
+        includeArchived: args?.includeArchived !== false,
         ...(args?.limit !== undefined ? { limit: args.limit } : {}),
       }),
     resolveCursorCloudAgentLane: (args?: { agentId?: string }) =>
diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts
index 8035a212fd..a4aaa60cf0 100644
--- a/apps/desktop/src/main/services/ai/devinCloudClient.ts
+++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts
@@ -44,6 +44,7 @@ type FetchLike = (
   json: () => Promise;
   text: () => Promise;
   arrayBuffer?: () => Promise;
+  headers?: { get(name: string): string | null };
 }>;
 
 export type DevinCloudClientArgs = {
@@ -55,6 +56,9 @@ export type DevinCloudClientArgs = {
   timeoutMs?: number;
 };
 
+/** Proof sync buffers attachments in memory — refuse files past this cap. */
+export const DEVIN_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
+
 export class DevinCloudApiError extends Error {
   readonly status: number;
   readonly body: string;
@@ -80,12 +84,32 @@ function readNumber(value: unknown): number | null {
   if (typeof value === "string") {
     const parsed = Number(value);
     if (Number.isFinite(parsed)) return parsed;
-    const millis = Date.parse(value);
-    if (Number.isFinite(millis)) return Math.floor(millis / 1000);
   }
   return null;
 }
 
+/**
+ * Epoch-millisecond timestamp parser for created_at/updated_at fields. ISO
+ * strings parse to millis directly; bare numbers are treated as seconds when
+ * they are implausibly small for a millisecond reading.
+ */
+function readTimestamp(value: unknown): number | null {
+  if (typeof value === "string") {
+    const millis = Date.parse(value);
+    if (Number.isFinite(millis)) return millis;
+  }
+  const numeric = readNumber(value);
+  if (numeric == null) return null;
+  return numeric < 1e12 ? Math.round(numeric * 1000) : Math.round(numeric);
+}
+
+export class DevinCloudResponseError extends Error {
+  constructor(path: string) {
+    super(`Devin API returned an unreadable response for ${path}.`);
+    this.name = "DevinCloudResponseError";
+  }
+}
+
 /**
  * v1 personal keys carry the `apk_user_` (or plain `apk_`) prefix. Everything
  * else is treated as a v3 credential — PATs are `cog_`, but prefix sniffing
@@ -172,8 +196,8 @@ function normalizeV3Session(record: Record): DevinCloudSessionS
     repos: Array.isArray(record.repos)
       ? record.repos.filter((t): t is string => typeof t === "string")
       : [],
-    createdAt: readNumber(record.created_at),
-    updatedAt: readNumber(record.updated_at),
+    createdAt: readTimestamp(record.created_at),
+    updatedAt: readTimestamp(record.updated_at),
     devinMode: (mode ?? null) as DevinCloudMode | null,
     acusConsumed: readNumber(record.acus_consumed),
     userId: readString(record.user_id),
@@ -196,8 +220,8 @@ function normalizeV1SessionSummary(record: Record): DevinCloudS
     pullRequests: pr ? [{ prUrl: pr, prState: null }] : [],
     tags: Array.isArray(record.tags) ? record.tags.filter((t): t is string => typeof t === "string") : [],
     repos: [],
-    createdAt: readNumber(record.created_at),
-    updatedAt: readNumber(record.updated_at),
+    createdAt: readTimestamp(record.created_at),
+    updatedAt: readTimestamp(record.updated_at),
     devinMode: null,
     acusConsumed: null,
     userId: readString(record.requesting_user_email),
@@ -215,7 +239,7 @@ function normalizeV3Message(record: Record): DevinCloudMessage
     eventId,
     source: source === "user" ? "user" : "devin",
     message: record.message as string,
-    createdAt: readNumber(record.created_at) ?? 0,
+    createdAt: readTimestamp(record.created_at) ?? 0,
   };
 }
 
@@ -228,7 +252,7 @@ function normalizeV1Message(record: Record): DevinCloudMessage
     eventId,
     source: type.startsWith("user") || type === "initial_user_message" ? "user" : "devin",
     message,
-    createdAt: readNumber(record.timestamp) ?? 0,
+    createdAt: readTimestamp(record.timestamp) ?? 0,
   };
 }
 
@@ -288,7 +312,10 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) {
       try {
         return JSON.parse(text) as T;
       } catch {
-        return undefined as T;
+        // A 2xx body that is not JSON means a proxy or upstream answered in
+        // Devin's place — reporting it as an empty page would silently turn
+        // malformed responses into empty fleets and accepted credentials.
+        throw new DevinCloudResponseError(path);
       }
     } finally {
       clearTimeout(timer);
@@ -519,7 +546,22 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) {
         signal: controller.signal,
       });
       if (!response.ok || !response.arrayBuffer) return null;
+      const declared = Number(response.headers?.get("content-length") ?? "");
+      if (Number.isFinite(declared) && declared > DEVIN_ATTACHMENT_MAX_BYTES) {
+        args.logger?.warn?.("devin_cloud.attachment_too_large", {
+          attachmentId: attachment.attachmentId,
+          bytes: declared,
+        });
+        return null;
+      }
       const buffer = await response.arrayBuffer();
+      if (buffer.byteLength > DEVIN_ATTACHMENT_MAX_BYTES) {
+        args.logger?.warn?.("devin_cloud.attachment_too_large", {
+          attachmentId: attachment.attachmentId,
+          bytes: buffer.byteLength,
+        });
+        return null;
+      }
       return new Uint8Array(buffer);
     } catch {
       return null;
@@ -593,17 +635,28 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) {
   /** Verify the credential: v3 lists orgs, v1 lists one session page. */
   const verify = async (): Promise<{ orgName: string | null }> => {
     if (authMode === "v1") {
-      await request("/v1/sessions?limit=1");
+      const page = await request("/v1/sessions?limit=1");
+      if (!isRecord(page) || !Array.isArray(page.sessions)) {
+        throw new Error("Devin rejected this token — the sessions endpoint did not answer as expected.");
+      }
       return { orgName: null };
     }
     const page = await request(
       "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify({ first: 50 })),
     );
-    const items = isRecord(page) && Array.isArray(page.items) ? page.items : [];
-    const first = items.find(isRecord);
-    const name = first ? readString(first.org_name) ?? readString(first.name) : null;
-    const id = first ? readString(first.org_id) ?? readString(first.id) : null;
-    if (id && !cachedOrgId) cachedOrgId = id;
+    if (!isRecord(page) || !Array.isArray(page.items)) {
+      throw new Error("Devin rejected this token — the organizations endpoint did not answer as expected.");
+    }
+    const first = page.items.find(isRecord);
+    if (!first) {
+      throw new Error("This Devin token works but no organizations are visible to it.");
+    }
+    const name = readString(first.org_name) ?? readString(first.name);
+    const id = readString(first.org_id) ?? readString(first.id);
+    if (!id) {
+      throw new Error("Could not determine your Devin org. Add your org id (org-...) in Settings > Devin.");
+    }
+    if (!cachedOrgId) cachedOrgId = id;
     return { orgName: name };
   };
 
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index cf33df2329..ac64c30abb 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -5110,6 +5110,7 @@ const CHAT_SESSION_TOOL_TYPES = [
   "kimi-chat",
   "grok-chat",
   "copilot-chat",
+  "devin-chat",
 ] satisfies TerminalToolType[];
 type ChatSessionToolType = (typeof CHAT_SESSION_TOOL_TYPES)[number];
 
@@ -5139,6 +5140,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";
 }
 
@@ -5152,6 +5154,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";
 }
 
@@ -43168,6 +43171,8 @@ export function createAgentChatService(args: {
   const devinCloudSyncedAttachmentIds = new Map>();
   /** Sessions whose needs-you marker this mirror raised (so it can clear it without touching others'). */
   const devinCloudAttentionRaised = new Set();
+  /** ADE sessions with a Devin cloud REST send in flight — the busy flag a runtime-less chat cannot carry. */
+  const devinCloudSendInFlight = new Set();
 
   const forgetDevinCloudHydrationState = (sessionId: string): void => {
     devinCloudHydratedEventIds.delete(sessionId);
@@ -43238,9 +43243,14 @@ export function createAgentChatService(args: {
     for (const attachment of attachments) {
       if (attachment.source !== "devin") continue;
       if (seen.has(attachment.attachmentId)) continue;
-      seen.add(attachment.attachmentId);
       const extension = attachment.name.toLowerCase().split(".").pop() ?? "";
-      if (!DEVIN_PROOF_IMPORTABLE_EXTENSIONS.has(extension)) continue;
+      // Unsupported types never change — mark them seen immediately, but a
+      // supported type only counts as synced once ingest actually succeeds,
+      // so a transient download or write failure retries on the next tick.
+      if (!DEVIN_PROOF_IMPORTABLE_EXTENSIONS.has(extension)) {
+        seen.add(attachment.attachmentId);
+        continue;
+      }
       try {
         const bytes = await aiIntegrationService.downloadDevinCloudAttachment(attachment);
         if (!bytes?.length) continue;
@@ -43257,6 +43267,7 @@ export function createAgentChatService(args: {
           }],
           owners: [{ kind: "chat_session", id: managed.session.id }],
         });
+        seen.add(attachment.attachmentId);
       } catch (error) {
         logger.warn("agent_chat.devin_cloud_attachment_sync_failed", {
           sessionId: managed.session.id,
@@ -43528,6 +43539,29 @@ export function createAgentChatService(args: {
         }
       }
     }
+    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({
+          limit: 500,
+          toolTypes: CHAT_SESSION_TOOL_TYPES,
+        });
+        for (const row of rows) {
+          if (!isChatToolType(row.toolType)) continue;
+          const persisted = readPersistedState(row.id);
+          if (normalizeDevinSessionId(persisted?.devinSessionId ?? "") !== 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),
+        });
+      }
+    }
 
     const existedBefore = Boolean(managed);
     if (!managed) {
@@ -43593,8 +43627,15 @@ export function createAgentChatService(args: {
     if (!getDevinCloudApiKey()) {
       throw new Error("Devin Cloud requires a Devin API token. Add one in Settings > AI Providers or set DEVIN_API_KEY.");
     }
-    const validation = validateSessionReadyForTurn(managed);
-    if (!validation.ready) throw new Error(validation.reason);
+    // Cloud-linked chats carry no local runtime — sends go over REST — so the
+    // shared readiness gate would reject every turn on "No runtime
+    // initialized". The cloud check keeps the parts that still apply:
+    // disposal, pending input, and one send at a time.
+    if (managed.closed) throw new Error("Session is disposed");
+    if (hasLivePendingInput(managed)) throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE);
+    if (devinCloudSendInFlight.has(managed.session.id)) {
+      throw new Error("Turn already active");
+    }
 
     const turnId = args.turnId ?? randomUUID();
     const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText;
@@ -43615,6 +43656,7 @@ export function createAgentChatService(args: {
       turnStatus: "started",
       turnId,
     });
+    devinCloudSendInFlight.add(managed.session.id);
     try {
       await aiIntegrationService.sendDevinCloudMessage({
         devinSessionId,
@@ -43634,16 +43676,22 @@ export function createAgentChatService(args: {
         runtime: "cloud",
         terminalReason: error instanceof Error ? error.message : String(error),
       });
+      markSessionIdleWithFreshCache(managed);
       persistChatState(managed);
       throw error;
+    } finally {
+      devinCloudSendInFlight.delete(managed.session.id);
     }
     // The user_message emitted above already carries this text's fingerprint,
-    // so the next poll's copy of it dedupes silently.
+    // so the next poll's copy of it dedupes silently. The local turn ends at
+    // REST delivery — the remote turn's life is tracked by the mirror, not by
+    // this session's active flag.
     emitChatEvent(managed, {
       type: "status",
       turnStatus: "completed",
       turnId,
     });
+    markSessionIdleWithFreshCache(managed);
     persistChatState(managed);
   };
 
diff --git a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts
index 0a2d3e81fd..456f89d9e0 100644
--- a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts
+++ b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts
@@ -216,22 +216,16 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) {
     }
   };
 
-  const resolvePullTargetLane = async (args: {
+  const findPullTargetLane = async (args: {
     linkedLaneId: string | null;
     branch: string;
-  }): Promise<{ lane: LaneSummary; created: boolean }> => {
+  }): Promise => {
     const lanes = await deps.laneService.list({ includeArchived: false, includeStatus: false });
     if (args.linkedLaneId) {
       const linked = lanes.find((lane) => lane.id === args.linkedLaneId);
-      if (linked) return { lane: linked, created: false };
+      if (linked) return linked;
     }
-    const byBranch = lanes.find((lane) => (lane.branchRef ?? "").trim() === args.branch);
-    if (byBranch) return { lane: byBranch, created: false };
-    const created = await deps.laneService.importBranch({
-      branchRef: args.branch,
-      name: args.branch,
-    });
-    return { lane: created, created: true };
+    return lanes.find((lane) => (lane.branchRef ?? "").trim() === args.branch) ?? null;
   };
 
   const assertCleanWorktree = async (worktreePath: string, laneName: string): Promise => {
@@ -250,9 +244,11 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) {
    * Fetch a Devin session's PR head into a lane.
    *
    * Devin's API exposes `pull_requests[].pr_url` but never a branch name, so
-   * the branch arrives as a GitHub `refs/pull//head` fetch. For a new lane
-   * the fetch writes `refs/heads/devin/` directly; for an existing lane
-   * it lands on FETCH_HEAD and merges with the dirty-worktree refusal.
+   * the branch arrives as a GitHub `refs/pull//head` fetch. The fetch has
+   * to come first either way: for a new lane it materializes
+   * `refs/heads/devin/` so `importBranch` has a real ref to check out,
+   * and for an existing lane it lands on that lane's own FETCH_HEAD (each
+   * worktree keeps its own) before the dirty-worktree-guarded merge.
    */
   const pullIntoLane = async (devinSessionId: string): Promise => {
     const id = devinSessionId.trim();
@@ -274,35 +270,63 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) {
     const laneIdFromTag = devinCloudAdeLaneId(session.tags);
     const safeBranch = safeBranchRef(devinBranchFor(id));
 
-    const { lane, created } = await resolvePullTargetLane({
+    let lane = await findPullTargetLane({
       linkedLaneId: link?.laneId ?? laneIdFromTag,
       branch: safeBranch,
     });
+    let created = false;
 
-    await assertCleanWorktree(lane.worktreePath, lane.name);
+    if (!lane) {
+      // Materialize the PR head as a local branch first — importBranch only
+      // resolves refs that already exist.
+      const fetchResult = await runGit(
+        ["fetch", "origin", `+refs/pull/${prNumber}/head:refs/heads/${safeBranch}`],
+        { cwd: projectRoot, timeoutMs: 60_000 },
+      );
+      if (fetchResult.exitCode !== 0) {
+        throw new Error(
+          `Could not fetch the session's PR head (refs/pull/${prNumber}/head): ${fetchResult.stderr.trim() || "fetch failed"}`,
+        );
+      }
+      try {
+        lane = await deps.laneService.importBranch({
+          branchRef: safeBranch,
+          name: safeBranch,
+        });
+        created = true;
+      } catch (error) {
+        await runGit(["update-ref", "-d", `refs/heads/${safeBranch}`], {
+          cwd: projectRoot,
+          timeoutMs: 15_000,
+        }).catch(() => undefined);
+        throw error;
+      }
+    } else {
+      await assertCleanWorktree(lane.worktreePath, lane.name);
 
-    const fetchResult = await runGit(
-      ["fetch", "origin", `+refs/pull/${prNumber}/head`],
-      { cwd: projectRoot, timeoutMs: 60_000 },
-    );
-    if (fetchResult.exitCode !== 0) {
-      throw new Error(
-        `Could not fetch the session's PR head (refs/pull/${prNumber}/head): ${fetchResult.stderr.trim() || "fetch failed"}`,
+      const fetchResult = await runGit(
+        ["fetch", "origin", `+refs/pull/${prNumber}/head`],
+        { cwd: lane.worktreePath, timeoutMs: 60_000 },
       );
-    }
+      if (fetchResult.exitCode !== 0) {
+        throw new Error(
+          `Could not fetch the session's PR head (refs/pull/${prNumber}/head): ${fetchResult.stderr.trim() || "fetch failed"}`,
+        );
+      }
 
-    const mergeResult = await runGit(["merge", "--no-edit", "FETCH_HEAD"], {
-      cwd: lane.worktreePath,
-      timeoutMs: 60_000,
-    });
-    if (mergeResult.exitCode !== 0) {
-      await runGit(["merge", "--abort"], {
+      const mergeResult = await runGit(["merge", "--no-edit", "FETCH_HEAD"], {
         cwd: lane.worktreePath,
-        timeoutMs: 30_000,
-      }).catch(() => undefined);
-      throw new Error(
-        `Merging the session's PR head into '${lane.branchRef}' conflicted; the merge was aborted. Resolve it manually in the lane worktree.`,
-      );
+        timeoutMs: 60_000,
+      });
+      if (mergeResult.exitCode !== 0) {
+        await runGit(["merge", "--abort"], {
+          cwd: lane.worktreePath,
+          timeoutMs: 30_000,
+        }).catch(() => undefined);
+        throw new Error(
+          `Merging the session's PR head into '${lane.branchRef}' conflicted; the merge was aborted. Resolve it manually in the lane worktree.`,
+        );
+      }
     }
 
     let sessionId: string | null = null;
diff --git a/apps/desktop/src/renderer/assets/provider-logos/devin.svg b/apps/desktop/src/renderer/assets/provider-logos/devin.svg
index f9d5758c37..412081f055 100644
--- a/apps/desktop/src/renderer/assets/provider-logos/devin.svg
+++ b/apps/desktop/src/renderer/assets/provider-logos/devin.svg
@@ -1,5 +1,8 @@
-
-  
-  
-  
+
+  
+  
+    
+    
+    
+  
 
diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx
index defd9fc41f..40b173e2fc 100644
--- a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx
+++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx
@@ -15,7 +15,6 @@ import type {
 import devinMark from "../../assets/provider-logos/devin.svg";
 import { openExternalUrl } from "../../lib/openExternal";
 import {
-  DEVIN_BLUE,
   devinCloudErrorMessage,
   devinCloudRepoLabel,
   formatDevinCloudAge,
@@ -394,12 +393,7 @@ export function DevinCloudFleetModal({
         {/* Header */}
         
- - - +
Devin Cloud
@@ -521,12 +515,7 @@ export function DevinCloudFleetModal({
) : entries.length === 0 ? (
- - - +
No Devin sessions
Sessions you launch from a chat composer with Devin Cloud — and anything diff --git a/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx index 39668d3158..0a3f861c41 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudQuickViewButton.tsx @@ -1,14 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { Diamond } from "@phosphor-icons/react"; import type { DevinCloudAuthStatus } from "../../../shared/types"; +import devinMark from "../../assets/provider-logos/devin.svg"; import { useAppStore } from "../../state/appStore"; import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, } from "../../lib/workSidebarBrowserResize"; -import { DEVIN_BLUE } from "../../lib/devinCloudUtils"; import { DevinCloudFleetModal } from "./DevinCloudFleetModal"; // Keep the entry point on the same visibility cadence as Linear and Cursor. @@ -78,7 +77,7 @@ export function DevinCloudQuickViewButton({ variant = "icon", onMenuActivate, }: { - variant?: "icon" | "menu-row" | "sidebar-row"; + variant?: "icon" | "menu-row"; onMenuActivate?: () => void; } = {}) { const project = useAppStore((s) => s.project); @@ -210,13 +209,14 @@ export function DevinCloudQuickViewButton({ onMenuActivate?.(); }; - const iconSize = variant === "menu-row" ? 12 : variant === "sidebar-row" ? 15 : 12; + const iconSize = variant === "menu-row" ? 12 : 13; const icon = ( - ); @@ -233,22 +233,13 @@ export function DevinCloudQuickViewButton({ data-state={open ? "open" : undefined} className={variant === "menu-row" ? HEADER_STATUS_MENU_ROW_CLASS - : variant === "sidebar-row" - ? `ade-shell-sidebar-item group relative flex w-full items-center transition-colors duration-100${open ? " bg-white/[0.08]" : ""}` - : "ade-shell-control relative inline-flex h-[20px] w-[20px] items-center justify-center transition-[background-color,color,border-color,box-shadow] duration-150"} + : "ade-shell-control relative inline-flex h-[20px] w-[20px] items-center justify-center transition-[background-color,color,border-color,box-shadow] duration-150"} style={{ WebkitAppRegion: "no-drag", - color: open ? "#93C5FD" : undefined, } as React.CSSProperties} onClick={handleToggle} > - {variant === "sidebar-row" ? ( - - {icon} - - ) : ( - icon - )} + {icon} {variant !== "icon" ? Devin Cloud : null} {open ? createPortal( diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 3bd31a4f74..aca2c60999 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -34,7 +34,6 @@ import type { GitHubStatus } from "../../../shared/types"; import { readStoredPrsRoute } from "../prs/prsRouteState"; import { readStoredProjectSettingsRoute } from "./projectRouteStorage"; import { CursorCloudQuickViewButton } from "./CursorCloudQuickViewButton"; -import { DevinCloudQuickViewButton } from "./DevinCloudQuickViewButton"; type TabNavItem = { to: string; @@ -374,7 +373,6 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) {/* The fleet entries own the same delayed, cached auth gate as the top-bar controls, so a disconnected integration leaves no dead sidebar affordance. */} - {/* Spacer pushes settings to bottom */} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 4ede8936fc..ae030b821c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, Diamond, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, DeviceMobile, DotsThree, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -130,7 +130,7 @@ import { hasChatOutputContext } from "../../../shared/chatOutputContext"; import { hydrateChatOutputContextChipsInEditor } from "./composerChatOutputContext"; import { SmartTooltip } from "../ui/SmartTooltip"; import { VoiceDictationButton } from "./VoiceDictationButton"; -import { ProviderLogo } from "../shared/ProviderLogos"; +import { ProviderLogo, DevinLogo } from "../shared/ProviderLogos"; import { pendingInputHeaderLabel, providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { useAppStore, useRootAppStore, rootAppStoreApi } from "../../state/appStore"; import { useVoiceModelInstalled } from "../../hooks/useVoiceModelInstalled"; @@ -6123,7 +6123,7 @@ export function AgentChatComposer({ ? [{ id: "devin-cloud-panel", label: devinCloudPaneOpen ? "Close Devin Cloud sessions" : "Open Devin Cloud sessions", - icon: , + icon: , active: devinCloudPaneOpen, onSelect: onToggleDevinCloudPanel, }] diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 81531f88e4..60ab37cbe6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { ArrowLeft, CaretRight, CircleNotch, CloudArrowUp, Cube, Desktop, DeviceMobile, Diamond, ArrowBendUpRight, DownloadSimple, GitFork, Lightning, Plus, Terminal, TreeStructure, X, type Icon } from "@phosphor-icons/react"; +import { ArrowLeft, CaretRight, CircleNotch, CloudArrowUp, Cube, Desktop, DeviceMobile, ArrowBendUpRight, DownloadSimple, GitFork, Lightning, Plus, Terminal, TreeStructure, X, type Icon } from "@phosphor-icons/react"; import { inferAttachmentType, mergeAttachments, @@ -217,6 +217,7 @@ import { derivePendingInputRequests, resolvePendingInputs, type DerivedPendingIn import { AskQuestionComposer } from "./AskQuestionComposer"; import { findUserMessageForTurn, isParentUserMessage, resolveTurnActive } from "./chatTurnState"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { DevinLogo } from "../shared/ProviderLogos"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { isCodexMemoryResetDraft } from "../../../shared/codexComposerCommands"; @@ -13483,7 +13484,7 @@ export function AgentChatPane({ ); }} > - + Devin Cloud ) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx index 7720b0ae3d..25fb21f8f2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatDevinCloudPanel.tsx @@ -2,7 +2,6 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { ArrowSquareOut, ArrowsClockwise, - CloudArrowUp, Desktop, } from "@phosphor-icons/react"; @@ -14,7 +13,6 @@ import type { } from "../../../shared/types"; import { navigateUrlInAdeBrowser, openExternalUrl } from "../../lib/openExternal"; import { - DEVIN_BLUE, devinCloudErrorMessage, devinCloudModeLabel, devinCloudRepoLabel, @@ -23,6 +21,7 @@ import { repoMatchKey, } from "../../lib/devinCloudUtils"; import { cn } from "../ui/cn"; +import { DevinLogo } from "../shared/ProviderLogos"; import { SmartTooltip } from "../ui/SmartTooltip"; const TERMINAL_STATUSES: ReadonlySet = new Set([ @@ -209,7 +208,7 @@ export const ChatDevinCloudPanel = forwardRef
- + Devin Cloud sessions {refreshing ? ( diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index dd9b6600c7..8acc39efcb 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -2236,6 +2236,7 @@ describe("ModelPicker", () => { "provider:anthropic", "provider:openai", "provider:cursor", + "provider:devin", "provider:opencode", "provider:pi", "provider:github-copilot", From aa91065b577b79e34bcc19a2adfd3ea07b3ef007 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Thu, 17 Sep 2026 08:24:02 -0700 Subject: [PATCH 03/45] ci(typecheck-desktop): raise Node heap to 8GB for desktop tsc Problem - typecheck-desktop OOM'd on this branch (tsc exited 134 after ~110s of GC thrashing at the default ~4GB heap). Change and boundary - NODE_OPTIONS=--max-old-space-size=8192 on the desktop typecheck step only; matches the repo's own precedent (the lint script already runs with an 8GB heap). Other typecheck jobs are unchanged. Built with Devin (Cognition AI). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97da7b7c6b..74f369d7ce 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 From a62ccdcdb5138b0b079e5818ae1844c3932b0d0b Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Thu, 17 Sep 2026 08:34:13 -0700 Subject: [PATCH 04/45] =?UTF-8?q?fix(devin):=20second-round=20review=20fix?= =?UTF-8?q?es=20=E2=80=94=20pagination,=20pull=20repo=20check,=20streaming?= =?UTF-8?q?=20cap,=20attention=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem - The cloud mirror only read the first page of messages (transcripts stop at 200), emitted 'done' while status was TTL-unknown, cleared attention markers owned by newer sources, and wrote remote attachment names to disk unsanitized. - pullIntoLane merged the project's same-numbered PR when a session's PR belonged to another repo; attachment downloads still buffered the full body when Content-Length was absent; a cleared devinCloudOrgId was resurrected from shared config. - The launch shelf's Devin Cloud machine row showed the generic violet cloud icon instead of the Cognition mark. Change and boundary - Follow listMessages endCursor (repeated-cursor guard) so mirrored transcripts pass 200 messages. - Gate the completion 'done' on a known-terminal status; refresh the remote record once when fresh output arrives with status unknown. - clearAttentionRequest gains an optional expectedSource; the mirror clears only provider_structured markers. - Attachment filenames are reduced to their basename before writing. - pullIntoLane compares the PR URL's repo to the project origin before fetching. - downloadAttachment streams the body with the 50MB cap enforced mid-read; Content-Length is only an early-out. - coerceAiConfig preserves explicit null devinCloudOrgId. - DraftMachineOption gains cloudProvider so the Devin Cloud row renders DevinLogo; cursor row tagged too. Verification - npm --prefix apps/desktop run typecheck: clean. - vitest DraftMachinePicker.test.tsx: 4 passed. - eslint on changed files: 0 errors. Built with Devin (Cognition AI). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/main/services/ai/devinCloudClient.ts | 43 ++++++++++++++++++- .../main/services/chat/agentChatService.ts | 35 +++++++++++++-- .../services/chat/devinCloudFleetService.ts | 18 +++++++- .../services/config/projectConfigService.ts | 3 ++ .../main/services/sessions/sessionService.ts | 14 +++++- .../components/chat/AgentChatPane.tsx | 2 + .../components/chat/DraftMachinePicker.tsx | 6 +++ 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index a4aaa60cf0..dc91bef6dd 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -45,6 +45,13 @@ type FetchLike = ( text: () => Promise; arrayBuffer?: () => Promise; headers?: { get(name: string): string | null }; + body?: { + getReader(): { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock?(): void; + }; + } | null; }>; export type DevinCloudClientArgs = { @@ -545,7 +552,7 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { }, signal: controller.signal, }); - if (!response.ok || !response.arrayBuffer) return null; + if (!response.ok) return null; const declared = Number(response.headers?.get("content-length") ?? ""); if (Number.isFinite(declared) && declared > DEVIN_ATTACHMENT_MAX_BYTES) { args.logger?.warn?.("devin_cloud.attachment_too_large", { @@ -554,6 +561,40 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { }); return null; } + // Stream the body and abort at the cap — Content-Length can be absent + // or wrong, so buffering first would let an oversized payload through. + const reader = response.body?.getReader?.(); + if (reader) { + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value?.length) continue; + total += value.length; + if (total > DEVIN_ATTACHMENT_MAX_BYTES) { + await reader.cancel().catch(() => undefined); + args.logger?.warn?.("devin_cloud.attachment_too_large", { + attachmentId: attachment.attachmentId, + bytes: total, + }); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; + } + if (!response.arrayBuffer) return null; const buffer = await response.arrayBuffer(); if (buffer.byteLength > DEVIN_ATTACHMENT_MAX_BYTES) { args.logger?.warn?.("devin_cloud.attachment_too_large", { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ac64c30abb..0d10e68eea 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -43254,7 +43254,11 @@ export function createAgentChatService(args: { try { const bytes = await aiIntegrationService.downloadDevinCloudAttachment(attachment); if (!bytes?.length) continue; - const filePath = createComputerUseArtifactPath(projectRoot, `devin-${attachment.name}`, extension); + // Attachment names are remote-controlled — drop any path segments so + // a crafted name cannot redirect the write outside the artifact dir. + const baseName = attachment.name.split(/[\\/]/).pop() ?? ""; + const safeName = baseName && baseName !== "." && baseName !== ".." ? baseName : "attachment"; + const filePath = createComputerUseArtifactPath(projectRoot, `devin-${safeName}`, extension); fs.writeFileSync(filePath, bytes); broker.ingest({ backend: { name: "devin", style: "external_cli" }, @@ -43357,6 +43361,20 @@ export function createAgentChatService(args: { first: 200, }); items = page.items; + // Follow the cursor: without it a session past one page replays the + // first 200 rows on every poll and newer output never arrives. + const seenCursors = new Set(); + let cursor = page.endCursor?.trim() ?? ""; + while (cursor && !seenCursors.has(cursor)) { + seenCursors.add(cursor); + const nextPage = await aiIntegrationService.listDevinCloudMessages({ + devinSessionId, + first: 200, + after: cursor, + }); + items.push(...nextPage.items); + cursor = nextPage.endCursor?.trim() ?? ""; + } } catch (error) { logger.warn("agent_chat.devin_cloud_messages_failed", { sessionId: managed.session.id, @@ -43392,8 +43410,10 @@ export function createAgentChatService(args: { ); } else if (!needsYou && devinCloudAttentionRaised.delete(managed.session.id)) { // Only clear the marker this mirror raised — a user- or - // runtime-raised attention belongs to whoever raised it. - sessionService.clearAttentionRequest(managed.session.id); + // runtime-raised attention belongs to whoever raised it. The + // conditional clear also protects a newer provider_structured + // request another source wrote after this mirror's. + sessionService.clearAttentionRequest(managed.session.id, "provider_structured"); } } @@ -43435,7 +43455,14 @@ export function createAgentChatService(args: { if (emittedVisible) { flushBufferedReasoning(managed); flushBufferedText(managed); - if (!isDevinCloudSessionLive(liveStatus) && !devinCloudDoneAnnounced.has(managed.session.id)) { + if (liveStatus == null) { + // A TTL-skipped status read is not evidence of a terminal state — + // check once when fresh output arrived before deciding the turn + // ended, or a still-running session would emit `done` early. + remote = remote ?? await aiIntegrationService.getDevinCloudSession(devinSessionId).catch(() => null); + liveStatus = remote?.status ?? null; + } + if (liveStatus != null && !isDevinCloudSessionLive(liveStatus) && !devinCloudDoneAnnounced.has(managed.session.id)) { devinCloudDoneAnnounced.add(managed.session.id); emitChatEvent(managed, { type: "done", diff --git a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts index 456f89d9e0..927fff148a 100644 --- a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts +++ b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts @@ -53,6 +53,13 @@ function githubPullNumber(prUrl: string | null): string | null { return match ? match[1] : null; } +/** `https://github.com/o/r/pull/123` → "https://github.com/o/r"; anything else → null. */ +function githubPullRepo(prUrl: string | null): string | null { + if (!prUrl) return null; + const match = /(github\.com\/[^/]+\/[^/]+)\/pull\/\d+/i.exec(prUrl.trim()); + return match ? `https://${match[1]}` : null; +} + /** * Guard a remote-reported ref before it reaches git argv or importBranch. * A leading `-` would be parsed as an option by git (classic option @@ -258,13 +265,22 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) { if (!session) throw new Error("Could not find this Devin session in your org."); if (session.isArchived) throw new Error("Unarchive this session before pulling it into a lane."); - const prNumber = githubPullNumber(session.pullRequests[0]?.prUrl ?? null); + const prUrl = session.pullRequests[0]?.prUrl ?? null; + const prNumber = githubPullNumber(prUrl); if (!prNumber) { throw new Error( "This session has not opened a GitHub pull request yet, so there is nothing to pull.", ); } + // PR numbers are repo-local and the fleet is org-wide: a session from a + // different repository must not merge this project's same-numbered PR. + const prRepoKey = repoMatchKey(githubPullRepo(prUrl)); + const projectRepoKey = await originMatchKey(); + if (!prRepoKey || !projectRepoKey || prRepoKey !== projectRepoKey) { + throw new Error("This session's pull request is not for this project's repository."); + } + const links = await deps.listDevinCloudSessionLinks().catch(() => [] as SessionLink[]); const link = links.find((entry) => entry.devinSessionId === id) ?? null; const laneIdFromTag = devinCloudAdeLaneId(session.tags); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 72d3a16245..f99d5145bd 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1681,6 +1681,9 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const devinCloudOrgId = asString(value.devinCloudOrgId)?.trim(); if (devinCloudOrgId) out.devinCloudOrgId = devinCloudOrgId; + // Explicit null is a reset — keep it so the merge doesn't resurrect the + // shared org value the user just cleared. + else if (value.devinCloudOrgId === null) out.devinCloudOrgId = null; const localProviders = coerceAiLocalProviders(value.localProviders); if (localProviders) out.localProviders = localProviders; diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 7da57070da..9ff48f5b31 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -2185,8 +2185,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/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 60ab37cbe6..53419a2ad3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -12389,6 +12389,7 @@ export function AgentChatPane({ id: CURSOR_CLOUD_MACHINE_ID, name: "Cursor Cloud", kind: "cloud" as const, + cloudProvider: "cursor" as const, unavailableReason: cloudUnavailableReason, }] : []), @@ -12397,6 +12398,7 @@ export function AgentChatPane({ id: DEVIN_CLOUD_MACHINE_ID, name: "Devin Cloud", kind: "cloud" as const, + cloudProvider: "devin" as const, unavailableReason: devinUnavailableReason, }] : []), diff --git a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx index 62d58bf81a..3d50d6b94a 100644 --- a/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx +++ b/apps/desktop/src/renderer/components/chat/DraftMachinePicker.tsx @@ -2,6 +2,7 @@ import { CaretDown, Check, CloudArrowUp, DesktopTower } from "@phosphor-icons/re import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { DevinLogo } from "../shared/ProviderLogos"; import { cn } from "../ui/cn"; import { SmartTooltip } from "../ui/SmartTooltip"; import { @@ -18,6 +19,8 @@ export type DraftMachineOption = { * this list because "where does this run" is one question, not two. */ kind?: "machine" | "cloud"; + /** Which provider's mark a cloud entry wears; generic cloud icon when unset. */ + cloudProvider?: "cursor" | "devin"; /** Set to render the row disabled with this sentence as its tooltip. */ unavailableReason?: string | null; }; @@ -26,6 +29,9 @@ const MENU_WIDTH = 220; const CLOUD_VIOLET = "#A78BFA"; function machineIcon(option: DraftMachineOption) { + if (option.kind === "cloud" && option.cloudProvider === "devin") { + return ; + } return option.kind === "cloud" ? ( ) : ( From 4383c661262d2200afc7b6f683f65652a6c02538 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 20 Sep 2026 23:23:39 -0700 Subject: [PATCH 05/45] fix(devin): keep cloud-turn completion pending until provably terminal; poll only the transcript tail Problem - A Devin cloud chat whose output hydrated while the session-status read failed never emitted 'done': later polls deduplicated the transcript, so completion handling never ran again. - Every mirror poll re-downloaded the whole transcript (all pages), so API load grew with history instead of with new output. Change and boundary - The mirror now holds the hydrate turn id in a pending-done map when output arrives without a provable terminal status; each later poll re-checks the remote record and emits 'done' with that turn id the first time the session reads terminal. - A per-session tail cursor checkpoints the last messages page, so steady-state polls request only rows after it. A failed fetch on a checkpoint retries once from scratch (covers cursor invalidation); in-memory only, so a host restart re-pulls once and dedupe hides it. - Adds the devin spec to providerKeySpecs/AddApiKeySheet (merge fallout) and pins @cursor/sdk 1.0.31 node_modules per upstream. Verification - npm --prefix apps/desktop run typecheck: clean. - npm --prefix apps/ade-cli run typecheck: clean. Built with Devin (Cognition AI). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/package-lock.json | 9 -- .../main/services/chat/agentChatService.ts | 98 +++++++++++++------ .../providers/keys/AddApiKeySheet.test.tsx | 1 + .../providers/keys/providerKeySpecs.ts | 13 +++ 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 93c59b550e..a53fe11be3 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -16845,9 +16845,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -16884,9 +16881,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -16899,9 +16893,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1bfd2faa05..e8ed1136f4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -44942,6 +44942,10 @@ export function createAgentChatService(args: { const devinCloudEmptyReads = new Map(); const devinCloudPlaceholderNameReads = new Map(); const devinCloudDoneAnnounced = new Set(); + /** Last seen message page cursor per ADE session — later polls only fetch the tail. */ + const devinCloudMessagesTailCursor = new Map(); + /** Hydrate turn id that emitted output but never got a provable terminal status, per ADE session. */ + const devinCloudPendingDoneTurn = new Map(); /** Attachment ids already filed into the proof drawer, per ADE session. */ const devinCloudSyncedAttachmentIds = new Map>(); /** Sessions whose needs-you marker this mirror raised (so it can clear it without touching others'). */ @@ -44955,6 +44959,8 @@ export function createAgentChatService(args: { devinCloudEmptyReads.delete(sessionId); devinCloudPlaceholderNameReads.delete(sessionId); devinCloudDoneAnnounced.delete(sessionId); + devinCloudMessagesTailCursor.delete(sessionId); + devinCloudPendingDoneTurn.delete(sessionId); devinCloudSyncedAttachmentIds.delete(sessionId); devinCloudAttentionRaised.delete(sessionId); }; @@ -44966,6 +44972,8 @@ export function createAgentChatService(args: { devinCloudEmptyReads.clear(); devinCloudPlaceholderNameReads.clear(); devinCloudDoneAnnounced.clear(); + devinCloudMessagesTailCursor.clear(); + devinCloudPendingDoneTurn.clear(); devinCloudSyncedAttachmentIds.clear(); devinCloudAttentionRaised.clear(); }; @@ -45131,24 +45139,37 @@ export function createAgentChatService(args: { let liveStatus = remote?.status ?? null; for (let attempt = 0; attempt < DEVIN_CLOUD_MESSAGES_RETRY_ATTEMPTS; attempt += 1) { try { - const page = await aiIntegrationService.listDevinCloudMessages({ - devinSessionId, - first: 200, - }); - items = page.items; - // Follow the cursor: without it a session past one page replays the - // first 200 rows on every poll and newer output never arrives. - const seenCursors = new Set(); - let cursor = page.endCursor?.trim() ?? ""; - while (cursor && !seenCursors.has(cursor)) { - seenCursors.add(cursor); - const nextPage = await aiIntegrationService.listDevinCloudMessages({ - devinSessionId, - first: 200, - after: cursor, - }); - items.push(...nextPage.items); - cursor = nextPage.endCursor?.trim() ?? ""; + // The tail cursor makes steady-state polls fetch only new output. + // First hydration (or a restarted host) has none and walks every + // page; dedupe in hydrate keeps that replay invisible. + const fetchDevinCloudMessages = async (): Promise => { + const fetched: DevinCloudMessage[] = []; + const seenCursors = new Set(); + let cursor = devinCloudMessagesTailCursor.get(managed.session.id) ?? ""; + let tail = cursor; + while (true) { + const page = await aiIntegrationService.listDevinCloudMessages({ + devinSessionId, + first: 200, + ...(cursor ? { after: cursor } : {}), + }); + fetched.push(...page.items); + const next = page.endCursor?.trim() ?? ""; + if (!next || seenCursors.has(next)) break; + seenCursors.add(next); + tail = next; + cursor = next; + } + if (tail) devinCloudMessagesTailCursor.set(managed.session.id, tail); + return fetched; + }; + try { + items = await fetchDevinCloudMessages(); + } catch (error) { + // A stale/invalidated checkpoint is retryable from scratch once; + // a first-page failure stays on the warn path. + if (!devinCloudMessagesTailCursor.delete(managed.session.id)) throw error; + items = await fetchDevinCloudMessages(); } } catch (error) { logger.warn("agent_chat.devin_cloud_messages_failed", { @@ -45237,17 +45258,38 @@ export function createAgentChatService(args: { remote = remote ?? await aiIntegrationService.getDevinCloudSession(devinSessionId).catch(() => null); liveStatus = remote?.status ?? null; } - if (liveStatus != null && !isDevinCloudSessionLive(liveStatus) && !devinCloudDoneAnnounced.has(managed.session.id)) { - devinCloudDoneAnnounced.add(managed.session.id); - emitChatEvent(managed, { - type: "done", - turnId: hydrateTurnId, - status: "completed", - runtime: "cloud", - ...(managed.session.model ? { model: managed.session.model } : {}), - ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), - }); + } + + // Completion bookkeeping runs outside `emittedVisible`: once output is + // mirrored and deduped, a later poll sees no new messages but still owns + // the pending turn's `done`. + if (!devinCloudDoneAnnounced.has(managed.session.id)) { + const pendingDoneTurnId = devinCloudPendingDoneTurn.get(managed.session.id); + if (emittedVisible || pendingDoneTurnId) { + if (liveStatus == null) { + remote = remote ?? await aiIntegrationService.getDevinCloudSession(devinSessionId).catch(() => null); + liveStatus = remote?.status ?? null; + } + if (liveStatus != null && !isDevinCloudSessionLive(liveStatus)) { + devinCloudDoneAnnounced.add(managed.session.id); + devinCloudPendingDoneTurn.delete(managed.session.id); + emitChatEvent(managed, { + type: "done", + turnId: pendingDoneTurnId ?? hydrateTurnId, + status: "completed", + runtime: "cloud", + ...(managed.session.model ? { model: managed.session.model } : {}), + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + persistChatState(managed); + } else if (emittedVisible) { + // Output arrived without provable termination (still live, or the + // status read failed) — hold the turn so `done` survives dedup. + devinCloudPendingDoneTurn.set(managed.session.id, hydrateTurnId); + } } + } + if (emittedVisible) { persistChatState(managed); } return emittedVisible; diff --git a/apps/desktop/src/renderer/components/settings/providers/keys/AddApiKeySheet.test.tsx b/apps/desktop/src/renderer/components/settings/providers/keys/AddApiKeySheet.test.tsx index 4e968f1b98..1d8eaee991 100644 --- a/apps/desktop/src/renderer/components/settings/providers/keys/AddApiKeySheet.test.tsx +++ b/apps/desktop/src/renderer/components/settings/providers/keys/AddApiKeySheet.test.tsx @@ -39,6 +39,7 @@ const EXPECTED: Record { diff --git a/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts b/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts index db795cb959..e2cba2e25f 100644 --- a/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts +++ b/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts @@ -220,6 +220,19 @@ const SPECS: Record = { legacyDefaultSlot: false, note: null, }, + devin: { + provider: "devin", + credentialProvider: "devin", + keyEnvVar: "WINDSURF_API_KEY", + keyHelp: "A Windsurf API key. The Devin CLI signs in with it when `devin auth login` has not run.", + endpoint: null, + protocol: false, + models: false, + providerId: false, + verifiable: false, + legacyDefaultSlot: false, + note: "Devin's browser sign-in (`devin auth login`) covers every account and needs no key. Only headless setups need WINDSURF_API_KEY.", + }, }; export function providerKeySpec(provider: SettingsProviderId): ProviderKeySpec { From e8abd68186222249a2b74c2662d86852781e67c3 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 20 Sep 2026 23:36:41 -0700 Subject: [PATCH 06/45] fix(devin): wire cloud commands into the remote router; address review findings - register Devin fleet/auth/chat remote commands so mobile, web, and relay clients get the same surface as desktop, with Cursor-parity viewer vs controller gating - reset cloud-turn completion state on each new send so follow-up turns still emit done - require an explicit org id when a PAT exposes multiple orgs instead of silently defaulting to the first - normalize ports and the ssh.github.com alias in repoMatchKey so valid SSH origins pass the pull-into-lane repo guard (covers Cursor too) - file the Windsurf CLI key under devin-cli so it cannot overwrite the cloud PAT stored under devin - scope the provider-key-spec credential test to harness providers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/services/sync/syncHostService.ts | 3 + .../services/sync/syncRemoteCommandService.ts | 96 +++++++++++++++++++ apps/ade-cli/src/services/sync/syncService.ts | 2 + .../src/main/services/ai/devinCloudClient.ts | 4 +- .../main/services/chat/agentChatService.ts | 4 + .../services/chat/harnessPresetLaunch.test.ts | 2 + .../providers/keys/providerKeySpecs.ts | 5 +- .../src/shared/cursorCloudRepoMatch.test.ts | 32 +++++++ .../src/shared/cursorCloudRepoMatch.ts | 8 +- 9 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/shared/cursorCloudRepoMatch.test.ts diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 4419b5eea5..06c610f748 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -136,6 +136,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 { createDevinCloudFleetService } from "../../../../desktop/src/main/services/chat/devinCloudFleetService"; 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"; @@ -1133,6 +1134,7 @@ type SyncHostServiceArgs = { ptyService: ReturnType; agentChatService?: ReturnType; cursorCloudFleetService?: ReturnType | null; + devinCloudFleetService?: ReturnType | null; personalChatScope?: Pick< PersonalChatScopeContract, "call" | "streamEvents" | "transcriptPath" | "isTurnActive" @@ -2254,6 +2256,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { operationService: args.operationService, agentChatService: args.agentChatService, cursorCloudFleetService: args.cursorCloudFleetService, + devinCloudFleetService: args.devinCloudFleetService, 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 4e200fb082..ff2d393e70 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -258,6 +258,8 @@ 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 { createDevinCloudFleetService } from "../../../../desktop/src/main/services/chat/devinCloudFleetService"; +import type { DevinCloudMode } from "../../../../desktop/src/shared/types/config"; import { resolveSmartLinkPreview } from "../../../../desktop/src/main/services/chat/smartLinkPreviewService"; import { createPromptStash, @@ -370,6 +372,7 @@ type SyncRemoteCommandServiceArgs = { aiIntegrationService?: ReturnType | null; agentChatService?: ReturnType; cursorCloudFleetService?: ReturnType | null; + devinCloudFleetService?: ReturnType | null; personalChatScope?: Pick; ctoStateService?: ReturnType | null; ctoMemoryService?: CtoMemoryService | null; @@ -530,6 +533,13 @@ function asOptionalCursorCloudServiceTier(value: unknown): "fast" | "standard" | throw new Error("Cursor Cloud serviceTier must be 'fast', 'standard', null, or omitted."); } +function asOptionalDevinCloudMode(value: unknown): DevinCloudMode | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (value === "normal" || value === "fast" || value === "lite" || value === "ultra" || value === "fusion") return value; + throw new Error("Devin Cloud devinMode must be 'normal', 'fast', 'lite', 'ultra', 'fusion', null, or omitted."); +} + function asOptionalNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -5913,6 +5923,92 @@ function registerMiscRemoteCommands({ args, register }: RemoteCommandRegistratio requireString(payload.agentId, "ai.cursorCloudStopRun requires agentId."), ); }); + + register("ai.getDevinCloudAuthStatus", { viewerAllowed: true }, async () => + requireService(args.aiIntegrationService, "AI integration service not available.").getDevinCloudAuthStatus()); + register("ai.setDevinCloudCredentials", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + const status = await requireService(args.aiIntegrationService, "AI integration service not available.").setDevinCloudCredentials({ + apiKey: requireString(payload.apiKey, "ai.setDevinCloudCredentials requires apiKey."), + ...(typeof payload.orgId === "string" ? { orgId: payload.orgId } : {}), + }); + args.devinCloudFleetService?.invalidateCache(); + return status; + }); + register("ai.getDevinCloudFleet", { viewerAllowed: true }, async (payload) => { + const fleetService = requireService(args.devinCloudFleetService, "Devin Cloud fleet not available."); + return fleetService.getFleet({ + force: payload.force === true, + includeArchived: payload.includeArchived === true, + }); + }); + // Pull mutates host lane worktrees (fetch + merge or lane import), so like the + // Cursor equivalent it is refused for read-only viewers and runs immediately. + register("ai.pullDevinCloudSessionIntoLane", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => + requireService(args.devinCloudFleetService, "Devin Cloud fleet not available.").pullIntoLane( + requireString(payload.devinSessionId, "ai.pullDevinCloudSessionIntoLane requires devinSessionId."), + )); + register("ai.terminateDevinCloudSession", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.aiIntegrationService, "AI integration service not available.").terminateDevinCloudSession({ + devinSessionId: requireString(payload.devinSessionId, "ai.terminateDevinCloudSession requires devinSessionId."), + ...(typeof payload.archive === "boolean" ? { archive: payload.archive } : {}), + }); + args.devinCloudFleetService?.invalidateCache(); + }); + register("ai.archiveDevinCloudSession", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.aiIntegrationService, "AI integration service not available.").archiveDevinCloudSession( + requireString(payload.devinSessionId, "ai.archiveDevinCloudSession requires devinSessionId."), + ); + args.devinCloudFleetService?.invalidateCache(); + }); + register("ai.unarchiveDevinCloudSession", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.aiIntegrationService, "AI integration service not available.").unarchiveDevinCloudSession( + requireString(payload.devinSessionId, "ai.unarchiveDevinCloudSession requires devinSessionId."), + ); + args.devinCloudFleetService?.invalidateCache(); + }); + register("ai.devinCloudFollowUp", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").devinCloudFollowUp({ + devinSessionId: requireString(payload.devinSessionId, "ai.devinCloudFollowUp requires devinSessionId."), + message: requireString(payload.message, "ai.devinCloudFollowUp requires message."), + }); + args.devinCloudFleetService?.invalidateCache(); + }); + register("ai.openDevinCloudChat", { viewerAllowed: true, queueable: false }, async (payload) => { + const sessionId = asTrimmedString(payload.sessionId); + const devinMode = asOptionalDevinCloudMode(payload.devinMode); + return requireService(args.agentChatService, "Agent chat service not available.").openDevinCloudChat({ + devinSessionId: requireString(payload.devinSessionId, "ai.openDevinCloudChat requires devinSessionId."), + laneId: requireString(payload.laneId, "ai.openDevinCloudChat requires laneId."), + ...(sessionId ? { sessionId } : {}), + ...(devinMode !== undefined ? { devinMode } : {}), + }); + }); + register("ai.watchDevinCloudMirror", { viewerAllowed: true, queueable: false }, async (payload) => { + if (typeof payload.watching !== "boolean") { + throw new Error("ai.watchDevinCloudMirror requires watching to be a boolean."); + } + requireService(args.agentChatService, "Agent chat service not available.").watchDevinCloudMirror({ + sessionId: requireString(payload.sessionId, "ai.watchDevinCloudMirror requires sessionId."), + watching: payload.watching, + }); + }); + register("ai.createDevinCloudSession", { viewerAllowed: false, controllerAllowed: true, queueable: false }, async (payload) => { + const devinMode = asOptionalDevinCloudMode(payload.devinMode); + const sessionId = asTrimmedString(payload.sessionId); + const title = asTrimmedString(payload.title); + const projectId = asTrimmedString(payload.projectId); + const result = await requireService(args.agentChatService, "Agent chat service not available.").createDevinCloudSessionForLane({ + laneId: requireString(payload.laneId, "ai.createDevinCloudSession requires laneId."), + prompt: requireString(payload.prompt, "ai.createDevinCloudSession requires prompt."), + ...(sessionId ? { sessionId } : {}), + ...(title ? { title } : {}), + ...(devinMode !== undefined ? { devinMode } : {}), + ...(projectId ? { projectId } : {}), + ...(typeof payload.bypassApproval === "boolean" ? { bypassApproval: payload.bypassApproval } : {}), + }); + args.devinCloudFleetService?.invalidateCache(); + return result; + }); } function registerPrAndDeeplinkRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index b5172f51bb..54411a4bb8 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -741,6 +741,7 @@ export function createSyncService(args: SyncServiceArgs) { aiIntegrationService: args.aiIntegrationService, agentChatService: args.agentChatService, cursorCloudFleetService: args.cursorCloudFleetService, + devinCloudFleetService: args.devinCloudFleetService, personalChatScope: args.personalChatScope, pushPublisherService: args.pushPublisherService, ctoStateService: args.ctoStateService, @@ -884,6 +885,7 @@ export function createSyncService(args: SyncServiceArgs) { ptyService: args.ptyService, agentChatService: args.agentChatService, cursorCloudFleetService: args.cursorCloudFleetService, + devinCloudFleetService: args.devinCloudFleetService, aiIntegrationService: args.aiIntegrationService, accountSettingsStore: args.accountSettingsStore, pushPublisherService: args.pushPublisherService, diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index dc91bef6dd..c0ec183c9f 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -348,7 +348,9 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { ); } if (items.length > 1) { - args.logger?.warn?.("devin_cloud.multi_org_defaulting_to_first", { orgId: id }); + throw new Error( + "Your Devin account belongs to multiple orgs. Add the org id (org-...) for the one you want in Settings > Devin.", + ); } return id; })().catch((error) => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e8ed1136f4..5ae89f2d4e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45501,6 +45501,10 @@ export function createAgentChatService(args: { turnId, }); devinCloudSendInFlight.add(managed.session.id); + // A fresh send means the remote turn is live again — reset completion so + // the mirror can emit `done` for this turn, not just the first one. + devinCloudDoneAnnounced.delete(managed.session.id); + devinCloudPendingDoneTurn.delete(managed.session.id); try { await aiIntegrationService.sendDevinCloudMessage({ devinSessionId, diff --git a/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts b/apps/desktop/src/main/services/chat/harnessPresetLaunch.test.ts index a2bd3ccd16..5abd21d9cd 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"; @@ -114,6 +115,7 @@ describe("credentialStoreProviderForHarness", () => { 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/renderer/components/settings/providers/keys/providerKeySpecs.ts b/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts index e2cba2e25f..7f6dd1f4d8 100644 --- a/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts +++ b/apps/desktop/src/renderer/components/settings/providers/keys/providerKeySpecs.ts @@ -222,7 +222,10 @@ const SPECS: Record = { }, devin: { provider: "devin", - credentialProvider: "devin", + // The cloud PAT lives in the `devin` slot (written by the Devin Cloud + // credential form); the Windsurf CLI key is a different credential, so it + // files under its own id instead of overwriting the PAT. + credentialProvider: "devin-cli", keyEnvVar: "WINDSURF_API_KEY", keyHelp: "A Windsurf API key. The Devin CLI signs in with it when `devin auth login` has not run.", endpoint: null, diff --git a/apps/desktop/src/shared/cursorCloudRepoMatch.test.ts b/apps/desktop/src/shared/cursorCloudRepoMatch.test.ts new file mode 100644 index 0000000000..0ae189503e --- /dev/null +++ b/apps/desktop/src/shared/cursorCloudRepoMatch.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { repoMatchKey } from "./cursorCloudRepoMatch"; + +describe("repoMatchKey", () => { + it("normalizes https, scp-style ssh, and ssh:// URL forms to one key", () => { + const key = "github.com/owner/repo"; + expect(repoMatchKey("https://github.com/owner/repo")).toBe(key); + expect(repoMatchKey("https://github.com/owner/repo.git")).toBe(key); + expect(repoMatchKey("git@github.com:owner/repo.git")).toBe(key); + expect(repoMatchKey("ssh://git@github.com/owner/repo.git")).toBe(key); + }); + + it("drops an explicit port on URL-form remotes", () => { + expect(repoMatchKey("ssh://git@ssh.github.com:443/owner/repo.git")).toBe("github.com/owner/repo"); + expect(repoMatchKey("https://git.example.com:8443/owner/repo.git")).toBe("git.example.com/owner/repo"); + }); + + it("canonicalizes GitHub's dedicated ssh host", () => { + expect(repoMatchKey("ssh://git@ssh.github.com/owner/repo")).toBe("github.com/owner/repo"); + }); + + it("is case-insensitive and ignores trailing slashes", () => { + expect(repoMatchKey("HTTPS://GitHub.com/Owner/Repo/")).toBe("github.com/owner/repo"); + }); + + it("returns empty for missing input", () => { + expect(repoMatchKey(null)).toBe(""); + expect(repoMatchKey("")).toBe(""); + expect(repoMatchKey(" ")).toBe(""); + }); +}); diff --git a/apps/desktop/src/shared/cursorCloudRepoMatch.ts b/apps/desktop/src/shared/cursorCloudRepoMatch.ts index afab29981e..10eeaf9ecb 100644 --- a/apps/desktop/src/shared/cursorCloudRepoMatch.ts +++ b/apps/desktop/src/shared/cursorCloudRepoMatch.ts @@ -13,12 +13,18 @@ export function repoMatchKey(url: string | null | undefined): string { // SSH form: git@host:owner/repo(.git) const sshMatch = s.match(/^[^@]+@([^:]+):(.+)$/); if (sshMatch) { - s = `${sshMatch[1]}/${sshMatch[2]}`; + // `ssh://git@host:443/owner/repo` reaches here too; drop an explicit port. + const portMatch = sshMatch[2].match(/^\d+\/(.+)$/); + s = `${sshMatch[1]}/${portMatch ? portMatch[1] : sshMatch[2]}`; } else { s = s.replace(/^[a-z+]+:\/\//i, ""); // Strip any leading user@ (e.g. https://user@host/...) s = s.replace(/^[^/@]+@/, ""); + // Drop an explicit port on the host (e.g. ssh://git@ssh.github.com:443/...) + s = s.replace(/^([^/:]+):\d+\//, "$1/"); } + // GitHub's dedicated SSH host is the same repository as github.com. + s = s.replace(/^ssh\.github\.com\//i, "github.com/"); s = s.replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); return s; } From b81cdc7bd6b69d5bdc0ca18f47843004d757e992 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 20 Sep 2026 23:44:07 -0700 Subject: [PATCH 07/45] fix(devin): verify() gates multi-org tokens too; v1 mirrors keep full history - verify() now refuses when a PAT sees >1 org without a configured id, and validates a configured org id against the visible set (orgName comes from the matching row) instead of caching the first row - v1 listMessages returns the whole inline transcript (mirror dedupes on event_id) instead of silently dropping everything before the tail - update cli/model-picker/sync-capability expectations for the new provider Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/ade-cli/src/cli.test.ts | 2 +- .../src/services/sync/syncHostService.test.ts | 7 +++++ .../ModelPicker/modelPickerLayout.test.ts | 1 + .../src/main/services/ai/devinCloudClient.ts | 28 +++++++++++++++---- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 2a0759c097..660b42de1e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3748,7 +3748,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/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index db1ec43609..ccb7b04f9b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -6370,6 +6370,10 @@ describe("CTO-gated Linear sync commands", () => { "ai.cursorCloudResolveLane", "ai.cursorCloudPullIntoLane", "ai.cursorCloudStopRun", + "ai.getDevinCloudFleet", + "ai.pullDevinCloudSessionIntoLane", + "ai.openDevinCloudChat", + "ai.getDevinCloudAuthStatus", "chat.listPromptStashes", "chat.createPromptStash", "chat.deletePromptStash", @@ -6390,6 +6394,8 @@ describe("CTO-gated Linear sync commands", () => { // both are host state mutations refused to read-only viewers. "ai.cursorCloudResolveLane", "ai.cursorCloudPullIntoLane", + // Pulling a Devin session's PR head mutates lane worktrees too. + "ai.pullDevinCloudSessionIntoLane", // Resuming spends a provider turn, so it is a host mutation a // read-only viewer never gets to make. "chat.resumeUsageLimitNow", @@ -6406,6 +6412,7 @@ describe("CTO-gated Linear sync commands", () => { "ai.cursorCloudStopRun", ]); const controllerAllowedActions = new Set([ + "ai.pullDevinCloudSessionIntoLane", "ai.createCursorCloudRun", "ai.archiveCursorCloudAgent", "ai.unarchiveCursorCloudAgent", 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 66b46d0aa6..48ce428480 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -47,6 +47,7 @@ describe("buildModelPickerLayout", () => { "claude", "codex", "cursor", + "devin", "opencode", "pi", "copilot", diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index c0ec183c9f..d0d5a49966 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -482,8 +482,10 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { const items = raw .filter(isRecord) .map(normalizeV1Message) - .filter((m): m is DevinCloudMessage => m !== null) - .slice(-first); + .filter((m): m is DevinCloudMessage => m !== null); + // v1 embeds the whole transcript in the session record — return all of + // it. The mirror dedupes on event_id, so replaying history is invisible + // and nothing before the tail is ever lost. return { items, endCursor: null }; } const qs = { @@ -694,13 +696,27 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { if (!first) { throw new Error("This Devin token works but no organizations are visible to it."); } - const name = readString(first.org_name) ?? readString(first.name); - const id = readString(first.org_id) ?? readString(first.id); + const idFor = (entry: Record) => readString(entry.org_id) ?? readString(entry.id); + if (cachedOrgId) { + const match = page.items.filter(isRecord).find((entry) => idFor(entry) === cachedOrgId); + if (!match) { + throw new Error( + `Org '${cachedOrgId}' is not visible to this Devin token. Check the org id in Settings > Devin.`, + ); + } + return { orgName: readString(match.org_name) ?? readString(match.name) }; + } + if (page.items.length > 1) { + throw new Error( + "Your Devin account belongs to multiple orgs. Add the org id (org-...) for the one you want in Settings > Devin.", + ); + } + const id = idFor(first); if (!id) { throw new Error("Could not determine your Devin org. Add your org id (org-...) in Settings > Devin."); } - if (!cachedOrgId) cachedOrgId = id; - return { orgName: name }; + cachedOrgId = id; + return { orgName: readString(first.org_name) ?? readString(first.name) }; }; return { From b800673df634e1f0db8dc0eec5600d418129c2b9 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 20 Sep 2026 23:49:04 -0700 Subject: [PATCH 08/45] fix(devin): page through all orgs during verify and org resolution Both v3 discovery paths now follow end_cursor across GET /v3/enterprise/organizations, so a configured org past page one is found instead of rejected. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/main/services/ai/devinCloudClient.ts | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index d0d5a49966..3ac7a87f17 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -329,6 +329,31 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { } }; + /** Every organization the token can see — the endpoint paginates. */ + const listAllOrganizations = async (): Promise[]> => { + const items: Record[] = []; + const seenCursors = new Set(); + let cursor: string | null = null; + for (;;) { + const qs = { first: 50, ...(cursor ? { after: cursor } : {}) }; + const page = await request( + "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify(qs)), + ); + if (!isRecord(page) || !Array.isArray(page.items)) { + throw new Error("Devin rejected this token — the organizations endpoint did not answer as expected."); + } + items.push(...page.items.filter(isRecord)); + const next = readString(page.end_cursor); + if (!next || seenCursors.has(next)) break; + seenCursors.add(next); + cursor = next; + } + return items; + }; + + const orgIdOf = (entry: Record): string | null => + readString(entry.org_id) ?? readString(entry.id); + const resolveOrgId = async (): Promise => { if (authMode === "v1") { throw new Error("Devin v1 personal keys are not org-scoped; this call needs a v3 PAT."); @@ -336,12 +361,9 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { if (cachedOrgId) return cachedOrgId; if (!orgLookupPromise) { orgLookupPromise = (async (): Promise => { - const page = await request( - "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify({ first: 2 })), - ); - const items = isRecord(page) && Array.isArray(page.items) ? page.items : []; + const items = await listAllOrganizations(); const first = items.find(isRecord); - const id = first ? readString(first.org_id) ?? readString(first.id) : null; + const id = first ? orgIdOf(first) : null; if (!id) { throw new Error( "Could not determine your Devin org. Add your org id (org-...) in Settings > Devin.", @@ -686,19 +708,13 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { } return { orgName: null }; } - const page = await request( - "/v3/enterprise/organizations?qs=" + encodeURIComponent(JSON.stringify({ first: 50 })), - ); - if (!isRecord(page) || !Array.isArray(page.items)) { - throw new Error("Devin rejected this token — the organizations endpoint did not answer as expected."); - } - const first = page.items.find(isRecord); + const items = await listAllOrganizations(); + const first = items.find(isRecord); if (!first) { throw new Error("This Devin token works but no organizations are visible to it."); } - const idFor = (entry: Record) => readString(entry.org_id) ?? readString(entry.id); if (cachedOrgId) { - const match = page.items.filter(isRecord).find((entry) => idFor(entry) === cachedOrgId); + const match = items.find((entry) => orgIdOf(entry) === cachedOrgId); if (!match) { throw new Error( `Org '${cachedOrgId}' is not visible to this Devin token. Check the org id in Settings > Devin.`, @@ -706,12 +722,12 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { } return { orgName: readString(match.org_name) ?? readString(match.name) }; } - if (page.items.length > 1) { + if (items.length > 1) { throw new Error( "Your Devin account belongs to multiple orgs. Add the org id (org-...) for the one you want in Settings > Devin.", ); } - const id = idFor(first); + const id = orgIdOf(first); if (!id) { throw new Error("Could not determine your Devin org. Add your org id (org-...) in Settings > Devin."); } From 2bcb43b0b5442c4e34872905a734d2653a35d08c Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 00:12:47 -0700 Subject: [PATCH 09/45] chore: retrigger flaky desktop test shards Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 78134145922eb8e2c1abd53c3762116425b9d9ea Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 00:33:17 -0700 Subject: [PATCH 10/45] chore: retrigger flaky desktop test shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 0707361c8d6fa0295340ac3fb9e266bdd26d39cb Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 02:54:37 -0700 Subject: [PATCH 11/45] fix(devin): disarm fleet delete on dismiss, clear stale org on v1, launch on lane branch - Closing an armed row menu now clears confirmDeleteId instead of re-arming - Saving a v1 key clears the persisted org so a later v3 key isn't verified against the previous org - Lane-bound cloud sessions name the pushed lane branch in the prompt (v3 create has no branch field) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/src/main/services/ai/aiIntegrationService.ts | 6 +++--- apps/desktop/src/main/services/chat/agentChatService.ts | 8 +++++++- .../src/renderer/components/app/DevinCloudFleetModal.tsx | 5 +++++ .../src/renderer/components/app/DevinCloudFleetRow.tsx | 4 +++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 80f977de39..bb14da66a0 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1648,9 +1648,9 @@ export function createAiIntegrationService(args: { const { orgName } = await client.verify(); storeStoredApiKey("devin", key); const resolvedOrgId = client.getOrgId() ?? orgId; - if (detectDevinAuthMode(key) === "v3") { - persistDevinCloudOrgId(resolvedOrgId); - } + // The org id only applies to v3 keys — persisting a stale one would make a + // later v3 key verify against the previous org. + persistDevinCloudOrgId(detectDevinAuthMode(key) === "v3" ? resolvedOrgId : null); devinCloudClientCache = { apiKey: key, orgId: resolvedOrgId, client }; return { configured: true, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5ae89f2d4e..120db1452e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45604,8 +45604,14 @@ export function createAgentChatService(args: { projectId: args.projectId, sessionId: args.sessionId, }); + // v3 has no branch field — the lane branch is pushed to the remote by the + // caller, so name it in the prompt the way Devin's own handoff flow does. + const laneBranch = laneInfo.branchRef?.trim(); + const cloudPrompt = repoUrl && laneBranch + ? `Repo: ${repoUrl} (branch: ${laneBranch})\nCheck out the existing '${laneBranch}' branch first — it has been pushed to the remote and carries this lane's commits.\n\n${prompt}` + : prompt; const created = await aiIntegrationService.createDevinCloudSession({ - prompt, + prompt: cloudPrompt, ...(repoUrl ? { repoUrls: [repoUrl] } : {}), tags, ...(args.title?.trim() ? { title: args.title.trim() } : {}), diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx index 40b173e2fc..ee27982a59 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx @@ -368,6 +368,11 @@ export function DevinCloudFleetModal({ onArchive={() => void toggleArchive(entry)} onRequestDelete={() => setConfirmDeleteId(entry.session.sessionId)} onConfirmDelete={() => void deleteSession(entry)} + onDismissDelete={() => + setConfirmDeleteId((current) => + current === entry.session.sessionId ? null : current, + ) + } /> ); diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx index e6fc6e783f..2df6c711d0 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx @@ -106,6 +106,7 @@ export function FleetRow({ onArchive, onRequestDelete, onConfirmDelete, + onDismissDelete, }: { entry: DevinCloudFleetEntry; expanded: boolean; @@ -119,6 +120,7 @@ export function FleetRow({ onArchive: () => void; onRequestDelete: () => void; onConfirmDelete: () => void; + onDismissDelete: () => void; }) { const [liveUrlCopied, setLiveUrlCopied] = useState(false); const { session } = entry; @@ -272,7 +274,7 @@ export function FleetRow({ onArchive={onArchive} onRequestDelete={onRequestDelete} onConfirmDelete={onConfirmDelete} - onConfirmDismiss={onRequestDelete} + onConfirmDismiss={onDismissDelete} />
From b259b1ab62ab419baaab7f22360298eb336dd8d8 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 02:58:51 -0700 Subject: [PATCH 12/45] fix(devin): verify lane branch is published before naming it to Devin The create path now checks the remote for the lane branch and pushes it when absent, so callers that never push (drawer, remote command) cannot hand Devin a checkout instruction for a branch that does not exist. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/services/chat/agentChatService.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 120db1452e..923f87749b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45604,10 +45604,34 @@ export function createAgentChatService(args: { projectId: args.projectId, sessionId: args.sessionId, }); - // v3 has no branch field — the lane branch is pushed to the remote by the - // caller, so name it in the prompt the way Devin's own handoff flow does. + // v3 has no branch field — name the lane branch in the prompt the way + // Devin's own handoff flow does, but only after the remote actually has it: + // some callers (drawer, remote command) never push. const laneBranch = laneInfo.branchRef?.trim(); - const cloudPrompt = repoUrl && laneBranch + let branchOnRemote = false; + if (repoUrl && laneBranch) { + try { + const ls = await runGit( + ["ls-remote", "--exit-code", "--heads", "origin", `refs/heads/${laneBranch}`], + { cwd: laneInfo.worktreePath, timeoutMs: 15_000 }, + ); + branchOnRemote = ls.exitCode === 0; + if (!branchOnRemote) { + const push = await runGit(["push", "-u", "origin", laneBranch], { + cwd: laneInfo.worktreePath, + timeoutMs: 60_000, + }); + branchOnRemote = push.exitCode === 0; + } + } catch (error) { + logger.warn("agent_chat.devin_cloud_branch_publish_failed", { + laneId: trimmedLane, + branch: laneBranch, + error: error instanceof Error ? error.message : String(error), + }); + } + } + const cloudPrompt = repoUrl && laneBranch && branchOnRemote ? `Repo: ${repoUrl} (branch: ${laneBranch})\nCheck out the existing '${laneBranch}' branch first — it has been pushed to the remote and carries this lane's commits.\n\n${prompt}` : prompt; const created = await aiIntegrationService.createDevinCloudSession({ From 774f6f2bec24a65689590a6b41149e44ffd29a19 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 03:04:11 -0700 Subject: [PATCH 13/45] fix(devin): require remote lane branch to match HEAD before prompting checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ls-remote existence was not freshness — compare the remote sha to the lane HEAD, push when stale, and only name the branch once the post-push remote points at the exact commit. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/services/chat/agentChatService.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 923f87749b..82af6d5311 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45611,17 +45611,29 @@ export function createAgentChatService(args: { let branchOnRemote = false; if (repoUrl && laneBranch) { try { - const ls = await runGit( - ["ls-remote", "--exit-code", "--heads", "origin", `refs/heads/${laneBranch}`], - { cwd: laneInfo.worktreePath, timeoutMs: 15_000 }, - ); - branchOnRemote = ls.exitCode === 0; - if (!branchOnRemote) { - const push = await runGit(["push", "-u", "origin", laneBranch], { + const headSha = (await runGit(["rev-parse", "HEAD"], { + cwd: laneInfo.worktreePath, + timeoutMs: 8_000, + })).stdout.trim(); + const remoteSha = async (): Promise => { + const ls = await runGit( + ["ls-remote", "--heads", "origin", `refs/heads/${laneBranch}`], + { cwd: laneInfo.worktreePath, timeoutMs: 15_000 }, + ); + return ls.exitCode === 0 ? ls.stdout.split(/\s+/)[0] ?? null : null; + }; + // Existence is not freshness: the remote ref must point at the lane's + // current HEAD or unpushed commits never reach the cloud session. + if (headSha && (await remoteSha()) !== headSha) { + const push = await runGit(["push", "origin", `${laneBranch}:${laneBranch}`], { cwd: laneInfo.worktreePath, timeoutMs: 60_000, }); - branchOnRemote = push.exitCode === 0; + if (push.exitCode === 0) { + branchOnRemote = (await remoteSha()) === headSha; + } + } else { + branchOnRemote = Boolean(headSha); } } catch (error) { logger.warn("agent_chat.devin_cloud_branch_publish_failed", { From 80cc8552079113f93ee38bcb44b58dd71fe19e82 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 12:54:58 -0700 Subject: [PATCH 14/45] feat(devin): VM actions via devin CLI + platform picker on cloud create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold in the newly-shipped Devin Cloud CLI surface: fleet rows gain SSH into VM (devin ssh), Forward port (devin forward), and Steer in terminal (devin --cloud -r) — spawned as tracked PTYs in the entry's lane, gated on devin CLI detection. The cloud composer and drawer panel gain a VM platform field mapped to the v3 'platform' label. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/main/services/ai/devinCloudClient.ts | 1 + .../main/services/chat/agentChatService.ts | 2 + .../components/app/DevinCloudFleetModal.tsx | 65 +++++++++++++ .../components/app/DevinCloudFleetRow.tsx | 95 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 24 +++++ .../components/chat/ChatDevinCloudPanel.tsx | 25 ++++- apps/desktop/src/shared/types/config.ts | 8 ++ docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 24 ++++- 9 files changed, 239 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index 3ac7a87f17..25f2886675 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -478,6 +478,7 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { ...(input.devinMode ? { devin_mode: input.devinMode } : {}), ...(input.resumable !== undefined ? { resumable: input.resumable } : {}), ...(input.bypassApproval !== undefined ? { bypass_approval: input.bypassApproval } : {}), + ...(input.platform?.trim() ? { platform: input.platform.trim() } : {}), }; const record = await request(await orgPath("/sessions"), { method: "POST", body }); if (!isRecord(record)) throw new Error("Devin did not return a session."); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 82af6d5311..15658fd93f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45583,6 +45583,7 @@ export function createAgentChatService(args: { devinMode?: DevinCloudMode | null; projectId?: string | null; bypassApproval?: boolean; + platform?: string | null; }): Promise<{ sessionId: string; session: AgentChatSession; devinSessionId: string }> => { const trimmedLane = args.laneId.trim(); const prompt = args.prompt.trim(); @@ -45653,6 +45654,7 @@ export function createAgentChatService(args: { ...(args.title?.trim() ? { title: args.title.trim() } : {}), ...(args.devinMode ? { devinMode: args.devinMode } : {}), ...(args.bypassApproval !== undefined ? { bypassApproval: args.bypassApproval } : {}), + ...(args.platform?.trim() ? { platform: args.platform.trim() } : {}), }); const opened = await openDevinCloudChat({ devinSessionId: created.sessionId, diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx index ee27982a59..985ce14c25 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useNavigate } from "react-router-dom"; import { ArrowSquareOut, ArrowsClockwise, @@ -21,6 +22,7 @@ import { repoMatchKey, } from "../../lib/devinCloudUtils"; import { announceWorkChatSessionCreated } from "../../lib/chatSessionEvents"; +import { revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; import { settingsRouteFor } from "../settings/settingsManifest"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; @@ -85,6 +87,22 @@ export function DevinCloudFleetModal({ const refreshLanes = useAppStore((s) => s.refreshLanes); const lanes = useAppStore((s) => s.lanes); + const navigate = useNavigate(); + const [devinCliAvailable, setDevinCliAvailable] = useState(false); + + // `devin` CLI presence gates the VM actions (ssh / forward / steer) — they + // ride the logged-in CLI credentials, not the fleet's API key. + useEffect(() => { + let cancelled = false; + void window.ade.agentTools?.detect?.() + .then((tools) => { + if (!cancelled) { + setDevinCliAvailable(Boolean(tools?.some((t) => t.id === "devin" && t.installed))); + } + }) + .catch(() => undefined); + return () => { cancelled = true; }; + }, []); const refresh = useCallback(async (soft: boolean) => { const generation = ++requestGeneration.current; @@ -246,6 +264,51 @@ export function DevinCloudFleetModal({ } }, [lanes, onClose, projectRoot]); + /** + * VM actions ride the Devin CLI (devin ssh / forward / --cloud -r) as a + * tracked terminal in the session's lane — same lane resolution as opening + * the mirrored chat. + */ + const openVmAction = useCallback(async ( + entry: DevinCloudFleetEntry, + kind: "ssh" | "steer" | "forward", + port?: string, + ) => { + const devinSessionId = entry.session.sessionId; + setBusySessionId(devinSessionId); + setRowError(null); + try { + const laneId = entry.ownership.laneId + ?? entry.adeLaneId + ?? lanes.find((lane) => lane.laneType === "primary")?.id + ?? null; + if (!laneId) throw new Error("No lane available to host this terminal."); + const label = entry.session.title || devinSessionId.slice(0, 10); + const spec = + kind === "ssh" + ? { args: ["ssh", devinSessionId], title: `Devin VM · ${label}` } + : kind === "forward" + ? { args: ["forward", devinSessionId, port ?? ""], title: `Devin forward · ${label}` } + : { args: ["--cloud", "-r", devinSessionId], title: `Devin Cloud · ${label}` }; + const created = await window.ade.pty.create({ + laneId, + cols: 100, + rows: 30, + title: spec.title, + tracked: true, + toolType: "devin", + command: "devin", + args: spec.args, + }); + revealTerminalSessionInWork(navigate, { terminalId: created.sessionId, laneId }); + onClose(); + } catch (err) { + setRowError({ sessionId: devinSessionId, message: devinCloudErrorMessage(err) }); + } finally { + setBusySessionId(null); + } + }, [lanes, navigate, onClose]); + const terminateSession = useCallback(async (entry: DevinCloudFleetEntry) => { const devinSessionId = entry.session.sessionId; setBusySessionId(devinSessionId); @@ -373,6 +436,8 @@ export function DevinCloudFleetModal({ current === entry.session.sessionId ? null : current, ) } + devinCliAvailable={devinCliAvailable} + onVmAction={(kind, port) => void openVmAction(entry, kind, port)} /> ); diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx index 2df6c711d0..c277615d02 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetRow.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { ArrowSquareOut, + ArrowsLeftRight, CaretDown, Desktop, GitPullRequest, Stop, + TerminalWindow, Trash, } from "@phosphor-icons/react"; @@ -107,6 +109,8 @@ export function FleetRow({ onRequestDelete, onConfirmDelete, onDismissDelete, + devinCliAvailable, + onVmAction, }: { entry: DevinCloudFleetEntry; expanded: boolean; @@ -121,6 +125,8 @@ export function FleetRow({ onRequestDelete: () => void; onConfirmDelete: () => void; onDismissDelete: () => void; + devinCliAvailable: boolean; + onVmAction: (kind: "ssh" | "steer" | "forward", port?: string) => void; }) { const [liveUrlCopied, setLiveUrlCopied] = useState(false); const { session } = entry; @@ -270,8 +276,10 @@ export function FleetRow({ busy={busy} confirmingDelete={confirmingDelete} finished={finished} + devinCliAvailable={devinCliAvailable} onPull={onPull} onArchive={onArchive} + onVmAction={onVmAction} onRequestDelete={onRequestDelete} onConfirmDelete={onConfirmDelete} onConfirmDismiss={onDismissDelete} @@ -350,8 +358,10 @@ function RowMenu({ busy, confirmingDelete, finished, + devinCliAvailable, onPull, onArchive, + onVmAction, onRequestDelete, onConfirmDelete, onConfirmDismiss, @@ -360,19 +370,25 @@ function RowMenu({ busy: boolean; confirmingDelete: boolean; finished: boolean; + devinCliAvailable: boolean; onPull: () => void; onArchive: () => void; + onVmAction: (kind: "ssh" | "steer" | "forward", port?: string) => void; onRequestDelete: () => void; onConfirmDelete: () => void; onConfirmDismiss: () => void; }) { const [open, setOpen] = useState(false); const [flipUp, setFlipUp] = useState(false); + const [forwardMode, setForwardMode] = useState(false); + const [forwardPort, setForwardPort] = useState(""); const menuRef = useRef(null); useEffect(() => { if (!open) { setFlipUp(false); + setForwardMode(false); + setForwardPort(""); return; } const onDocClick = (event: MouseEvent) => { @@ -442,6 +458,85 @@ function RowMenu({ Open live session in ADE ) : null} + {!entry.session.isArchived ? ( + devinCliAvailable ? ( + <> + {forwardMode ? ( +
+ setForwardPort(event.target.value.replace(/[^0-9:]/g, ""))} + onKeyDown={(event) => { + if (event.key === "Enter" && forwardPort.trim()) { + setOpen(false); + onVmAction("forward", forwardPort.trim()); + } else if (event.key === "Escape") { + setForwardMode(false); + } + }} + placeholder="8080 or 3000:8080" + aria-label="VM port to forward" + className="h-6 min-w-0 flex-1 rounded border border-white/[0.10] bg-white/[0.04] px-1.5 font-mono text-[10.5px] text-fg/80 outline-none placeholder:text-fg/30" + /> + +
+ ) : ( + <> + + + + + )} + + ) : ( +
+ Install the devin CLI for SSH / port-forward / steer actions. +
+ ) + ) : null} {finished && !entry.session.isArchived && entry.prUrl ? (
+
+ VM platform + onPlatformChange(event.target.value)} + placeholder="org default" + aria-label="Devin VM platform" + className="h-6 w-36 rounded-md border border-white/[0.08] bg-white/[0.03] px-1.5 text-right font-mono text-[10.5px] text-fg/75 outline-none placeholder:text-fg/30 hover:border-white/[0.16]" + /> + + +
) : ( -
+
+
+ setKeyInput(event.target.value)} + placeholder="cog_..." + type="password" + disabled={busy} + onKeyDown={(event) => { if (event.key === "Enter") void save(); }} + style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + +
setKeyInput(event.target.value)} - placeholder="cog_..." - type="password" + aria-label="Devin org id (optional)" + value={orgInput} + onChange={(event) => setOrgInput(event.target.value)} + placeholder="org-... (optional — required on non-enterprise accounts)" disabled={busy} onKeyDown={(event) => { if (event.key === "Enter") void save(); }} style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> - +
+ Org id is auto-discovered on enterprise accounts; personal/team + accounts must paste it — visible in your Devin settings and session + URLs (or in the CLI via devin auth status). +
)} {auth && !auth.configured && auth.error ? ( From 5a7309e6a68b1065a2f7fb35fa0895f34b370a5a Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 14:07:40 -0700 Subject: [PATCH 21/45] fix(desktop): populate fleet Mine filter via /v3/self caller identity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/ade-cli/src/bootstrap.ts | 1 + apps/desktop/src/main/main.ts | 1 + .../main/services/ai/aiIntegrationService.ts | 17 ++++ .../src/main/services/ai/devinCloudClient.ts | 44 ++++++++-- .../chat/devinCloudFleetService.test.ts | 88 +++++++++++++++++++ .../services/chat/devinCloudFleetService.ts | 7 ++ .../components/app/DevinCloudFleetModal.tsx | 2 +- apps/desktop/src/shared/types/config.ts | 5 ++ 8 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/devinCloudFleetService.test.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index a9a6cfd73c..c090bb7e0a 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1859,6 +1859,7 @@ export async function createAdeRuntime(args: { logger, listDevinCloudSessions: (args) => aiIntegrationService.listDevinCloudSessions(args), getDevinCloudSession: (devinSessionId) => aiIntegrationService.getDevinCloudSession(devinSessionId), + getDevinCloudCallerUserId: () => aiIntegrationService.getDevinCloudCallerUserId(), laneService: { list: (args) => laneService.list(args), importBranch: (args) => laneService.importBranch(args), diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 40264d408b..539f1417b2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -4464,6 +4464,7 @@ app.whenReady().then(async () => { logger, listDevinCloudSessions: (args) => aiIntegrationService.listDevinCloudSessions(args), getDevinCloudSession: (devinSessionId) => aiIntegrationService.getDevinCloudSession(devinSessionId), + getDevinCloudCallerUserId: () => aiIntegrationService.getDevinCloudCallerUserId(), laneService: { list: (args) => laneService.list(args), importBranch: (args) => laneService.importBranch(args), diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index bb14da66a0..7b48c93941 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1618,6 +1618,21 @@ export function createAiIntegrationService(args: { } }; + // `/v3/self` principal id for the stored credential — drives the fleet's + // "Mine" filter. Cached per apiKey; null on v1 keys (no self endpoint). + let devinCloudCaller: { apiKey: string; userId: string | null } | null = null; + + const getDevinCloudCallerUserId = async (): Promise => { + const apiKey = await requireDevinCloudApiKey(); + if (devinCloudCaller && devinCloudCaller.apiKey === apiKey) { + return devinCloudCaller.userId; + } + const client = await devinCloudClient(); + const self = await client.getSelf().catch(() => null); + devinCloudCaller = { apiKey, userId: self?.userId ?? null }; + return devinCloudCaller.userId; + }; + const getDevinCloudAuthStatus = async (): Promise => { const apiKey = getStoredApiKey("devin"); if (!apiKey) { @@ -1640,6 +1655,7 @@ export function createAiIntegrationService(args: { deleteStoredApiKey("devin"); persistDevinCloudOrgId(null); devinCloudClientCache = null; + devinCloudCaller = null; return { configured: false, authMode: null, orgId: null, orgName: null, error: null }; } const orgId = args.orgId?.trim() || null; @@ -2562,6 +2578,7 @@ export function createAiIntegrationService(args: { archiveDevinCloudSession, unarchiveDevinCloudSession, requireDevinCloudApiKey, + getDevinCloudCallerUserId, getAvailabilityAsync, resolveModelForTask, diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index 3caf992d97..17dc71d96b 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -363,6 +363,30 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { const orgIdOf = (entry: Record): string | null => readString(entry.org_id) ?? readString(entry.id); + /** + * `GET /v3/self` — who the credential authenticates as. Works for PATs and + * service-user keys on every account tier (the enterprise org list is + * enterprise-only); null on v1 keys, which have no equivalent. + */ + const getSelf = async (): Promise<{ + userId: string | null; + orgId: string | null; + userName: string | null; + } | null> => { + if (authMode === "v1") return null; + try { + const record = await request("/v3/self"); + if (!isRecord(record)) return null; + return { + userId: readString(record.user_id) ?? readString(record.service_user_id), + orgId: readString(record.org_id), + userName: readString(record.user_name) ?? readString(record.service_user_name), + }; + } catch { + return null; + } + }; + const noOrgIdError = () => new Error( "Could not determine your Devin org. Add your org id (org-...) in Settings > Devin — it is shown in your Devin settings and session URLs.", @@ -391,6 +415,11 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { if (cachedOrgId) return cachedOrgId; if (!orgLookupPromise) { orgLookupPromise = (async (): Promise => { + // `/v3/self` is the cheapest, tier-agnostic discovery: PATs and service + // users get `org_id` even on non-enterprise accounts where the + // enterprise org list is gated. + const self = await getSelf(); + if (self?.orgId) return self.orgId; const items = await listAllOrganizations(); if (!items) throw noOrgIdError(); const first = items.find(isRecord); @@ -738,14 +767,17 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { } const items = await listAllOrganizations(); if (!items) { - // Non-enterprise account: org listing is enterprise-gated, so verify - // the configured org by probing an org-scoped endpoint instead. - if (!cachedOrgId) throw noOrgIdError(); - if (!(await probeOrg(cachedOrgId))) { + // Non-enterprise account: org listing is enterprise-gated. `/v3/self` + // still reports the org for PATs; otherwise verify the configured org by + // probing an org-scoped endpoint. + const orgId = cachedOrgId ?? (await getSelf())?.orgId ?? null; + if (!orgId) throw noOrgIdError(); + if (!(await probeOrg(orgId))) { throw new Error( - `Org '${cachedOrgId}' is not visible to this Devin token. Check the org id in Settings > Devin.`, + `Org '${orgId}' is not visible to this Devin token. Check the org id in Settings > Devin.`, ); } + cachedOrgId = orgId; return { orgName: null }; } const first = items.find(isRecord); @@ -787,6 +819,8 @@ export function createDevinCloudClient(args: DevinCloudClientArgs) { archiveSession, unarchiveSession, verify, + /** `GET /v3/self` — caller identity (userId/orgId) for PATs and service users. */ + getSelf, /** Resolved org id when known (configured or discovered). */ getOrgId: () => cachedOrgId, }; diff --git a/apps/desktop/src/main/services/chat/devinCloudFleetService.test.ts b/apps/desktop/src/main/services/chat/devinCloudFleetService.test.ts new file mode 100644 index 0000000000..e15f7b5ef4 --- /dev/null +++ b/apps/desktop/src/main/services/chat/devinCloudFleetService.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDevinCloudFleetService } from "./devinCloudFleetService"; +import type { DevinCloudSessionSummary } from "../../../shared/types/config"; + +const mockGit = vi.hoisted(() => ({ runGit: vi.fn() })); +vi.mock("../git/git", () => ({ + runGit: (...args: unknown[]) => mockGit.runGit(...args), +})); + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +} as unknown as Parameters[0]["logger"]; + +function session(overrides: Partial & { sessionId: string }): DevinCloudSessionSummary { + return { + title: `Session ${overrides.sessionId}`, + status: "working", + statusDetail: "working", + isArchived: false, + url: `https://app.devin.ai/sessions/${overrides.sessionId}`, + pullRequests: [], + tags: [], + repos: [], + createdAt: null, + updatedAt: null, + devinMode: null, + acusConsumed: null, + userId: null, + parentSessionId: null, + origin: null, + ...overrides, + }; +} + +function buildHarness(opts: { + sessions: DevinCloudSessionSummary[]; + callerUserId?: string | null; + withCallerDep?: boolean; +}) { + const service = createDevinCloudFleetService({ + projectRoot: "/repo", + logger, + listDevinCloudSessions: async () => ({ items: opts.sessions, endCursor: null }), + ...(opts.withCallerDep === false + ? {} + : { + getDevinCloudCallerUserId: async () => opts.callerUserId ?? null, + }), + laneService: { + list: async () => [], + importBranch: async () => { throw new Error("not used"); }, + }, + listDevinCloudSessionLinks: async () => [], + openDevinCloudChat: async () => { throw new Error("not used"); }, + }); + return service; +} + +describe("devinCloudFleetService isMine", () => { + it("marks sessions whose userId matches the credential principal", async () => { + const service = buildHarness({ + callerUserId: "user-abc", + sessions: [ + session({ sessionId: "s-mine", userId: "user-abc" }), + session({ sessionId: "s-theirs", userId: "user-xyz" }), + session({ sessionId: "s-nouser", userId: null }), + ], + }); + const fleet = await service.getFleet({ force: true }); + const byId = new Map(fleet.items.map((e) => [e.session.sessionId, e])); + expect(byId.get("s-mine")?.isMine).toBe(true); + expect(byId.get("s-theirs")?.isMine).toBe(false); + expect(byId.get("s-nouser")?.isMine).toBe(false); + }); + + it("reports isMine false when no caller identity is available (v1 key)", async () => { + const service = buildHarness({ + callerUserId: null, + sessions: [session({ sessionId: "s1", userId: "user-abc" })], + }); + const fleet = await service.getFleet({ force: true }); + expect(fleet.items[0]?.isMine).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts index 927fff148a..87031627f9 100644 --- a/apps/desktop/src/main/services/chat/devinCloudFleetService.ts +++ b/apps/desktop/src/main/services/chat/devinCloudFleetService.ts @@ -33,6 +33,8 @@ type FleetServiceDeps = { }>; /** Single-session read for ids beyond the first list page. */ getDevinCloudSession?: (devinSessionId: string) => Promise; + /** `user_id` of the credential's principal (`/v3/self`) — drives the "Mine" filter. */ + getDevinCloudCallerUserId?: () => Promise; laneService: Pick, "list" | "importBranch">; /** ADE chat sessions already linked to a Devin cloud session. */ listDevinCloudSessionLinks: () => Promise; @@ -150,6 +152,10 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) { cursor = next; } while (true); + const callerUserId = deps.getDevinCloudCallerUserId + ? await deps.getDevinCloudCallerUserId().catch(() => null) + : null; + return listedItems.map((session): DevinCloudFleetEntry => { const link = linkByDevinId.get(session.sessionId) ?? null; const laneIdFromTag = devinCloudAdeLaneId(session.tags); @@ -182,6 +188,7 @@ export function createDevinCloudFleetService(deps: FleetServiceDeps) { createdViaAde, adeLaneId: laneIdFromTag, matchedBy, + isMine: Boolean(callerUserId && session.userId && session.userId === callerUserId), }; }); }; diff --git a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx index 985ce14c25..028105a17e 100644 --- a/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx +++ b/apps/desktop/src/renderer/components/app/DevinCloudFleetModal.tsx @@ -52,7 +52,7 @@ function provenanceMatches(entry: DevinCloudFleetEntry, provenance: ProvenanceFi case "ade": return entry.createdViaAde; case "mine": - return entry.createdViaAde || entry.session.origin === "mine"; + return entry.createdViaAde || entry.isMine; default: return true; } diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 689dd3398b..379fda2c0a 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1538,6 +1538,11 @@ export type DevinCloudFleetEntry = { * org-level row unrelated to this project ("org"). */ matchedBy: "session" | "repo" | "tag" | "org"; + /** + * True when the fleet credential's principal (`/v3/self` user_id) created + * this session. Null-safe: false on v1 keys, which report no caller. + */ + isMine: boolean; }; export type DevinCloudFleetResult = { From 3472136b9d9ca012377a491c5ae4be5722d8f3ea Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 14:14:50 -0700 Subject: [PATCH 22/45] fix(desktop): retry /v3/self after transient failures, drop stale cloud mode, sniff attachment markup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/services/ai/aiIntegrationService.ts | 10 ++++- .../src/main/services/ai/devinCloudClient.ts | 37 ++++++++++++++++++- .../chat/devinCloudFleetService.test.ts | 2 +- .../components/chat/AgentChatPane.tsx | 4 +- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 7b48c93941..1bf02858d4 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1627,9 +1627,17 @@ export function createAiIntegrationService(args: { if (devinCloudCaller && devinCloudCaller.apiKey === apiKey) { return devinCloudCaller.userId; } + // v1 keys have no self endpoint — their null is permanent, so cache it. For + // v3 keys only a successful read is cached; a transient /v3/self failure + // must not pin Mine=false for the life of the credential. + if (detectDevinAuthMode(apiKey) === "v1") { + devinCloudCaller = { apiKey, userId: null }; + return null; + } const client = await devinCloudClient(); const self = await client.getSelf().catch(() => null); - devinCloudCaller = { apiKey, userId: self?.userId ?? null }; + if (!self) return null; + devinCloudCaller = { apiKey, userId: self.userId }; return devinCloudCaller.userId; }; diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index 17dc71d96b..761f617843 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -263,6 +263,26 @@ function normalizeV1Message(record: Record): DevinCloudMessage }; } +/** + * Proof attachments render in-app, so bytes that smell like markup/script + * (HTML, SVG, XML) are refused regardless of the remote's declared name or + * content-type — a hostile or confused response must not enter the artifact + * store as something renderable. + */ +function sniffIsActiveMarkup(bytes: Uint8Array): boolean { + const head = new TextDecoder("utf-8", { fatal: false }) + .decode(bytes.subarray(0, 512)) + .trimStart() + .toLowerCase(); + return ( + head.startsWith(" & { sessionId: string }): DevinCloudSessionSummary { return { title: `Session ${overrides.sessionId}`, - status: "working", + status: "running", statusDetail: "working", isArchived: false, url: `https://app.devin.ai/sessions/${overrides.sessionId}`, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c77b867f98..c455dc577c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6012,8 +6012,8 @@ export function AgentChatPane({ if (!cursorCloudCanLaunch && cursorCloudMode) setCursorCloudMode(false); }, [cursorCloudCanLaunch, cursorCloudMode, setCursorCloudMode]); useEffect(() => { - if (!devinCloudCanLaunch && devinCloudMode && devinCloudUnavailableReason) setDevinCloudMode(false); - }, [devinCloudCanLaunch, devinCloudMode, devinCloudUnavailableReason]); + if (!devinCloudCanLaunch && devinCloudMode) setDevinCloudMode(false); + }, [devinCloudCanLaunch, devinCloudMode, setDevinCloudMode]); const applyCursorCloudModelSwitch = useCallback((nextModelId: string) => { setModelId(nextModelId); setReasoningEffort(null); From ead40bf181d2e8991febd4bcd6b2f3d3921d4ace Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 14:20:13 -0700 Subject: [PATCH 23/45] fix(desktop): broaden attachment markup sniffing (BOM, comments, bare tags) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/services/ai/devinCloudClient.test.ts | 37 +++++++++++++++++++ .../src/main/services/ai/devinCloudClient.ts | 20 +++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts index fd834dfaa4..b6810176b2 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts @@ -65,3 +65,40 @@ describe("devinCloudClient verify on non-enterprise accounts", () => { await expect(client.listSessions()).rejects.toThrow(/Could not determine your Devin org/); }); }); + +describe("devinCloudClient downloadAttachment", () => { + const attachment = { attachmentId: "att-1", name: "proof.png", url: "x", source: "devin" as const, contentType: "image/png" }; + + const bytesResponse = (bytes: Uint8Array) => ({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + headers: { get: () => null }, + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }); + + it("refuses attachments whose bytes sniff as markup despite an image name", async () => { + for (const payload of [ + "\uFEFFhi", + "", + "", + "", + ]) { + const fetchImpl = vi.fn(async () => bytesResponse(new TextEncoder().encode(payload))); + const client = createDevinCloudClient({ + apiKey: "cog_test", orgId: "org-mine", fetchImpl: fetchImpl as never, logger, + }); + await expect(client.downloadAttachment(attachment)).resolves.toBeNull(); + } + }); + + it("returns bytes for genuine binary content", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + const fetchImpl = vi.fn(async () => bytesResponse(png)); + const client = createDevinCloudClient({ + apiKey: "cog_test", orgId: "org-mine", fetchImpl: fetchImpl as never, logger, + }); + await expect(client.downloadAttachment(attachment)).resolves.toEqual(png); + }); +}); diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index 761f617843..d5762f44ba 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -267,20 +267,18 @@ function normalizeV1Message(record: Record): DevinCloudMessage * Proof attachments render in-app, so bytes that smell like markup/script * (HTML, SVG, XML) are refused regardless of the remote's declared name or * content-type — a hostile or confused response must not enter the artifact - * store as something renderable. + * store as something renderable. Matches tags anywhere in the first KB: + * documents can open with a BOM, an XML declaration, comments, or bare tags + * like `` that prefix checks would miss. */ +const ACTIVE_MARKUP_HEAD = + /<\s*(?:!doctype\s+html|html|head|body|svg|script|iframe|object|embed|base|meta|form|style|link)\b|<\?xml| Date: Mon, 21 Sep 2026 14:24:26 -0700 Subject: [PATCH 24/45] fix(desktop): flag markup only at document root so inline tags in text survive Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/services/ai/devinCloudClient.test.ts | 15 +++++++ .../src/main/services/ai/devinCloudClient.ts | 39 ++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts index b6810176b2..bd6ff1bd12 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts @@ -101,4 +101,19 @@ describe("devinCloudClient downloadAttachment", () => { }); await expect(client.downloadAttachment(attachment)).resolves.toEqual(png); }); + + it("keeps text files that merely mention markup tags inline", async () => { + for (const payload of [ + "The element contains the result.", + "", + "note: see and ` that prefix checks would miss. + * Proof attachments render in-app, so documents whose *root element* is active + * markup (HTML, SVG) are refused regardless of the remote's declared name or + * content-type. The check peels the only permitted preamble — BOM, whitespace, + * comments, `` declarations, a doctype — then asks whether the document + * begins with a renderable tag, so a plain-text `report.txt` mentioning + * `` inline still lands in the drawer. */ -const ACTIVE_MARKUP_HEAD = - /<\s*(?:!doctype\s+html|html|head|body|svg|script|iframe|object|embed|base|meta|form|style|link)\b|<\?xml|"); + if (end === -1) return true; + head = head.slice(end + 2); + continue; + } + if (head.startsWith(""); + if (end === -1) return true; + head = head.slice(end + 3); + continue; + } + if (/^/i.test(head)) { + head = head.replace(/^/i, ""); + continue; + } + return ACTIVE_MARKUP_ROOT.test(head); + } } export type DevinCloudListSessionsArgs = { From 5fc958348c6e3b65cd13dd8b18670a751306bee2 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 21 Sep 2026 14:29:14 -0700 Subject: [PATCH 25/45] fix(desktop): peel processing instructions at first > per HTML bogus-comment semantics Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/src/main/services/ai/devinCloudClient.test.ts | 3 +++ apps/desktop/src/main/services/ai/devinCloudClient.ts | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts index bd6ff1bd12..06142cf2ce 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.test.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.test.ts @@ -84,6 +84,9 @@ describe("devinCloudClient downloadAttachment", () => { "", "", "", + // `` — the SVG + // here is inert, but the smuggled ` ?>", ]) { const fetchImpl = vi.fn(async () => bytesResponse(new TextEncoder().encode(payload))); const client = createDevinCloudClient({ diff --git a/apps/desktop/src/main/services/ai/devinCloudClient.ts b/apps/desktop/src/main/services/ai/devinCloudClient.ts index da5a77ef5f..14c46784b1 100644 --- a/apps/desktop/src/main/services/ai/devinCloudClient.ts +++ b/apps/desktop/src/main/services/ai/devinCloudClient.ts @@ -281,9 +281,12 @@ function sniffIsActiveMarkup(bytes: Uint8Array): boolean { for (;;) { head = head.trimStart(); if (head.startsWith(""); + // Browsers read `` — not the + // XML-style `?>` — so anything before that `>` (including a smuggled + // `