From eb630141b5287acdff106f68ecb05b38b7c5965c Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 4 Sep 2026 02:59:17 +0530 Subject: [PATCH] feat(providers): add Devin via the official CLI ACP server Devin ships an official ACP server (devin acp) but T3 Code had no driver for it, so Devin Pro subscriptions could not be used from T3 Code. This adds Devin as a seventh built-in provider, off by default and listed last. Everything runs through the local Devin CLI over ACP stdio: version check via devin --version, auth via devin auth status, and model catalog from the session model config option (197 models). Sessions default to the Code (accept-edits) mode with T3 permission prompts, Plan interaction maps to Devin's plan mode, and Full access maps to Bypass Permissions. Includes the ACP adapter, snapshot/status provider, skill discovery, text generation, web and mobile icons (official chain-link mark), settings entries, and docs. Verified against the real CLI (devin 3000.6.7, logged in): ACP initialize, session/new modes and model options, and model discovery through the shared probe runtime (DevinAcpCliProbe 3/3). Unit suites: DevinAcpSupport + DevinProvider 13/13, contracts 79/79, web 52/52; tsgo clean on contracts/server/web/mobile; vp lint clean on touched files. Built with Muse Spark in T3 Code. --- AGENTS.md | 4 +- README.md | 5 +- apps/mobile/src/components/ProviderIcon.tsx | 13 + .../src/provider/Drivers/DevinDriver.ts | 193 +++ .../src/provider/Drivers/DevinSkills.ts | 284 +++++ .../src/provider/Layers/DevinAdapter.ts | 1126 +++++++++++++++++ .../src/provider/Layers/DevinProvider.test.ts | 74 ++ .../src/provider/Layers/DevinProvider.ts | 502 ++++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + .../src/provider/Services/DevinAdapter.ts | 19 + .../src/provider/acp/DevinAcpCliProbe.test.ts | 60 + .../src/provider/acp/DevinAcpSupport.test.ts | 107 ++ .../src/provider/acp/DevinAcpSupport.ts | 109 ++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/providerStatusCache.ts | 1 + .../src/textGeneration/DevinTextGeneration.ts | 268 ++++ apps/web/src/components/Icons.tsx | 14 + .../src/components/chat/providerIconUtils.ts | 2 + .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 9 + apps/web/src/session-logic.ts | 6 + docs/internals/glossary.md | 2 +- docs/internals/providers.md | 6 +- docs/user/install.md | 3 +- docs/user/permission-modes.md | 2 +- packages/contracts/src/model.ts | 3 + packages/contracts/src/settings.ts | 33 + 27 files changed, 2841 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DevinDriver.ts create mode 100644 apps/server/src/provider/Drivers/DevinSkills.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.test.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.ts create mode 100644 apps/server/src/provider/Services/DevinAdapter.ts create mode 100644 apps/server/src/provider/acp/DevinAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.ts create mode 100644 apps/server/src/textGeneration/DevinTextGeneration.ts diff --git a/AGENTS.md b/AGENTS.md index 48a6374ab0af..10fb0b078fe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Devin) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Devin each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. diff --git a/README.md b/README.md index f1cc79730843..7a3caf92230c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and Devin. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,7 +13,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and Devin. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` @@ -21,6 +21,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. +> - Devin: install [Devin CLI](https://docs.devin.ai/cli) and run `devin auth login` ### Try it out (install-free) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index ef79e6af8e94..f592ce856fe5 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -70,6 +70,19 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "devin") { + // Official Devin chain-link mark (docs.devin.ai/logo/favicon.svg), + // nominative use. Mono fill adapts to the theme like the other marks. + return ( + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..cd165bd8f33a --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,193 @@ +/** + * DevinDriver — `ProviderDriver` for the Devin CLI (`devin acp`) runtime. + * + * Devin exposes an ACP-based CLI. Model catalog and capability refreshes + * happen during the managed provider status check via Devin's session + * config options. + * + * Text generation is supported via the ACP runtime — `makeDevinTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/DevinDriver + */ +import { DevinSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, + enrichDevinSnapshot, +} from "../Layers/DevinProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { probeDevinSkills } from "./DevinSkills.ts"; +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +const DRIVER_KIND = ProviderDriverKind.make("devin"); +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (options) => + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: options?.binaryPath?.trim() || "devin", + updateArgs: ["update"], + updateLockKey: "devin", + }), +}; + +export type DevinDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +export const DevinDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Devin", + supportsMultipleInstances: true, + }, + configSchema: DevinSettings, + defaultConfig: (): DevinSettings => decodeDevinSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DevinSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkDevinProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + // Model catalog and capabilities come from Devin's session + // config options during provider checks. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichDevinSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + stampIdentity, + httpClient, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Devin snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeDevinSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Devin skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/DevinSkills.ts b/apps/server/src/provider/Drivers/DevinSkills.ts new file mode 100644 index 000000000000..3e98d72f97a6 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.ts @@ -0,0 +1,284 @@ +/** + * DevinSkills — workspace-aware discovery and native invocation for Devin. + * + * Devin discovers Agent Skills recursively from user and project roots but + * its ACP command catalog only appears after opening a real session. Scanning + * the same roots avoids starting an agent and its MCP servers just to populate + * a composer menu. + * + * @module provider/Drivers/DevinSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import { parse as parseYamlDocument } from "yaml"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; +const MAX_SKILL_DEPTH = 10; +const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); +const MAX_SKILL_SCAN_ENTRIES = 10_000; +const MAX_SKILL_SCAN_BYTES = FileSystem.Size(8_000_000); + +interface DevinSkillFrontmatter { + readonly description?: string; + readonly displayName?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + readonly cliVisible: boolean; +} + +interface DevinSkillScanBudget { + remainingEntries: number; + remainingBytes: bigint; + exhausted: boolean; + incomplete: boolean; +} + +class DevinSkillsProbeError extends Schema.TaggedErrorClass()( + "DevinSkillsProbeError", + { + reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), + cwd: Schema.optional(Schema.String), + }, +) { + override get message(): string { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + return `Devin skill discovery${location} was incomplete (${this.reason}).`; + } +} + +const orUndefined = ( + effect: Effect.Effect, + budget?: DevinSkillScanBudget, +): Effect.Effect => + effect.pipe( + Effect.map((value): A | undefined => value), + Effect.catchTags({ + PlatformError: (error) => { + if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; + return Effect.void.pipe(Effect.as(undefined)); + }, + }), + ); + +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + return true; + case "false": + case "no": + case "off": + return false; + default: + return undefined; + } +} + +function parseSkillFrontmatter(contents: string): DevinSkillFrontmatter | undefined { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) return { cliVisible: true }; + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + + const record = parsed as Record; + const metadata = + typeof record.metadata === "object" && record.metadata !== null + ? (record.metadata as Record) + : undefined; + const rawSurfaces = metadata?.surfaces; + const surfaces = Array.isArray(rawSurfaces) + ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") + : typeof rawSurfaces === "string" + ? rawSurfaces.split(",") + : []; + const description = typeof record.description === "string" ? record.description.trim() : ""; + const displayName = typeof record.name === "string" ? record.name.trim() : ""; + return { + cliVisible: + surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), + ...(description ? { description } : {}), + ...(displayName ? { displayName } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), + }; +} + +const discoverSkillsInRoot = Effect.fn("discoverDevinSkillsInRoot")(function* (input: { + readonly directory: string; + readonly scope: "user" | "project"; + readonly budget: DevinSkillScanBudget; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skills: ServerProviderSkill[] = []; + if (input.budget.exhausted) return skills; + const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); + if (!rootDirectory) return skills; + const visitedDirectories = new Set(); + + const visit = Effect.fn("visitDevinSkillDirectory")(function* ( + directory: string, + depth: number, + ): Effect.fn.Return { + if (input.budget.exhausted) return; + const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); + if (!resolvedDirectory) { + return; + } + if ( + visitedDirectories.has(resolvedDirectory) || + (resolvedDirectory !== rootDirectory && + !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`)) + ) { + return; + } + visitedDirectories.add(resolvedDirectory); + + const skillPath = path.join(resolvedDirectory, "SKILL.md"); + const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); + if (skillInfo?.type === "File") { + let frontmatter: DevinSkillFrontmatter | undefined = { cliVisible: true }; + if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { + const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); + if (contents !== undefined) { + input.budget.remainingBytes -= skillInfo.size; + frontmatter = parseSkillFrontmatter(contents); + } + } + const name = path.basename(resolvedDirectory).trim(); + if (frontmatter?.cliVisible && name) { + skills.push({ + name, + path: skillPath, + scope: input.scope, + enabled: true, + ...(frontmatter.displayName && frontmatter.displayName !== name + ? { displayName: frontmatter.displayName } + : {}), + ...(frontmatter.description ? { description: frontmatter.description } : {}), + ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), + }); + } + } + + const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget); + if (!entries) { + return; + } + for (const entry of [...entries].sort()) { + if (input.budget.remainingEntries === 0) { + input.budget.exhausted = true; + return; + } + input.budget.remainingEntries -= 1; + const child = path.join(resolvedDirectory, entry); + const info = yield* orUndefined(fileSystem.stat(child), input.budget); + if (info?.type !== "Directory") continue; + if (depth >= MAX_SKILL_DEPTH) { + input.budget.exhausted = true; + return; + } + yield* visit(child, depth + 1); + } + }); + + yield* visit(rootDirectory, 0); + return skills; +}); + +const inspectDevinSkills = Effect.fn("inspectDevinSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const path = yield* Path.Path; + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const userRootsBelow = (base: string) => [ + { directory: path.join(base, ".devin", "skills"), scope: "user" as const }, + { directory: path.join(base, ".cognition", "skills"), scope: "user" as const }, + { directory: path.join(base, ".agents", "skills"), scope: "user" as const }, + ]; + const projectRootsBelow = (base: string) => [ + { directory: path.join(base, ".devin", "skills"), scope: "project" as const }, + { directory: path.join(base, ".cognition", "skills"), scope: "project" as const }, + { directory: path.join(base, ".agents", "skills"), scope: "project" as const }, + ]; + const roots = [...(cwd ? projectRootsBelow(cwd) : []), ...userRootsBelow(userHome)]; + + const skillsByName = new Map(); + const budget: DevinSkillScanBudget = { + remainingEntries: MAX_SKILL_SCAN_ENTRIES, + remainingBytes: MAX_SKILL_SCAN_BYTES, + exhausted: false, + incomplete: false, + }; + for (const root of roots) { + if (budget.exhausted) break; + const skills = yield* discoverSkillsInRoot({ ...root, budget }); + for (const skill of skills) { + if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); + } + } + return { + skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), + failureReason: budget.exhausted + ? ("scan-budget-exhausted" as const) + : budget.incomplete + ? ("filesystem-error" as const) + : undefined, + }; +}); + +export const discoverDevinSkills = Effect.fn("discoverDevinSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + return (yield* inspectDevinSkills(cwd, environment)).skills; +}); + +export const probeDevinSkills = Effect.fn("probeDevinSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const inspection = yield* inspectDevinSkills(cwd, environment); + if (inspection.failureReason) { + return yield* new DevinSkillsProbeError({ + reason: inspection.failureReason, + ...(cwd ? { cwd } : {}), + }); + } + return inspection.skills; +}); + +/** Devin invokes Agent Skills with `/name`; T3 composers insert `$name`. */ +export function hasDevinSkillMention(prompt: string): boolean { + return HAS_SKILL_MENTION_PATTERN.test(prompt); +} + +export function rewriteDevinSkillMentions(prompt: string, skillNames: ReadonlySet): string { + return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => + skillNames.has(name) ? `${prefix}/${name}` : match, + ); +} diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..682cae754c9a --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,1126 @@ +/** + * DevinAdapterLive — Devin CLI (`devin acp`) via ACP. + * + * @module DevinAdapterLive + */ + +import { + ApprovalRequestId, + type DevinSettings, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { applyDevinAcpModelSelection, makeDevinAcpRuntime } from "../acp/DevinAcpSupport.ts"; +import { type DevinAdapterShape } from "../Services/DevinAdapter.ts"; +import { resolveDevinAcpBaseModelId } from "./DevinProvider.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + discoverDevinSkills, + hasDevinSkillMention, + rewriteDevinSkillMentions, +} from "../Drivers/DevinSkills.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("devin"); +const DEVIN_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan"]; +const ACP_IMPLEMENT_MODE_ALIASES = [ + "accept-edits", + "code", + "agent", + "default", + "chat", + "implement", + "bypass", +]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface DevinAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`devin`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `devinSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface DevinSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + devinSkillNames: ReadonlySet | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseDevinResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== DEVIN_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + // Devin enforces approvals through `session/request_permission`, which T3 + // answers per turn. Sessions stay on the Code (`accept-edits`) mode so the + // agent can propose edits; `ask` would forbid code changes entirely. + // Full access maps to Bypass Permissions. + if (input.runtimeMode === "full-access") { + return ( + findModeByAliases(modeState.availableModes, ["bypass"])?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyDevinAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("devin"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Devin runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Devin ACP extension event.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.devin.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: DevinSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.devin.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: DevinSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: DevinAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const devinModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: DevinSessionContext; + + const resumeSessionId = parseDevinResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the DevinSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { devin: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveDevinSettings = options?.resolveSettings + ? yield* options.resolveSettings + : devinSettings; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeDevinAcpRuntime({ + devinSettings: effectiveDevinSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: devinModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: devinModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: DEVIN_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + devinSkillNames: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Devin runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Devin ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: DevinAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveDevinAcpBaseModelId(model); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const promptParts: Array = []; + const rawPrompt = input.input?.trim() ?? ""; + if (rawPrompt) { + let devinSkillNames = ctx.devinSkillNames; + if (hasDevinSkillMention(rawPrompt) && devinSkillNames === undefined) { + const skills = yield* discoverDevinSkills(ctx.session.cwd, options?.environment).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + devinSkillNames = new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + ctx.devinSkillNames = devinSkillNames; + } + const prompt = devinSkillNames + ? rewriteDevinSkillMentions(rawPrompt, devinSkillNames) + : rawPrompt; + promptParts.push({ type: "text", text: prompt }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + // Devin ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: DevinAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: DevinAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: DevinAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: DevinAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: DevinAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: DevinAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: DevinAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: DevinAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: DevinAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Devin session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies DevinAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts new file mode 100644 index 000000000000..43f6e63436b3 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDevinProviderSnapshot, + getDevinFallbackModels, + parseDevinAuthStatusOutput, + parseDevinVersionOutput, + resolveDevinAcpBaseModelId, +} from "./DevinProvider.ts"; + +describe("parseDevinVersionOutput", () => { + it("extracts the version from `devin --version` output", () => { + expect(parseDevinVersionOutput("devin 3000.6.7 (260a97c8)\n")).toBe("3000.6.7"); + }); + + it("returns null for unrecognized output", () => { + expect(parseDevinVersionOutput("")).toBeNull(); + expect(parseDevinVersionOutput("something else")).toBeNull(); + }); +}); + +describe("resolveDevinAcpBaseModelId", () => { + it("falls back to adaptive for empty selections", () => { + expect(resolveDevinAcpBaseModelId(null)).toBe("adaptive"); + expect(resolveDevinAcpBaseModelId(" ")).toBe("adaptive"); + }); + + it("passes explicit slugs through untouched", () => { + expect(resolveDevinAcpBaseModelId("glm-5-2")).toBe("glm-5-2"); + }); +}); + +describe("parseDevinAuthStatusOutput", () => { + it("reports authenticated when logged in", () => { + const parsed = parseDevinAuthStatusOutput( + { stdout: "Logged in (via Devin).\n", stderr: "", code: 0 }, + "3000.6.7", + ); + expect(parsed.status).toBe("ready"); + expect(parsed.auth.status).toBe("authenticated"); + expect(parsed.version).toBe("3000.6.7"); + }); + + it("reports unauthenticated when login is required", () => { + const parsed = parseDevinAuthStatusOutput( + { stdout: "Not logged in. Run `devin auth login`.\n", stderr: "", code: 1 }, + "3000.6.7", + ); + expect(parsed.status).toBe("error"); + expect(parsed.auth.status).toBe("unauthenticated"); + expect(parsed.message).toContain("devin auth login"); + }); +}); + +describe("getDevinFallbackModels", () => { + it("offers the adaptive default plus custom models", () => { + const models = getDevinFallbackModels({ customModels: ["my-model"] }); + expect(models.map((model) => model.slug)).toEqual(["adaptive", "my-model"]); + }); +}); + +describe("buildDevinProviderSnapshot", () => { + it("downgrades ready to warning when discovery fails", () => { + const snapshot = buildDevinProviderSnapshot({ + checkedAt: "2026-09-04T00:00:00.000Z", + devinSettings: { enabled: true, binaryPath: "devin", customModels: [] }, + parsed: { version: "3000.6.7", status: "ready", auth: { status: "authenticated" } }, + discoveredModels: [], + discoveryWarning: "Devin ACP model discovery failed.", + }); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Devin ACP model discovery failed."); + }); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..d0e24a5c14e6 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,502 @@ +import type { + DevinSettings, + ModelCapabilities, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; + +const DEVIN_PRESENTATION = { + displayName: "Devin", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 20_000; +const DEVIN_CLI_INSTALLATION_DOCS_URL = "https://docs.devin.ai/cli"; +const DEVIN_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ + "Devin ACP model discovery failed.", + "Devin CLI setup may be incomplete; install or enable the Devin CLI, restart T3 Code, and try again.", + `See ${DEVIN_CLI_INSTALLATION_DOCS_URL}.`, + "Check server logs for ACP details.", +].join(" "); + +export function buildInitialDevinProviderSnapshot( + devinSettings: DevinSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getDevinFallbackModels(devinSettings); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Devin CLI availability...", + }, + }); + }); +} + +interface DevinAcpDiscoveredModel { + readonly slug: string; + readonly name: string; +} + +function buildDevinDiscoveredModels( + discoveredModels: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return discoveredModels.flatMap((model) => { + if (!model.slug || seen.has(model.slug)) return []; + seen.add(model.slug); + return [ + { + slug: model.slug, + name: model.name || model.slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function flattenModelSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") return []; + return configOption.options.flatMap((entry) => + "value" in entry + ? [{ slug: entry.value.trim(), name: entry.name.trim() }] + : entry.options.map((option) => ({ + slug: option.value.trim(), + name: option.name.trim(), + })), + ); +} + +const makeDevinAcpProbeRuntime = (devinSettings: DevinSettings, environment?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: devinSettings.binaryPath, + args: ["acp"], + cwd: process.cwd(), + ...(environment ? { env: environment } : {}), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + authMethodId: "devin-browser", + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + Effect.provideService(Crypto.Crypto, crypto), + ); + return runtime; + }); + +const withDevinAcpProbeRuntime = ( + devinSettings: DevinSettings, + useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, + environment?: NodeJS.ProcessEnv, +) => + makeDevinAcpProbeRuntime(devinSettings, environment).pipe( + Effect.flatMap(useRuntime), + Effect.scoped, + ); + +export const discoverDevinModelsViaAcp = ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +) => + withDevinAcpProbeRuntime( + devinSettings, + (acp) => + Effect.gen(function* () { + yield* acp.start(); + const configOptions = yield* acp.getConfigOptions; + const modelOption = configOptions.find((option) => option.id === "model"); + return buildDevinDiscoveredModels(flattenModelSelectOptions(modelOption)); + }), + environment, + ); + +export function getDevinFallbackModels( + devinSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings( + [{ slug: "adaptive", name: "Adaptive", isCustom: false, capabilities: EMPTY_CAPABILITIES }], + devinSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +/** `devin --version` prints e.g. `devin 3000.6.7 (260a97c8)`. */ +export function parseDevinVersionOutput(output: string): string | null { + const match = output.trim().match(/^devin\s+([^\s(]+)/im); + return match?.[1]?.trim() || null; +} + +export interface DevinAuthResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +export function parseDevinAuthStatusOutput( + result: CommandResult, + version: string | null, +): DevinAuthResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lower = combined.toLowerCase(); + const emailMatch = combined.match(/email:\s*([^\s]+)/i); + const email = emailMatch?.[1]?.trim(); + + if ( + lower.includes("not logged in") || + lower.includes("login required") || + lower.includes("authentication required") || + lower.includes("not authenticated") + ) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Devin CLI is not authenticated. Run `devin auth login` and try again.", + }; + } + + if (result.code === 0 && (lower.includes("logged in") || email)) { + return { + version, + status: "ready", + auth: { + status: "authenticated", + ...(email ? { email } : {}), + }, + }; + } + + if (result.code === 0) { + return { version, status: "ready", auth: { status: "unknown" } }; + } + + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Devin CLI authentication status.", + }; +} + +function buildDevinCliCommandMissingMessage(binaryPath: string): string { + return [ + `Devin CLI command \`${binaryPath}\` was not found.`, + `Install or enable the Devin CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${DEVIN_CLI_INSTALLATION_DOCS_URL}.`, + ].join(" "); +} + +export function buildDevinProviderSnapshot(input: { + readonly checkedAt: string; + readonly devinSettings: DevinSettings; + readonly parsed: DevinAuthResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const parts = [input.parsed.message, input.discoveryWarning].filter( + (message): message is string => !!message?.trim(), + ); + const message = parts.length > 0 ? parts.join(" ") : undefined; + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: input.devinSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + input.devinSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + +const runDevinCommand = ( + devinSettings: DevinSettings, + args: ReadonlyArray, + environment?: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnCommand = yield* resolveSpawnCommand( + devinSettings.binaryPath, + args, + environment ? { env: environment } : {}, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, + }); + + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getDevinFallbackModels(devinSettings); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runDevinCommand(devinSettings, ["--version"], environment).pipe( + Effect.timeoutOption(8_000), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + yield* Effect.logWarning("Devin CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? buildDevinCliCommandMissingMessage(devinSettings.binaryPath) + : "Failed to execute Devin CLI health check.", + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but timed out while running `devin --version`.", + }, + }); + } + + const version = parseDevinVersionOutput(versionProbe.success.value.stdout); + + const authProbe = yield* runDevinCommand(devinSettings, ["auth", "status"], environment).pipe( + Effect.timeoutOption(8_000), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? buildDevinCliCommandMissingMessage(devinSettings.binaryPath) + : "Failed to verify Devin CLI authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI timed out while running `devin auth status`.", + }, + }); + } + + const parsed = parseDevinAuthStatusOutput(authProbe.success.value, version); + + let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; + if (parsed.auth.status !== "unauthenticated") { + const discoveryExit = yield* Effect.exit( + discoverDevinModelsViaAcp(devinSettings, environment).pipe( + Effect.timeoutOption(DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Devin ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + discoveryWarning = DEVIN_ACP_MODEL_DISCOVERY_FAILED_MESSAGE; + } else if (Option.isNone(discoveryExit.value)) { + discoveryWarning = `Devin ACP model discovery timed out after ${DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } else if (discoveryExit.value.value.length === 0) { + discoveryWarning = "Devin ACP model discovery returned no built-in models."; + } else { + discoveredModels = discoveryExit.value; + } + } + return buildDevinProviderSnapshot({ + checkedAt, + devinSettings, + parsed, + discoveredModels: Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); + +/** Background maintenance enrichment. Model data comes from status checks only. */ +export const enrichDevinSnapshot = (input: { + readonly settings: DevinSettings; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value) => value); + + if (!settings.enabled || snapshot.auth.status === "unauthenticated") { + return Effect.void; + } + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Devin version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }).pipe(Effect.asVoid), + ), + ); +}; + +export function resolveDevinAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "adaptive"; +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6942d7f9dd52..d544137841dd 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2319,6 +2319,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "devin", "grok", "opencode", ]); diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts new file mode 100644 index 000000000000..ddf01fa8cc98 --- /dev/null +++ b/apps/server/src/provider/Services/DevinAdapter.ts @@ -0,0 +1,19 @@ +/** + * DevinAdapter — shape type for the Devin provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/DevinDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module DevinAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * DevinAdapterShape — per-instance Devin adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface DevinAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts new file mode 100644 index 000000000000..8bced93afeba --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts @@ -0,0 +1,60 @@ +/** + * Optional integration check against a real `devin acp` install. + * Enable with: T3_DEVIN_ACP_PROBE=1 vp test run src/provider/acp/DevinAcpCliProbe.test.ts + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { discoverDevinModelsViaAcp } from "../Layers/DevinProvider.ts"; + +const devinLayer = AcpSessionRuntime.layer({ + spawn: { + command: "devin", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-probe", version: "0.0.0" }, + authMethodId: "devin-browser", +}); + +describe.runIf(process.env.T3_DEVIN_ACP_PROBE === "1")("Devin ACP CLI probe", () => { + it.effect("initialize and authenticate against real devin acp", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + expect(typeof started.sessionId).toBe("string"); + }).pipe(Effect.provide(devinLayer), Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/new returns mode and model config options", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + const configOptions = started.sessionSetupResult.configOptions ?? []; + const ids = configOptions.map((option) => option.id); + expect(ids).toContain("mode"); + expect(ids).toContain("model"); + const modeState = yield* runtime.getModeState; + expect(modeState?.availableModes.map((mode) => mode.id)).toEqual( + expect.arrayContaining(["accept-edits", "ask", "plan", "bypass"]), + ); + }).pipe(Effect.provide(devinLayer), Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("discovers models through the shared probe runtime", () => + Effect.gen(function* () { + const models = yield* discoverDevinModelsViaAcp({ + enabled: true, + binaryPath: "devin", + customModels: [], + }); + expect(models.length).toBeGreaterThan(0); + expect(models.some((model) => model.slug === "adaptive")).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts new file mode 100644 index 000000000000..d718832cb539 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { applyDevinAcpModelSelection, buildDevinAcpSpawnInput } from "./DevinAcpSupport.ts"; + +const devinConfigOptions: ReadonlyArray = [ + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "accept-edits", + options: [ + { value: "accept-edits", name: "Code" }, + { value: "ask", name: "Ask" }, + { value: "plan", name: "Plan" }, + { value: "bypass", name: "Bypass Permissions" }, + ], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "glm-5-2", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "glm-5-2", name: "GLM-5.2" }, + ], + }, +]; + +const makeRecordingRuntime = () => { + const calls: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const runtime = { + getConfigOptions: Effect.succeed(devinConfigOptions), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + return { runtime, calls }; +}; + +describe("buildDevinAcpSpawnInput", () => { + it("builds the default Devin ACP command", () => { + expect(buildDevinAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "devin", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("uses the configured binary path and forwards the environment", () => { + const env = { ...process.env, DEVIN_MODEL: "opus" }; + expect( + buildDevinAcpSpawnInput({ binaryPath: "/usr/local/bin/devin" }, "/tmp/project", env), + ).toEqual({ + command: "/usr/local/bin/devin", + args: ["acp"], + cwd: "/tmp/project", + env, + }); + }); +}); + +describe("applyDevinAcpModelSelection", () => { + it.effect("sets the model config option when it differs", () => + Effect.gen(function* () { + const { runtime, calls } = makeRecordingRuntime(); + yield* applyDevinAcpModelSelection({ + runtime, + model: "adaptive", + selections: [], + mapError: ({ step, configId }) => `${step}:${configId}`, + }); + expect(calls).toEqual([{ configId: "model", value: "adaptive" }]); + }), + ); + + it.effect("skips the model reselect when it already matches", () => + Effect.gen(function* () { + const { runtime, calls } = makeRecordingRuntime(); + yield* applyDevinAcpModelSelection({ + runtime, + model: "glm-5-2", + selections: [], + mapError: ({ step, configId }) => `${step}:${configId}`, + }); + expect(calls).toEqual([]); + }), + ); + + it.effect("ignores empty model requests", () => + Effect.gen(function* () { + const { runtime, calls } = makeRecordingRuntime(); + yield* applyDevinAcpModelSelection({ + runtime, + model: " ", + selections: [], + mapError: ({ step, configId }) => `${step}:${configId}`, + }); + expect(calls).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..cbecfc968661 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,109 @@ +import { type DevinSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type DevinAcpRuntimeDevinSettings = Pick; + +export interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeDevinSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export interface DevinAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option"; + readonly configId?: string; +} + +export function buildDevinAcpSpawnInput( + devinSettings: DevinAcpRuntimeDevinSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: devinSettings?.binaryPath || "devin", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeDevinAcpRuntime = ( + input: DevinAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const { childProcessSpawner, devinSettings, environment, ...runtimeOptions } = input; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeOptions, + spawn: buildDevinAcpSpawnInput(devinSettings, input.cwd, environment), + authMethodId: "devin-browser", + }).pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +interface DevinAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; +} + +/** Devin exposes models as `model` config select. Skip no-op reselects. */ +export function applyDevinAcpModelSelection(input: { + readonly runtime: DevinAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: DevinAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const configOptions = yield* input.runtime.getConfigOptions; + const findOption = (id: string) => configOptions.find((option) => option.id === id); + + const requestedModel = input.model?.trim(); + if (requestedModel) { + const modelOption = findOption("model"); + if (modelOption && modelOption.currentValue !== requestedModel) { + yield* input.runtime + .setConfigOption("model", requestedModel) + .pipe( + Effect.mapError((cause) => + input.mapError({ cause, step: "set-config-option", configId: "model" }), + ), + ); + } + } + + for (const selection of input.selections ?? []) { + const option = findOption(selection.id); + if (!option || option.id === "model") continue; + yield* input.runtime + .setConfigOption(option.id, selection.value) + .pipe( + Effect.mapError((cause) => + input.mapError({ cause, step: "set-config-option", configId: option.id }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..8a5f3edc89ec 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DevinDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | AntigravityDriverEnv; @@ -53,4 +55,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ "grok", "opencode", "antigravity", + "devin", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 000000000000..bf806d76da76 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,268 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DevinSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyDevinAcpModelSelection, + makeDevinAcpRuntime, +} from "../provider/acp/DevinAcpSupport.ts"; + +const DEVIN_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Build a Devin text-generation closure bound to a specific `DevinSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runDevinJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeDevinAcpRuntime({ + devinSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyDevinAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + new TextGenerationError({ + operation, + detail: + step === "set-config-option" + ? `Failed to set Devin ACP config option "${configId}" for text generation.` + : "Failed to set Devin ACP base model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(DEVIN_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Devin ACP request was cancelled." + : "Devin Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("DevinTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runDevinJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("DevinTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runDevinJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("DevinTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("DevinTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..9a49216d2b7f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,20 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +// Devin chain-link mark, nominative use to identify the Devin provider. +// Upstream: official Devin docs favicon (https://docs.devin.ai/logo/favicon.svg), +// Cognition trademark. `fill` adapted to currentColor for themes. +export const DevinIcon: Icon = ({ className, ...props }) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..dcb43a01c49e 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -3,6 +3,7 @@ import { AntigravityIcon, ClaudeAI, CursorIcon, + DevinIcon, GrokIcon, Icon, OpenAI, @@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, }; export type ModelEsque = { diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7b98dbe42991..60812d557134 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -26,6 +26,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial **Providers** when you want to use them. For Antigravity, select the environment in provider settings, then install and sign in there. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index f7da2fc32164..d61141c589f6 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -18,7 +18,7 @@ without prompting; commands and anything else still stop for approval. **Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as -OpenCode and Antigravity) fall back to asking, like Supervised. +OpenCode, Antigravity, and Devin) fall back to asking, like Supervised. **Full access**: allow commands and edits without prompts. The default. The agent runs unattended until it finishes or asks a question of its own. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index d217a0cc2cbb..d31b7c476746 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -157,6 +158,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [DEVIN_DRIVER_KIND]: "Devin", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9f1f0c846ac3..2602b0fa6fda 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -675,6 +675,31 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Devin CLI binary.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -895,6 +920,7 @@ export const ServerSettings = Schema.Struct({ grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + devin: DevinSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -1050,6 +1076,12 @@ const AntigravitySettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -1103,6 +1135,7 @@ export const ServerSettingsPatch = Schema.Struct({ grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), + devin: Schema.optionalKey(DevinSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual