diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 374738d0aeca..f257c42d4f7e 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -37,6 +37,31 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+ if (props.provider === "devin") {
+ return (
+
+ );
+ }
+
if (props.provider === "grok") {
const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F";
return (
diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index 4e8295733141..7345ebb6de25 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -36,6 +36,7 @@ function providerDisplayLabel(provider: {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent") return "Claude";
+ if (provider.driver === "devin") return "Devin";
return provider.instanceId;
}
diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts
new file mode 100644
index 000000000000..b8afcc1151b8
--- /dev/null
+++ b/apps/server/src/provider/Drivers/DevinDriver.ts
@@ -0,0 +1,186 @@
+/**
+ * DevinDriver — built-in driver for the Devin CLI via ACP.
+ *
+ * @module DevinDriver
+ */
+import { DevinSettings, ProviderDriverKind } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+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 { HttpClient } from "effect/unstable/http";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerConfig } from "../../config.ts";
+import * as ServerSettingsService from "../../serverSettings.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 {
+ makeCachedProviderMaintenanceResolution,
+ makeManualOnlyProviderMaintenanceCapabilities,
+ makeProviderMaintenanceCapabilities,
+ type ProviderMaintenanceCapabilitiesResolver,
+ resolveProviderMaintenanceCapabilitiesEffect,
+} from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts";
+const decodeDevinSettings = Schema.decodeSync(DevinSettings);
+
+const DRIVER_KIND = ProviderDriverKind.make("devin");
+// devin updates itself (`devin update`), so the resolved executable is its
+// own updater. No executable means nothing to update, not "whatever is on
+// PATH".
+const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
+ resolve: (context) =>
+ Effect.succeed(
+ context
+ ? makeProviderMaintenanceCapabilities({
+ provider: DRIVER_KIND,
+ packageName: null,
+ updateExecutable: context.resolvedCommandPath,
+ updateArgs: ["update"],
+ updateLockKey: "devin",
+ platform: context.platform,
+ })
+ : makeManualOnlyProviderMaintenanceCapabilities({
+ provider: DRIVER_KIND,
+ packageName: null,
+ }),
+ ),
+};
+
+export type DevinDriverEnv =
+ | BackgroundPolicy.BackgroundPolicy
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ProviderEventLoggers
+ | ServerConfig
+ | ServerSettingsService.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.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 resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
+ resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
+ binaryPath: effectiveConfig.binaryPath,
+ env: processEnv,
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ ),
+ );
+
+ 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),
+ );
+
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ resolveMaintenance,
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ // Model catalog comes from `devin models list --format json` during
+ // provider checks; enrichment only republishes version advisories.
+ enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
+ resolveMaintenance().pipe(
+ Effect.flatMap((maintenanceCapabilities) =>
+ enrichDevinSnapshot({
+ settings: settings.provider,
+ snapshot: currentSnapshot,
+ maintenanceCapabilities,
+ enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
+ publishSnapshot,
+ stampIdentity,
+ httpClient,
+ }),
+ ),
+ ),
+ }).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,
+ adapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts
new file mode 100644
index 000000000000..f3d4a0a365f2
--- /dev/null
+++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts
@@ -0,0 +1,335 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodePath from "node:path";
+import * as NodeOS from "node:os";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeURL from "node:url";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import {
+ ApprovalRequestId,
+ DevinSettings,
+ ProviderDriverKind,
+ type ProviderRuntimeEvent,
+ ThreadId,
+ ProviderInstanceId,
+} from "@t3tools/contracts";
+
+import { ServerConfig } from "../../config.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import type { DevinAdapterShape } from "../Services/DevinAdapter.ts";
+import { makeDevinAdapter } from "./DevinAdapter.ts";
+import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
+const decodeDevinSettings = Schema.decodeSync(DevinSettings);
+
+// Test-local service tag so the rest of the file can keep using `yield* DevinAdapter`.
+class DevinAdapter extends Context.Service()(
+ "t3/provider/Layers/DevinAdapter.test/DevinAdapter",
+) {}
+
+const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
+const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
+
+async function makeMockAgentWrapper(extraEnv?: Record) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mock-"));
+ return writeFakeCli({
+ directory: dir,
+ name: "fake-devin",
+ env: extraEnv ?? {},
+ source: execScriptSource({ scriptPath: mockAgentPath }),
+ });
+}
+
+async function makeProbeWrapper(
+ requestLogPath: string,
+ argvLogPath: string,
+ extraEnv?: Record,
+) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-probe-"));
+ return writeFakeCli({
+ directory: dir,
+ name: "fake-devin",
+ env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv },
+ source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }),
+ });
+}
+
+async function readArgvLog(filePath: string) {
+ const raw = await NodeFSP.readFile(filePath, "utf8");
+ return raw
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => line.split("\t").filter((token) => token.length > 0));
+}
+
+async function readJsonLines(filePath: string) {
+ const raw = await NodeFSP.readFile(filePath, "utf8");
+ return raw
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as Record);
+}
+
+// Tests mutate `ServerSettingsService` mid-flight (setting
+// `providers.devin.binaryPath` to a mock ACP wrapper); see the equivalent
+// resolver comment in CursorAdapter.test.ts.
+const makeResolveDevinSettings = Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsService;
+ return yield* Effect.succeed(
+ serverSettings.getSettings.pipe(
+ Effect.map((snapshot) => snapshot.providers.devin),
+ Effect.orDie,
+ ),
+ );
+});
+
+const devinAdapterTestLayer = it.layer(
+ Layer.effect(
+ DevinAdapter,
+ Effect.gen(function* () {
+ const devinConfig = decodeDevinSettings({});
+ const resolveSettings = yield* makeResolveDevinSettings;
+ return yield* makeDevinAdapter(devinConfig, { resolveSettings });
+ }),
+ ).pipe(
+ Layer.provideMerge(ServerSettingsService.layerTest()),
+ Layer.provideMerge(
+ ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-devin-adapter-test-",
+ }),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ ),
+);
+
+devinAdapterTestLayer("DevinAdapter", (it) => {
+ it.effect(
+ "starts a session, applies the selected model, and maps the prompt flow to runtime events",
+ () =>
+ Effect.gen(function* () {
+ const adapter = yield* DevinAdapter;
+ const settings = yield* ServerSettingsService;
+ const threadId = ThreadId.make("devin-mock-thread");
+ const workspace = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-workspace-")),
+ );
+ const requestLogPath = NodePath.join(workspace, "requests.ndjson");
+ const argvLogPath = NodePath.join(workspace, "argv.txt");
+ yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8"));
+ const wrapperPath = yield* Effect.promise(() =>
+ makeProbeWrapper(requestLogPath, argvLogPath),
+ );
+ yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } });
+
+ const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.threadId === threadId),
+ Stream.takeUntil((event) => event.type === "turn.completed"),
+ Stream.runCollect,
+ Effect.forkChild,
+ );
+
+ const session = yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("devin"),
+ cwd: workspace,
+ runtimeMode: "full-access",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("devin"),
+ model: "composer-2",
+ },
+ });
+
+ assert.equal(session.provider, "devin");
+ assert.deepStrictEqual(session.resumeCursor, {
+ schemaVersion: 1,
+ sessionId: "mock-session-1",
+ });
+
+ yield* adapter.sendTurn({
+ threadId,
+ input: "hello mock",
+ attachments: [],
+ });
+
+ const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
+ const types = runtimeEvents.map((event) => event.type);
+ for (const t of [
+ "session.started",
+ "session.state.changed",
+ "thread.started",
+ "turn.started",
+ "content.delta",
+ "turn.completed",
+ ] as const) {
+ assert.include(types, t);
+ }
+
+ const delta = runtimeEvents.find((event) => event.type === "content.delta");
+ if (delta?.type === "content.delta") {
+ assert.equal(delta.payload.delta, "hello from mock");
+ }
+
+ yield* adapter.stopSession(threadId);
+
+ // full-access spawns `devin --permission-mode bypass acp`.
+ const argv = yield* Effect.promise(() => readArgvLog(argvLogPath));
+ assert.deepStrictEqual(argv, [["--permission-mode", "bypass", "acp"]]);
+
+ // Model selection goes through the model config option. The mock has
+ // no `bypass` mode, so full-access leaves the session mode alone.
+ const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
+ const configRequests = requests
+ .filter((entry) => entry.method === "session/set_config_option")
+ .map((entry) => {
+ const params = entry.params as { configId: string; value: unknown };
+ return { configId: params.configId, value: params.value };
+ });
+ assert.includeDeepMembers(configRequests, [{ configId: "model", value: "composer-2" }]);
+ }),
+ );
+
+ it.effect("maps approval-required to --permission-mode normal at spawn", () =>
+ Effect.gen(function* () {
+ const adapter = yield* DevinAdapter;
+ const settings = yield* ServerSettingsService;
+ const threadId = ThreadId.make("devin-approval-thread");
+ const workspace = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-approval-")),
+ );
+ const argvLogPath = NodePath.join(workspace, "argv.txt");
+ const requestLogPath = NodePath.join(workspace, "requests.ndjson");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeProbeWrapper(requestLogPath, argvLogPath),
+ );
+ yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } });
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("devin"),
+ cwd: workspace,
+ runtimeMode: "approval-required",
+ });
+ yield* adapter.stopSession(threadId);
+
+ const argv = yield* Effect.promise(() => readArgvLog(argvLogPath));
+ assert.deepStrictEqual(argv, [["--permission-mode", "normal", "acp"]]);
+
+ // The mock starts in the read-only `ask` mode; approval-required must
+ // restore the least-privileged writable mode so work can proceed.
+ const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
+ const modeRequests = requests
+ .filter((entry) => entry.method === "session/set_config_option")
+ .map((entry) => {
+ const params = entry.params as { configId: string; value: unknown };
+ return { configId: params.configId, value: params.value };
+ })
+ .filter((params) => params.configId === "mode");
+ assert.deepStrictEqual(modeRequests, [{ configId: "mode", value: "code" }]);
+ }),
+ );
+
+ it.effect("routes plan turns to the agent's plan/architect mode", () =>
+ Effect.gen(function* () {
+ const adapter = yield* DevinAdapter;
+ const settings = yield* ServerSettingsService;
+ const threadId = ThreadId.make("devin-plan-thread");
+ const workspace = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-plan-")),
+ );
+ const requestLogPath = NodePath.join(workspace, "requests.ndjson");
+ const argvLogPath = NodePath.join(workspace, "argv.txt");
+ yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8"));
+ const wrapperPath = yield* Effect.promise(() =>
+ makeProbeWrapper(requestLogPath, argvLogPath),
+ );
+ yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } });
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("devin"),
+ cwd: workspace,
+ runtimeMode: "auto-accept-edits",
+ });
+ yield* adapter.sendTurn({
+ threadId,
+ input: "plan this out",
+ attachments: [],
+ interactionMode: "plan",
+ });
+ yield* adapter.stopSession(threadId);
+
+ const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
+ const modeRequests = requests
+ .filter((entry) => entry.method === "session/set_config_option")
+ .map((entry) => {
+ const params = entry.params as { configId: string; value: unknown };
+ return { configId: params.configId, value: params.value };
+ })
+ .filter((params) => params.configId === "mode");
+ // Session start maps auto-accept-edits -> code; the plan turn then
+ // maps to the mock's architect mode.
+ assert.deepStrictEqual(modeRequests, [
+ { configId: "mode", value: "code" },
+ { configId: "mode", value: "architect" },
+ ]);
+ }),
+ );
+
+ it.effect("surfaces a permission request and resolves it on respondToRequest", () =>
+ Effect.gen(function* () {
+ const adapter = yield* DevinAdapter;
+ const settings = yield* ServerSettingsService;
+ const threadId = ThreadId.make("devin-permission-thread");
+
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),
+ );
+ yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } });
+
+ const approvalRequestedFiber = yield* adapter.streamEvents.pipe(
+ Stream.filter(
+ (event): event is Extract =>
+ event.type === "request.opened" && event.threadId === threadId,
+ ),
+ Stream.take(1),
+ Stream.runCollect,
+ Effect.forkChild,
+ );
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("devin"),
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ });
+
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "read package.json", attachments: [] })
+ .pipe(Effect.forkChild);
+
+ const approvalEvents = yield* Fiber.join(approvalRequestedFiber);
+ const approvalRequest = Array.from(approvalEvents)[0];
+ assert.isDefined(approvalRequest);
+ assert.equal(approvalRequest.payload.requestType, "exec_command_approval");
+
+ yield* adapter.respondToRequest(
+ threadId,
+ ApprovalRequestId.make(String(approvalRequest.requestId)),
+ "accept",
+ );
+
+ const turn = yield* Fiber.join(sendTurnFiber);
+ assert.equal(turn.threadId, threadId);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts
new file mode 100644
index 000000000000..2d1cac9933ab
--- /dev/null
+++ b/apps/server/src/provider/Layers/DevinAdapter.ts
@@ -0,0 +1,1087 @@
+/**
+ * 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,
+ type UserInputQuestion,
+} 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 { buildRuntimeInstructions } from "../RuntimeInstructions.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 { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts";
+import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
+import {
+ applyDevinAcpModelSelection,
+ makeDevinAcpRuntime,
+ resolveDevinModeId,
+} from "../acp/DevinAcpSupport.ts";
+import { type DevinAdapterShape } from "../Services/DevinAdapter.ts";
+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
+
+const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
+
+const PROVIDER = ProviderDriverKind.make("devin");
+const DEVIN_RESUME_VERSION = 1 as const;
+
+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. See `CursorAdapterLiveOptions`
+ * for the rationale — tests that mutate settings mid-flight pass a resolver
+ * so the spawn 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;
+ /** 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 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: EffectAcpErrors.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 = resolveDevinModeId({
+ 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;
+}
+
+/**
+ * Map a `session/elicitation` form request to user-input questions. Enum
+ * properties become fixed choices; everything else takes a custom answer.
+ * URL-mode elicitations cannot be answered in the chat UI.
+ */
+function toElicitationContentValue(
+ value: unknown,
+): string | number | boolean | ReadonlyArray | undefined {
+ if (typeof value === "string" || typeof value === "boolean") return value;
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (Array.isArray(value)) {
+ const strings = value.filter((entry): entry is string => typeof entry === "string");
+ return strings.length === value.length ? strings : undefined;
+ }
+ return undefined;
+}
+
+function extractElicitationQuestions(
+ params: Extract,
+): ReadonlyArray {
+ const properties = params.requestedSchema.properties ?? {};
+ return Object.entries(properties).map(([id, property]) => {
+ const options =
+ "enum" in property && Array.isArray(property.enum)
+ ? property.enum
+ .filter((value): value is string => typeof value === "string")
+ .map((value) => ({ label: value, description: value }))
+ : [];
+ return {
+ id,
+ header: property.title?.trim() || id,
+ question: property.description?.trim() || property.title?.trim() || params.message,
+ options,
+ allowCustomAnswer: true,
+ multiSelect: property.type === "array",
+ };
+ });
+}
+
+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 mapRequestFailure = (effect: Effect.Effect) =>
+ effect.pipe(
+ Effect.mapError(
+ (cause) =>
+ new EffectAcpErrors.AcpTransportError({
+ detail: "Failed to process Devin ACP 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) =>
+ 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,
+ ) =>
+ 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: "acp.jsonrpc",
+ method: "session/update",
+ 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,
+ });
+
+ const effectiveDevinSettings = options?.resolveSettings
+ ? yield* options.resolveSettings
+ : devinSettings;
+
+ const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
+ const acp = yield* makeDevinAcpRuntime({
+ devinSettings: effectiveDevinSettings,
+ ...(options?.environment || mcpSession?.agentDeviceEnvironment
+ ? {
+ environment: McpProviderSession.withAgentDeviceEnvironment(
+ options?.environment ?? process.env,
+ mcpSession,
+ ),
+ }
+ : {}),
+ childProcessSpawner,
+ cwd,
+ runtimeMode: input.runtimeMode,
+ ...(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.handleElicitation((params) =>
+ mapRequestFailure(
+ Effect.gen(function* () {
+ yield* logNative(input.threadId, "session/elicitation", params);
+ if (params.mode !== "form") {
+ return { action: { action: "cancel" as const } };
+ }
+ const questions = extractElicitationQuestions(params);
+ if (questions.length === 0) {
+ return { action: { action: "cancel" as const } };
+ }
+ const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
+ const runtimeRequestId = RuntimeRequestId.make(requestId);
+ const answers = yield* Deferred.make();
+ pendingUserInputs.set(requestId, { answers });
+ yield* offerRuntimeEvent({
+ type: "user-input.requested",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { questions },
+ raw: {
+ source: "acp.jsonrpc",
+ method: "session/elicitation",
+ payload: params,
+ },
+ });
+ const resolved = yield* Deferred.await(answers);
+ pendingUserInputs.delete(requestId);
+ yield* offerRuntimeEvent({
+ type: "user-input.resolved",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId: ctx?.activeTurnId,
+ requestId: runtimeRequestId,
+ payload: { answers: resolved },
+ });
+ if (Object.keys(resolved).length === 0) {
+ return { action: { action: "cancel" as const } };
+ }
+ const content: Record> =
+ {};
+ for (const [key, value] of Object.entries(resolved)) {
+ const coerced = toElicitationContentValue(value);
+ if (coerced !== undefined) {
+ content[key] = coerced;
+ }
+ }
+ return {
+ action: {
+ action: "accept" as const,
+ content,
+ },
+ };
+ }),
+ ),
+ );
+ yield* acp.handleRequestPermission((params) =>
+ mapRequestFailure(
+ Effect.gen(function* () {
+ yield* logNative(input.threadId, "session/request_permission", params);
+ 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,
+ 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);
+ yield* emitPlanUpdate(ctx, event.payload, event.rawPayload);
+ return;
+ case "ToolCallUpdated":
+ yield* logNative(ctx.threadId, "session/update", event.rawPayload);
+ 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);
+ 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 — see the
+ // equivalent comment in CursorAdapter for why `forkIn` is
+ // required here.
+ 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;
+ 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 },
+ });
+ }
+
+ const promptParts: Array = [];
+ const rawPrompt = input.input?.trim() ?? "";
+ if (rawPrompt) {
+ promptParts.push({ type: "text", text: rawPrompt });
+ }
+ 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.",
+ });
+ }
+
+ // ACP has no system-message field; keep runtime context separate from the user's text.
+ const result = yield* ctx.acp
+ .prompt({
+ prompt: [
+ ...promptParts,
+ {
+ type: "text",
+ text: buildRuntimeInstructions({ harness: "Devin", model }),
+ },
+ ],
+ })
+ .pipe(
+ Effect.mapError((error) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
+ ),
+ );
+
+ yield* ctx.acp.drainEvents;
+
+ 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,
+ };
+
+ // 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/elicitation",
+ 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..f60ec809e4c9
--- /dev/null
+++ b/apps/server/src/provider/Layers/DevinProvider.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { devinModelsFromCatalog, parseDevinAuthStatus } from "./DevinProvider.ts";
+
+const SAMPLE_MODELS_JSON = {
+ families: [
+ {
+ family_uid: "swe-2",
+ family_label: "SWE-2",
+ slug: "swe-2",
+ aliases: ["swe"],
+ variants: [
+ { model_uid: "swe-2-high", label: "SWE-2 High", is_new: true },
+ { model_uid: "swe-2-medium", label: "SWE-2 Medium" },
+ { model_uid: "swe-2-max", label: "SWE-2 Max" },
+ ],
+ },
+ {
+ family_uid: "devin-core",
+ family_label: "Adaptive",
+ slug: "devin-core",
+ variants: [{ model_uid: "adaptive", label: "Adaptive" }],
+ },
+ ],
+};
+
+describe("devinModelsFromCatalog", () => {
+ it("groups effort variants into one row per family", () => {
+ const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON);
+ expect(models.map((model) => model.slug)).toEqual(["swe-2", "adaptive"]);
+
+ const swe2 = models[0]!;
+ expect(swe2).toMatchObject({
+ name: "SWE-2",
+ badge: "new",
+ isCustom: false,
+ isDefault: false,
+ });
+ // Family slug must not become `subProvider` — the picker strips that
+ // prefix from the label, leaving bare effort names like "High".
+ expect(swe2.subProvider).toBeUndefined();
+ // Variant uids stay reachable as aliases so stored flat selections like
+ // `swe-2-high` still resolve to the grouped row.
+ expect(swe2.aliases).toEqual(["swe", "swe-2-high", "swe-2-medium", "swe-2-max"]);
+
+ const effortDescriptor = swe2.capabilities?.optionDescriptors?.find(
+ (descriptor) => descriptor.id === "effort",
+ );
+ expect(effortDescriptor).toMatchObject({
+ type: "select",
+ label: "Reasoning",
+ });
+ expect(
+ effortDescriptor?.type === "select" ? effortDescriptor.options.map((o) => o.id) : [],
+ ).toEqual(["medium", "high", "max"]);
+ });
+
+ it("marks adaptive as the default model", () => {
+ const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON);
+ expect(models.find((model) => model.slug === "adaptive")?.isDefault).toBe(true);
+ });
+
+ it("returns an empty list for missing or malformed input", () => {
+ expect(devinModelsFromCatalog(undefined)).toEqual([]);
+ expect(devinModelsFromCatalog({})).toEqual([]);
+ expect(
+ devinModelsFromCatalog({ families: [{ variants: [{ model_uid: " ", label: "x" }] }] }),
+ ).toEqual([]);
+ });
+});
+
+describe("parseDevinAuthStatus", () => {
+ it("marks a logged-in CLI as authenticated", () => {
+ const result = parseDevinAuthStatus({
+ code: 0,
+ stdout:
+ "Logged in (via Devin).\n Name: adam\n Email: adam@example.com\n",
+ stderr: "",
+ });
+ expect(result.status).toBe("ready");
+ expect(result.auth.status).toBe("authenticated");
+ expect(result.auth.email).toBe("adam@example.com");
+ });
+
+ it("marks a logged-out CLI as unauthenticated", () => {
+ const result = parseDevinAuthStatus({
+ code: 1,
+ stdout: "",
+ stderr: "Not logged in.",
+ });
+ expect(result.status).toBe("error");
+ expect(result.auth.status).toBe("unauthenticated");
+ });
+
+ it("treats an unrecognized zero-exit output as ready with unknown auth", () => {
+ const result = parseDevinAuthStatus({ code: 0, stdout: "something else", stderr: "" });
+ expect(result.status).toBe("ready");
+ expect(result.auth.status).toBe("unknown");
+ });
+});
diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts
new file mode 100644
index 000000000000..fe3d0f1bf201
--- /dev/null
+++ b/apps/server/src/provider/Layers/DevinProvider.ts
@@ -0,0 +1,448 @@
+/**
+ * DevinProvider — status + model-catalog snapshot for the Devin CLI.
+ *
+ * Probes `devin --version`, `devin auth status`, and
+ * `devin models list --format json`. ACP `session/new` exposes the same model
+ * list as a `model` config option, but spawning a session just to read the
+ * catalog would boot the agent on every health refresh — the JSON listing is
+ * cheaper and needs no session.
+ *
+ * @module DevinProvider
+ */
+import type {
+ DevinSettings,
+ ModelCapabilities,
+ ServerProvider,
+ ServerProviderAuth,
+ ServerProviderModel,
+ ServerProviderState,
+} from "@t3tools/contracts";
+import { ProviderDriverKind } 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 Option from "effect/Option";
+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 { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+import {
+ buildServerProvider,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ providerModelsFromSettings,
+ spawnAndCollect,
+ type CommandResult,
+ type ServerProviderDraft,
+} from "../providerSnapshot.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ type ProviderMaintenanceCapabilities,
+} from "../providerMaintenance.ts";
+const PROVIDER_KIND = ProviderDriverKind.make("devin");
+
+const DEVIN_PRESENTATION = {
+ displayName: "Devin",
+ showInteractionModeToggle: true,
+} as const;
+const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] };
+
+const ABOUT_TIMEOUT_MS = 10_000;
+const MODELS_TIMEOUT_MS = 15_000;
+const DEVIN_CLI_INSTALLATION_DOCS_URL = "https://devin.ai/cli";
+
+function joinProviderMessages(...messages: ReadonlyArray): string | undefined {
+ const parts: Array = [];
+ for (const message of messages) {
+ const trimmed = message?.trim();
+ if (trimmed) {
+ parts.push(trimmed);
+ }
+ }
+ return parts.length > 0 ? parts.join(" ") : undefined;
+}
+
+function buildDevinCliCommandMissingMessage(binaryPath: string): string {
+ return [
+ `Devin CLI command \`${binaryPath}\` was not found.`,
+ `Install the Devin CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`,
+ `See ${DEVIN_CLI_INSTALLATION_DOCS_URL}.`,
+ ].join(" ");
+}
+
+// ── `devin models list --format json` ────────────────────────────────────
+
+import { devinModelsFromCatalog, type DevinModelsListJson } from "../devinModelCatalog.ts";
+
+export { devinModelsFromCatalog };
+
+function parseDevinModelsJson(raw: string): DevinModelsListJson | undefined {
+ const trimmed = raw.trim();
+ if (!trimmed.startsWith("{")) {
+ return undefined;
+ }
+ try {
+ const parsed = JSON.parse(trimmed) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return undefined;
+ }
+ return parsed as DevinModelsListJson;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Static fallback so the picker is never empty when discovery fails. Uses
+ * the same grouped shape as live discovery: one row per base with effort
+ * descriptors.
+ */
+const DEVIN_FALLBACK_MODELS: ReadonlyArray = devinModelsFromCatalog({
+ families: [
+ {
+ family_uid: "adaptive",
+ family_label: "Adaptive",
+ variants: [{ model_uid: "adaptive" }],
+ },
+ {
+ family_uid: "swe-2",
+ family_label: "SWE-2",
+ aliases: ["swe"],
+ variants: [
+ { model_uid: "swe-2-high" },
+ { model_uid: "swe-2-medium" },
+ { model_uid: "swe-2-max" },
+ ],
+ },
+ {
+ family_uid: "swe-1.7",
+ family_label: "SWE-1.7",
+ variants: [{ model_uid: "swe-1-7" }, { model_uid: "swe-1-7-medium" }],
+ },
+ {
+ family_uid: "swe-1.7-lightning",
+ family_label: "SWE-1.7 Lightning",
+ variants: [{ model_uid: "swe-1-7-lightning" }, { model_uid: "swe-1-7-lightning-medium" }],
+ },
+ {
+ family_uid: "swe-1.6-fast",
+ family_label: "SWE-1.6 Fast",
+ variants: [{ model_uid: "swe-1-6-fast" }],
+ },
+ ],
+});
+
+function getDevinFallbackModels(devinSettings: DevinSettings): ReadonlyArray {
+ return providerModelsFromSettings(
+ DEVIN_FALLBACK_MODELS,
+ devinSettings.customModels,
+ EMPTY_CAPABILITIES,
+ );
+}
+
+// ── `devin auth status` ──────────────────────────────────────────────────
+
+export interface DevinProbeResult {
+ readonly version: string | null;
+ readonly status: Exclude;
+ readonly auth: ServerProviderAuth;
+ readonly message?: string;
+}
+
+export function parseDevinAuthStatus(
+ result: CommandResult,
+): Pick {
+ const combined = `${result.stdout}\n${result.stderr}`;
+ const lower = combined.toLowerCase();
+
+ if (
+ lower.includes("not logged in") ||
+ lower.includes("not authenticated") ||
+ lower.includes("no credentials")
+ ) {
+ return {
+ status: "error",
+ auth: { status: "unauthenticated" },
+ message: "Devin CLI is not authenticated. Run `devin auth login` and try again.",
+ };
+ }
+
+ if (lower.includes("logged in")) {
+ const emailMatch =
+ result.stdout.match(/^\s*Email:\s+(.+)$/m) ?? result.stdout.match(/^\s*Name:\s+(.+)$/m);
+ const email = emailMatch?.[1]?.trim();
+ return {
+ status: "ready",
+ auth: {
+ status: "authenticated",
+ ...(email ? { email } : {}),
+ },
+ };
+ }
+
+ if (result.code === 0) {
+ return { status: "ready", auth: { status: "unknown" } };
+ }
+ return {
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Could not verify Devin CLI authentication status.",
+ };
+}
+
+const runDevinCommand = (
+ devinSettings: DevinSettings,
+ args: ReadonlyArray,
+ environment?: NodeJS.ProcessEnv,
+) =>
+ Effect.gen(function* () {
+ const spawnCommand = yield* resolveSpawnCommand(
+ devinSettings.binaryPath || "devin",
+ args,
+ environment ? { env: environment } : {},
+ );
+ return yield* spawnAndCollect(
+ devinSettings.binaryPath || "devin",
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ ...(environment ? { env: environment } : { extendEnv: true }),
+ shell: spawnCommand.shell,
+ }),
+ );
+ }).pipe(Effect.scoped);
+
+export function buildDevinProviderSnapshot(input: {
+ readonly checkedAt: string;
+ readonly devinSettings: DevinSettings;
+ readonly parsed: DevinProbeResult;
+ readonly discoveredModels?: ReadonlyArray;
+ readonly discoveryWarning?: string;
+}): ServerProviderDraft {
+ const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
+ return buildServerProvider({
+ driver: PROVIDER_KIND,
+ 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 } : {}),
+ },
+ });
+}
+
+export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* (
+ devinSettings: DevinSettings,
+ environment?: NodeJS.ProcessEnv,
+): Effect.fn.Return<
+ ServerProviderDraft,
+ never,
+ ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
+> {
+ 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 probeFailure = (message: string, installed = true): ServerProviderDraft =>
+ buildServerProvider({
+ presentation: DEVIN_PRESENTATION,
+ enabled: devinSettings.enabled,
+ checkedAt,
+ models: fallbackModels,
+ probe: {
+ installed,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message,
+ },
+ });
+
+ // Version probe first — it proves the binary exists and is executable.
+ const versionProbe = yield* runDevinCommand(devinSettings, ["--version"], environment).pipe(
+ Effect.timeoutOption(ABOUT_TIMEOUT_MS),
+ Effect.result,
+ );
+
+ if (Result.isFailure(versionProbe)) {
+ const error = versionProbe.failure;
+ yield* Effect.logWarning("Devin CLI health check failed.", { errorTag: error._tag });
+ return probeFailure(
+ isCommandMissingCause(error)
+ ? buildDevinCliCommandMissingMessage(devinSettings.binaryPath || "devin")
+ : "Failed to execute Devin CLI health check.",
+ !isCommandMissingCause(error),
+ );
+ }
+ if (Option.isNone(versionProbe.success)) {
+ return probeFailure("Devin CLI is installed but timed out while running `devin --version`.");
+ }
+ const version = parseGenericCliVersion(
+ `${versionProbe.success.value.stdout}\n${versionProbe.success.value.stderr}`,
+ );
+
+ // Auth probe.
+ const authProbe = yield* runDevinCommand(devinSettings, ["auth", "status"], environment).pipe(
+ Effect.timeoutOption(ABOUT_TIMEOUT_MS),
+ Effect.result,
+ );
+
+ const parsed: DevinProbeResult = (() => {
+ if (Result.isFailure(authProbe)) {
+ return {
+ version,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Could not run `devin auth status`.",
+ };
+ }
+ if (Option.isNone(authProbe.success)) {
+ return {
+ version,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Devin CLI timed out while running `devin auth status`.",
+ };
+ }
+ const auth = parseDevinAuthStatus(authProbe.success.value);
+ return { version, ...auth };
+ })();
+
+ // Model catalog — skipped when unauthenticated since the CLI cannot list
+ // anything useful then.
+ let discoveredModels = Option.none>();
+ let discoveryWarning: string | undefined;
+ if (parsed.auth.status !== "unauthenticated") {
+ const modelsExit = yield* Effect.exit(
+ runDevinCommand(devinSettings, ["models", "list", "--format", "json"], environment).pipe(
+ Effect.timeoutOption(MODELS_TIMEOUT_MS),
+ ),
+ );
+ if (Exit.isFailure(modelsExit)) {
+ yield* Effect.logWarning("Devin model discovery failed", {
+ errorTag: causeErrorTag(modelsExit.cause),
+ });
+ discoveryWarning = "Devin model discovery failed.";
+ } else if (Option.isNone(modelsExit.value)) {
+ discoveryWarning = "Devin model discovery timed out.";
+ } else {
+ const models = devinModelsFromCatalog(parseDevinModelsJson(modelsExit.value.value.stdout));
+ if (models.length === 0) {
+ discoveryWarning = "Devin model discovery returned no models.";
+ } else {
+ discoveredModels = Option.some(models);
+ }
+ }
+ }
+
+ return buildDevinProviderSnapshot({
+ checkedAt,
+ devinSettings,
+ parsed,
+ discoveredModels: Option.getOrElse(
+ Option.filter(discoveredModels, (models) => models.length > 0),
+ () => fallbackModels,
+ ),
+ ...(discoveryWarning ? { discoveryWarning } : {}),
+ });
+});
+
+export const buildInitialDevinProviderSnapshot = (
+ devinSettings: DevinSettings,
+): Effect.Effect =>
+ 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...",
+ },
+ });
+ });
+
+/**
+ * Background maintenance enrichment: republishes update/version advisory
+ * metadata without re-running model discovery.
+ */
+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),
+ ),
+ );
+};
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
index 988c89e1e679..a96115d5d6a9 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -732,6 +732,92 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
]);
});
+ it("drops stale Devin variant rows missing from a successful refresh", () => {
+ const previousProvider = {
+ instanceId: ProviderInstanceId.make("devin"),
+ driver: ProviderDriverKind.make("devin"),
+ status: "ready",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ checkedAt: "2026-09-11T00:00:00.000Z",
+ version: "3000.10.21",
+ models: [
+ {
+ slug: "swe-2-high",
+ name: "SWE-2 High",
+ isCustom: false,
+ capabilities: null,
+ },
+ {
+ slug: "swe-2-medium",
+ name: "SWE-2 Medium",
+ isCustom: false,
+ capabilities: null,
+ },
+ ],
+ slashCommands: [],
+ skills: [],
+ } as const satisfies ServerProvider;
+ const refreshedProvider = {
+ ...previousProvider,
+ checkedAt: "2026-09-11T00:01:00.000Z",
+ models: [
+ {
+ slug: "swe-2",
+ name: "SWE-2",
+ isCustom: false,
+ capabilities: createModelCapabilities({
+ optionDescriptors: [
+ selectDescriptor("effort", "Reasoning", [
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High" },
+ ]),
+ ],
+ }),
+ },
+ ],
+ } satisfies ServerProvider;
+
+ assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
+ ...refreshedProvider.models,
+ ]);
+ });
+
+ it("retains Devin models when model discovery degrades to a warning", () => {
+ const previousProvider = {
+ instanceId: ProviderInstanceId.make("devin"),
+ driver: ProviderDriverKind.make("devin"),
+ status: "ready",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ checkedAt: "2026-09-11T00:00:00.000Z",
+ version: "3000.10.21",
+ models: [
+ {
+ slug: "swe-2",
+ name: "SWE-2",
+ isCustom: false,
+ capabilities: null,
+ },
+ ],
+ slashCommands: [],
+ skills: [],
+ } as const satisfies ServerProvider;
+ const refreshedProvider = {
+ ...previousProvider,
+ status: "warning",
+ checkedAt: "2026-09-11T00:01:00.000Z",
+ models: [],
+ message: "Devin model discovery failed.",
+ } satisfies ServerProvider;
+
+ assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
+ ...previousProvider.models,
+ ]);
+ });
+
it("retains stale OpenCode models when a refresh fails", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
@@ -2615,6 +2701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
"claudeAgent",
"codex",
"cursor",
+ "devin",
"grok",
"opencode",
]);
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts
index a8e6caf95aa7..3d8de62ded0f 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.ts
@@ -103,12 +103,18 @@ export function upsertProviderWorkspaceSnapshot(
const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity");
const isCodex = provider.driver === ProviderDriverKind.make("codex");
- if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) {
+ const isDevin = provider.driver === ProviderDriverKind.make("devin");
+ if (
+ !isAntigravity &&
+ !isCodex &&
+ !isDevin &&
+ provider.driver !== ProviderDriverKind.make("opencode")
+ ) {
return true;
}
if (
- (isAntigravity || isCodex) &&
+ (isAntigravity || isCodex || isDevin) &&
(!provider.enabled || provider.auth.status === "unauthenticated")
) {
return false;
@@ -121,8 +127,14 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean =>
const isPendingInitialProbe =
provider.enabled && !provider.installed && provider.status === "warning";
const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
+ // Devin reports a failed `models list` as a warning on an installed CLI —
+ // keep the last good catalog until discovery succeeds again.
+ const isDevinDiscoveryDegraded = isDevin && provider.installed && provider.status === "warning";
return (
- isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail
+ isPendingAntigravityAuthentication ||
+ isPendingInitialProbe ||
+ didInstalledProviderProbeFail ||
+ isDevinDiscoveryDegraded
);
};
diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts
new file mode 100644
index 000000000000..be32a25cd1aa
--- /dev/null
+++ b/apps/server/src/provider/Services/DevinAdapter.ts
@@ -0,0 +1,17 @@
+/**
+ * DevinAdapter — shape type for the Devin provider adapter.
+ *
+ * Mirrors {@link ./CursorAdapter.ts}: the driver model
+ * ({@link ../Drivers/DevinDriver.ts}) bundles one adapter per instance as a
+ * captured closure, so only the shape interface lives here.
+ *
+ * @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/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts
new file mode 100644
index 000000000000..84c8ae82f988
--- /dev/null
+++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts
@@ -0,0 +1,189 @@
+import { assert, describe, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import type * as EffectAcpSchema from "effect-acp/schema";
+
+import type { AcpSessionModeState } from "./AcpRuntimeModel.ts";
+import {
+ applyDevinAcpModelSelection,
+ buildDevinAcpSpawnInput,
+ resolveDevinModeId,
+} from "./DevinAcpSupport.ts";
+
+describe("buildDevinAcpSpawnInput", () => {
+ it("builds the default Devin ACP command", () => {
+ assert.deepStrictEqual(buildDevinAcpSpawnInput(undefined, "/tmp/project"), {
+ command: "devin",
+ args: ["acp"],
+ cwd: "/tmp/project",
+ });
+ });
+
+ it("honors the configured binary path", () => {
+ assert.deepStrictEqual(
+ buildDevinAcpSpawnInput({ binaryPath: "/usr/local/bin/devin" }, "/tmp/project"),
+ {
+ command: "/usr/local/bin/devin",
+ args: ["acp"],
+ cwd: "/tmp/project",
+ },
+ );
+ });
+
+ it.each([
+ ["approval-required", ["--permission-mode", "normal", "acp"]],
+ ["auto-accept-edits", ["--permission-mode", "accept-edits", "acp"]],
+ ["auto", ["--permission-mode", "smart", "acp"]],
+ ["full-access", ["--permission-mode", "bypass", "acp"]],
+ ] as const)("maps %s to %j", (runtimeMode, args) => {
+ assert.deepStrictEqual(
+ buildDevinAcpSpawnInput(undefined, "/tmp/project", undefined, runtimeMode).args,
+ args,
+ );
+ });
+});
+
+const DEVIN_MODES: AcpSessionModeState["availableModes"] = [
+ { id: "accept-edits", name: "Code" },
+ { id: "smart", name: "Smart" },
+ { id: "ask", name: "Ask" },
+ { id: "plan", name: "Plan" },
+ { id: "bypass", name: "Bypass Permissions" },
+];
+
+function modeState(currentModeId: string): AcpSessionModeState {
+ return { currentModeId, availableModes: DEVIN_MODES };
+}
+
+describe("resolveDevinModeId", () => {
+ it("maps plan interaction mode to Devin's plan mode", () => {
+ assert.strictEqual(
+ resolveDevinModeId({
+ interactionMode: "plan",
+ runtimeMode: "full-access",
+ modeState: modeState("accept-edits"),
+ }),
+ "plan",
+ );
+ });
+
+ it.each([
+ ["auto-accept-edits", "accept-edits"],
+ ["auto", "smart"],
+ ["full-access", "bypass"],
+ ] as const)("maps %s to %s", (runtimeMode, expected) => {
+ assert.strictEqual(
+ resolveDevinModeId({
+ interactionMode: "default",
+ runtimeMode,
+ modeState: modeState("accept-edits"),
+ }),
+ expected,
+ );
+ });
+
+ it("leaves approval-required untouched while a writable mode is active", () => {
+ assert.isUndefined(
+ resolveDevinModeId({
+ interactionMode: "default",
+ runtimeMode: "approval-required",
+ modeState: modeState("accept-edits"),
+ }),
+ );
+ });
+
+ it("escapes a read-only mode for approval-required after a plan turn", () => {
+ assert.strictEqual(
+ resolveDevinModeId({
+ interactionMode: "default",
+ runtimeMode: "approval-required",
+ modeState: modeState("plan"),
+ }),
+ "accept-edits",
+ );
+ });
+
+ it("returns undefined when no mode state is known", () => {
+ assert.isUndefined(
+ resolveDevinModeId({
+ interactionMode: "plan",
+ runtimeMode: "full-access",
+ modeState: undefined,
+ }),
+ );
+ });
+});
+
+describe("applyDevinAcpModelSelection", () => {
+ it.effect("applies the model through the model config option", () =>
+ Effect.gen(function* () {
+ const calls: Array<
+ | { readonly type: "model"; readonly value: string }
+ | { readonly type: "config"; readonly configId: string; readonly value: string | boolean }
+ > = [];
+
+ const runtime = {
+ getConfigOptions: Effect.succeed([
+ {
+ id: "model",
+ name: "Model",
+ category: "model",
+ type: "select" as const,
+ currentValue: "adaptive",
+ options: [{ value: "adaptive", name: "Adaptive" }],
+ },
+ {
+ id: "mode",
+ name: "Mode",
+ category: "mode",
+ type: "select" as const,
+ currentValue: "accept-edits",
+ options: [{ value: "accept-edits", name: "Code" }],
+ },
+ ]),
+ setModel: (value: string) =>
+ Effect.sync(() => {
+ calls.push({ type: "model", value });
+ }),
+ setConfigOption: (configId: string, value: string | boolean) =>
+ Effect.sync(() => {
+ calls.push({ type: "config", configId, value });
+ }),
+ };
+
+ yield* applyDevinAcpModelSelection({
+ runtime,
+ model: "swe-2-high",
+ selections: [{ id: "mode", value: "plan" }],
+ mapError: ({ cause }) => cause,
+ });
+
+ assert.deepStrictEqual(calls, [
+ { type: "model", value: "swe-2-high" },
+ { type: "config", configId: "mode", value: "plan" },
+ ]);
+ }),
+ );
+
+ it.effect("skips set_model when no model is selected", () =>
+ Effect.gen(function* () {
+ let called = false;
+ const runtime = {
+ getConfigOptions: Effect.succeed([] as ReadonlyArray),
+ setModel: () =>
+ Effect.sync(() => {
+ called = true;
+ }),
+ setConfigOption: () => Effect.void,
+ };
+
+ yield* applyDevinAcpModelSelection({
+ runtime,
+ model: undefined,
+ selections: [],
+ mapError: ({ cause }) => cause,
+ });
+
+ assert.isFalse(called);
+ }),
+ );
+});
diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts
new file mode 100644
index 000000000000..e622b0c616f4
--- /dev/null
+++ b/apps/server/src/provider/acp/DevinAcpSupport.ts
@@ -0,0 +1,266 @@
+/**
+ * DevinAcpSupport — spawn and session helpers for `devin acp`.
+ *
+ * Devin's ACP server exposes a merged "Session Mode" selector (accept-edits,
+ * smart, ask, plan, bypass) plus a `model` config option. The CLI's most
+ * restrictive writable permission mode (`normal`) is not in that selector —
+ * it is only reachable through the `--permission-mode` spawn flag, so T3's
+ * "approval-required" maps to the flag at spawn and to `accept-edits` (the
+ * least-privileged writable ACP mode) when the session mode must be restored
+ * in-session.
+ *
+ * @module DevinAcpSupport
+ */
+import {
+ type DevinSettings,
+ type ProviderOptionSelection,
+ type RuntimeMode,
+} 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 type * as EffectAcpSchema from "effect-acp/schema";
+
+import type { AcpSessionModeState } from "./AcpRuntimeModel.ts";
+import { collectSessionConfigOptionValues } from "./AcpRuntimeModel.ts";
+import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
+import { isDevinModelDimOptionId, resolveDevinModelUid } from "../devinModelCatalog.ts";
+
+type DevinAcpRuntimeDevinSettings = Pick;
+
+/**
+ * T3 runtime mode → Devin `--permission-mode`. `normal` auto-approves
+ * read-only work and prompts for risky actions; `bypass` auto-approves all.
+ */
+function devinAcpPermissionArgs(runtimeMode?: RuntimeMode): ReadonlyArray {
+ switch (runtimeMode) {
+ case "approval-required":
+ return ["--permission-mode", "normal"];
+ case "auto-accept-edits":
+ return ["--permission-mode", "accept-edits"];
+ case "auto":
+ return ["--permission-mode", "smart"];
+ case "full-access":
+ return ["--permission-mode", "bypass"];
+ default:
+ return [];
+ }
+}
+
+export interface DevinAcpRuntimeInput extends Omit<
+ AcpSessionRuntime.AcpSessionRuntimeOptions,
+ "authMethodId" | "clientCapabilities" | "spawn"
+> {
+ readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
+ readonly devinSettings: DevinAcpRuntimeDevinSettings | null | undefined;
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly runtimeMode?: RuntimeMode;
+}
+
+export interface DevinAcpModelSelectionErrorContext {
+ readonly cause: EffectAcpErrors.AcpError;
+ readonly step: "set-config-option" | "set-model";
+ readonly configId?: string;
+}
+
+// Devin reads and writes the workspace itself; T3 does not proxy fs or
+// terminal I/O for it.
+export const DEVIN_ACP_CLIENT_CAPABILITIES = {
+ fs: { readTextFile: false, writeTextFile: false },
+ terminal: false,
+} satisfies NonNullable;
+
+export function buildDevinAcpSpawnInput(
+ devinSettings: DevinAcpRuntimeDevinSettings | null | undefined,
+ cwd: string,
+ environment?: NodeJS.ProcessEnv,
+ runtimeMode?: RuntimeMode,
+): AcpSessionRuntime.AcpSpawnInput {
+ return {
+ command: devinSettings?.binaryPath || "devin",
+ args: [...devinAcpPermissionArgs(runtimeMode), "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 acpContext = yield* Layer.build(
+ AcpSessionRuntime.layer({
+ ...input,
+ spawn: buildDevinAcpSpawnInput(
+ input.devinSettings,
+ input.cwd,
+ input.environment,
+ input.runtimeMode,
+ ),
+ authMethodId: "devin-browser",
+ clientCapabilities: DEVIN_ACP_CLIENT_CAPABILITIES,
+ }).pipe(
+ Layer.provide(
+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
+ ),
+ ),
+ );
+ return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
+ Effect.provide(acpContext),
+ );
+ });
+
+const DEVIN_PLAN_MODE_ALIASES = ["plan", "architect"];
+const DEVIN_READ_ONLY_MODE_IDS = new Set(["plan", "ask"]);
+// First match in the session's advertised modes wins.
+const DEVIN_MODE_BY_RUNTIME_MODE: Partial>> = {
+ "auto-accept-edits": ["accept-edits", "code"],
+ auto: ["smart"],
+ "full-access": ["bypass"],
+};
+
+function normalizeModeSearchText(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
+}
+
+function findModeByAliases(
+ modes: ReadonlyArray<{ readonly id: string; readonly name: string }>,
+ aliases: ReadonlyArray,
+): { readonly id: string } | undefined {
+ const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
+ for (const alias of normalizedAliases) {
+ const exact = modes.find(
+ (mode) => mode.id.toLowerCase() === alias || mode.name.toLowerCase() === alias,
+ );
+ if (exact) return exact;
+ }
+ for (const alias of normalizedAliases) {
+ const partial = modes.find((mode) =>
+ normalizeModeSearchText(`${mode.id} ${mode.name}`).includes(alias),
+ );
+ if (partial) return partial;
+ }
+ return undefined;
+}
+
+/**
+ * Resolve the Devin session mode for a turn. Returns `undefined` when the
+ * current mode already expresses the requested posture, leaving it alone.
+ */
+export function resolveDevinModeId(input: {
+ readonly interactionMode: "default" | "plan" | 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, DEVIN_PLAN_MODE_ALIASES)?.id;
+ }
+
+ const preferredIds = DEVIN_MODE_BY_RUNTIME_MODE[input.runtimeMode];
+ if (preferredIds !== undefined) {
+ return findModeByAliases(modeState.availableModes, preferredIds)?.id;
+ }
+
+ // approval-required: `normal` is spawn-flag only. If an earlier plan/ask
+ // turn left a read-only mode active, restore the least-privileged writable
+ // mode so the agent can keep working under supervision.
+ if (DEVIN_READ_ONLY_MODE_IDS.has(modeState.currentModeId)) {
+ return (
+ findModeByAliases(modeState.availableModes, ["accept-edits", "code"])?.id ??
+ modeState.availableModes.find((mode) => !DEVIN_READ_ONLY_MODE_IDS.has(mode.id))?.id
+ );
+ }
+ return undefined;
+}
+
+interface DevinAcpModelSelectionRuntime {
+ readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"];
+ readonly setConfigOption: (
+ configId: string,
+ value: string | boolean,
+ ) => Effect.Effect;
+ readonly setModel: (model: string) => Effect.Effect;
+}
+
+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* () {
+ // Devin's session config surface is just `model` + `mode`. The picker
+ // groups variants into one row per family with effort/speed/context
+ // options, so resolve those dims back to a concrete advertised uid
+ // before applying the model.
+ const configOptions = yield* input.runtime.getConfigOptions;
+ const modelOption =
+ configOptions.find((candidate) => candidate.category === "model") ??
+ configOptions.find((candidate) => candidate.id === "model");
+ const advertisedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : [];
+
+ const model = input.model?.trim();
+ if (model) {
+ const resolvedModel =
+ advertisedModelValues.length > 0
+ ? resolveDevinModelUid({
+ model,
+ selections: input.selections,
+ advertisedValues: advertisedModelValues,
+ currentValue:
+ modelOption && "currentValue" in modelOption
+ ? typeof modelOption.currentValue === "string"
+ ? modelOption.currentValue
+ : undefined
+ : undefined,
+ })
+ : model;
+ yield* input.runtime.setModel(resolvedModel).pipe(
+ Effect.mapError((cause) =>
+ input.mapError({
+ cause,
+ step: "set-model",
+ }),
+ ),
+ );
+ }
+
+ // Model-dim selections are already folded into the uid; apply any other
+ // selection that matches an advertised config option so future Devin
+ // options light up without an adapter change.
+ for (const selection of input.selections ?? []) {
+ if (isDevinModelDimOptionId(selection.id)) continue;
+ const option = configOptions.find((candidate) => candidate.id === selection.id);
+ if (!option || option.id === "model") continue;
+ yield* input.runtime
+ .setConfigOption(
+ option.id,
+ typeof selection.value === "boolean" ? selection.value : String(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..8630252ec2d6 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;
@@ -50,6 +52,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray {
+ it("parses effort, speed, and context suffixes", () => {
+ expect(parseDevinModelUid("claude-opus-5-low-fast")).toEqual({
+ base: "claude-opus-5",
+ effort: "low",
+ speed: "fast",
+ context: undefined,
+ });
+ expect(parseDevinModelUid("glm-5-2-max-1m")).toEqual({
+ base: "glm-5-2",
+ effort: "max",
+ speed: undefined,
+ context: "1m",
+ });
+ expect(parseDevinModelUid("gpt-5-6-sol-none-priority")).toEqual({
+ base: "gpt-5-6-sol",
+ effort: "none",
+ speed: "priority",
+ context: undefined,
+ });
+ });
+
+ it("parses underscore-separated uids", () => {
+ expect(parseDevinModelUid("MODEL_GPT_5_2_LOW")).toEqual({
+ base: "model-gpt-5-2",
+ effort: "low",
+ speed: undefined,
+ context: undefined,
+ });
+ expect(parseDevinModelUid("MODEL_CLAUDE_4_5_OPUS_THINKING")).toEqual({
+ base: "model-claude-4-5-opus",
+ effort: "thinking",
+ speed: undefined,
+ context: undefined,
+ });
+ });
+
+ it("leaves bare uids untouched", () => {
+ for (const uid of ["adaptive", "kimi-k2-6", "swe-1-7-lightning", "MODEL_PRIVATE_11"]) {
+ const dims = parseDevinModelUid(uid);
+ expect(dims.effort).toBeUndefined();
+ expect(dims.speed).toBeUndefined();
+ expect(dims.context).toBeUndefined();
+ expect(dims.base).toBe(normalizeDevinModelId(uid));
+ }
+ });
+});
+
+describe("devinModelsFromCatalog grouping", () => {
+ it("emits a Speed select for fast variants", () => {
+ const models = devinModelsFromCatalog({
+ families: [
+ {
+ family_label: "Claude Opus 5",
+ family_uid: "claude-opus-5",
+ variants: [
+ { model_uid: "claude-opus-5-low" },
+ { model_uid: "claude-opus-5-high" },
+ { model_uid: "claude-opus-5-low-fast" },
+ { model_uid: "claude-opus-5-high-fast" },
+ ],
+ },
+ ],
+ });
+ const opus = models[0]!;
+ expect(opus.slug).toBe("claude-opus-5");
+ expect(opus.name).toBe("Claude Opus 5");
+ const descriptors = opus.capabilities?.optionDescriptors ?? [];
+ const speed = descriptors.find((d) => d.id === "speed");
+ expect(speed?.type === "select" ? speed.options.map((o) => o.id) : []).toEqual([
+ "standard",
+ "fast",
+ ]);
+ const effort = descriptors.find((d) => d.id === "effort");
+ expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([
+ "low",
+ "high",
+ ]);
+ });
+
+ it("emits a Default effort choice and Context select for bare-variant families", () => {
+ const models = devinModelsFromCatalog({
+ families: [
+ {
+ family_label: "GLM-5.2",
+ family_uid: "glm-5.2",
+ variants: [
+ { model_uid: "glm-5-2" },
+ { model_uid: "glm-5-2-max" },
+ { model_uid: "glm-5-2-1m" },
+ { model_uid: "glm-5-2-max-1m" },
+ { model_uid: "glm-5-2-none" },
+ { model_uid: "glm-5-2-none-1m" },
+ ],
+ },
+ ],
+ });
+ const glm = models[0]!;
+ const descriptors = glm.capabilities?.optionDescriptors ?? [];
+ const effort = descriptors.find((d) => d.id === "effort");
+ expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([
+ "default",
+ "none",
+ "max",
+ ]);
+ const context = descriptors.find((d) => d.id === "context");
+ expect(context?.type === "select" ? context.options.map((o) => o.id) : []).toEqual([
+ "200k",
+ "1m",
+ ]);
+ });
+
+ it("groups families whose opaque uids share no base via label dims", () => {
+ const models = devinModelsFromCatalog({
+ families: [
+ {
+ family_label: "Claude Sonnet 4.5",
+ family_uid: "claude-sonnet-4.5",
+ slug: "claude-sonnet-4.5",
+ variants: [
+ { model_uid: "MODEL_PRIVATE_2", label: "Claude Sonnet 4.5" },
+ { model_uid: "MODEL_PRIVATE_3", label: "Claude Sonnet 4.5 Thinking" },
+ ],
+ },
+ ],
+ });
+ expect(models).toHaveLength(1);
+ const row = models[0]!;
+ expect(row.slug).toBe("MODEL_PRIVATE_2");
+ expect(row.name).toBe("Claude Sonnet 4.5");
+ const effort = row.capabilities?.optionDescriptors?.find((d) => d.id === "effort");
+ expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([
+ "MODEL_PRIVATE_2",
+ "MODEL_PRIVATE_3",
+ ]);
+ expect(effort?.type === "select" ? effort.options.map((o) => o.label) : []).toEqual([
+ "Default",
+ "Thinking",
+ ]);
+ });
+});
+
+describe("resolveDevinModelUid", () => {
+ const advertised = [
+ "swe-2-high",
+ "swe-2-medium",
+ "swe-2-max",
+ "claude-opus-5-low",
+ "claude-opus-5-high",
+ "claude-opus-5-low-fast",
+ "claude-opus-5-high-fast",
+ "glm-5-2",
+ "glm-5-2-max",
+ "glm-5-2-1m",
+ "adaptive",
+ ];
+
+ it("passes through uid-valued dim selections from opaque families", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "MODEL_PRIVATE_14",
+ selections: [{ id: "effort", value: "MODEL_PRIVATE_12" }],
+ advertisedValues: ["MODEL_PRIVATE_12", "MODEL_PRIVATE_13", "MODEL_PRIVATE_14"],
+ }),
+ ).toBe("MODEL_PRIVATE_12");
+ });
+
+ it("passes through exact uid matches", () => {
+ expect(resolveDevinModelUid({ model: "swe-2-high", advertisedValues: advertised })).toBe(
+ "swe-2-high",
+ );
+ expect(resolveDevinModelUid({ model: "swe-1-6-fast", advertisedValues: advertised })).toBe(
+ "swe-1-6-fast",
+ );
+ });
+
+ it("resolves effort selections to concrete uids", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "swe-2",
+ selections: [{ id: "effort", value: "max" }],
+ advertisedValues: advertised,
+ }),
+ ).toBe("swe-2-max");
+ });
+
+ it("combines effort and speed selections", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "claude-opus-5",
+ selections: [
+ { id: "effort", value: "low" },
+ { id: "speed", value: "fast" },
+ ],
+ advertisedValues: advertised,
+ }),
+ ).toBe("claude-opus-5-low-fast");
+ expect(
+ resolveDevinModelUid({
+ model: "claude-opus-5",
+ selections: [
+ { id: "effort", value: "high" },
+ { id: "speed", value: "standard" },
+ ],
+ advertisedValues: advertised,
+ }),
+ ).toBe("claude-opus-5-high");
+ });
+
+ it("resolves the default effort to the bare variant", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "glm-5-2",
+ selections: [{ id: "effort", value: "default" }],
+ advertisedValues: advertised,
+ }),
+ ).toBe("glm-5-2");
+ expect(
+ resolveDevinModelUid({
+ model: "glm-5-2",
+ selections: [
+ { id: "effort", value: "default" },
+ { id: "context", value: "1m" },
+ ],
+ advertisedValues: advertised,
+ }),
+ ).toBe("glm-5-2-1m");
+ });
+
+ it("keeps the session's current value when no dims are selected", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "swe-2",
+ advertisedValues: advertised,
+ currentValue: "swe-2-high",
+ }),
+ ).toBe("swe-2-high");
+ });
+
+ it("relaxes to a matching effort when speed is unavailable", () => {
+ expect(
+ resolveDevinModelUid({
+ model: "swe-2",
+ selections: [
+ { id: "effort", value: "max" },
+ { id: "speed", value: "fast" },
+ ],
+ advertisedValues: advertised,
+ }),
+ ).toBe("swe-2-max");
+ });
+
+ it("does not let sibling families leak into each other", () => {
+ const values = ["swe-1-7", "swe-1-7-medium", "swe-1-7-lightning", "swe-1-7-lightning-medium"];
+ expect(
+ resolveDevinModelUid({
+ model: "swe-1-7",
+ selections: [{ id: "effort", value: "medium" }],
+ advertisedValues: values,
+ }),
+ ).toBe("swe-1-7-medium");
+ expect(
+ resolveDevinModelUid({
+ model: "swe-1-7-lightning",
+ advertisedValues: values,
+ }),
+ ).toBe("swe-1-7-lightning");
+ });
+});
diff --git a/apps/server/src/provider/devinModelCatalog.ts b/apps/server/src/provider/devinModelCatalog.ts
new file mode 100644
index 000000000000..077581a7f3e8
--- /dev/null
+++ b/apps/server/src/provider/devinModelCatalog.ts
@@ -0,0 +1,549 @@
+/**
+ * devinModelCatalog — pure parsing/grouping helpers for Devin's model surface.
+ *
+ * Devin's CLI encodes reasoning effort, speed tier, and context window in the
+ * model uid itself (`claude-opus-5-high-fast`, `glm-5-2-max-1m`,
+ * `MODEL_GPT_5_2_LOW`). The picker should still present one row per family
+ * with option descriptors, matching how Codex/Claude expose effort and fast
+ * mode. This module owns both directions:
+ *
+ * - `devinModelsFromCatalog` groups `devin models list --format json` into
+ * per-family rows with `effort` / `speed` / `context` option descriptors.
+ * - `resolveDevinModelUid` turns `{ model: , options }` back into a
+ * concrete advertised uid for `session/set_config_option`.
+ *
+ * @module devinModelCatalog
+ */
+import type {
+ ProviderOptionChoice,
+ ProviderOptionSelection,
+ ServerProviderModel,
+} from "@t3tools/contracts";
+
+// ── uid suffix grammar ───────────────────────────────────────────────────
+//
+// Wire order is -[-][-]; separators may be `-`
+// or `_` (`MODEL_GPT_5_2_LOW`). Suffixes strip right-to-left: context, then
+// speed, then effort.
+
+const EFFORT_TOKENS = new Set([
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ "thinking",
+]);
+const SPEED_TOKENS = new Set(["fast", "priority"]);
+const CONTEXT_TOKENS = new Set(["1m"]);
+
+export interface DevinModelDims {
+ /** Normalized base id (lowercase, `-` separators). */
+ readonly base: string;
+ readonly effort: string | undefined;
+ readonly speed: string | undefined;
+ readonly context: string | undefined;
+}
+
+export function normalizeDevinModelId(value: string): string {
+ return value.trim().toLowerCase().replaceAll("_", "-");
+}
+
+export function parseDevinModelUid(uid: string): DevinModelDims {
+ const tokens = normalizeDevinModelId(uid).split("-").filter(Boolean);
+ let effort: string | undefined;
+ let speed: string | undefined;
+ let context: string | undefined;
+ while (tokens.length > 1) {
+ const last = tokens[tokens.length - 1]!;
+ if (context === undefined && CONTEXT_TOKENS.has(last)) {
+ context = last;
+ tokens.pop();
+ continue;
+ }
+ if (speed === undefined && SPEED_TOKENS.has(last)) {
+ speed = last;
+ tokens.pop();
+ continue;
+ }
+ if (effort === undefined && EFFORT_TOKENS.has(last)) {
+ effort = last;
+ tokens.pop();
+ continue;
+ }
+ break;
+ }
+ return { base: tokens.join("-"), effort, speed, context };
+}
+
+// ── picker rows ──────────────────────────────────────────────────────────
+
+export const DEVIN_EFFORT_OPTION_ID = "effort";
+export const DEVIN_SPEED_OPTION_ID = "speed";
+export const DEVIN_CONTEXT_OPTION_ID = "context";
+
+/** Selection values that mean "the provider default" for an axis. */
+const DEFAULT_EFFORT_VALUE = "default";
+const STANDARD_SPEED_VALUE = "standard";
+const STANDARD_CONTEXT_VALUE = "200k";
+
+const EFFORT_ORDER = [
+ DEFAULT_EFFORT_VALUE,
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ "thinking",
+] as const;
+
+const EFFORT_LABELS: Record = {
+ [DEFAULT_EFFORT_VALUE]: "Default",
+ none: "None",
+ minimal: "Minimal",
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ xhigh: "X-High",
+ max: "Max",
+ thinking: "Thinking",
+};
+
+const SPEED_LABELS: Record = {
+ [STANDARD_SPEED_VALUE]: "Standard",
+ fast: "Fast",
+ priority: "Priority",
+};
+
+const CONTEXT_LABELS: Record = {
+ [STANDARD_CONTEXT_VALUE]: "Standard",
+ "1m": "1M",
+};
+
+interface DevinModelVariant {
+ readonly model_uid: string;
+ readonly label?: string;
+ readonly is_new?: boolean;
+ readonly is_beta?: boolean;
+}
+
+interface DevinModelFamily {
+ readonly family_uid?: string;
+ readonly family_label?: string;
+ readonly slug?: string;
+ readonly aliases?: ReadonlyArray;
+ readonly variants?: ReadonlyArray;
+}
+
+export interface DevinModelsListJson {
+ readonly families?: ReadonlyArray;
+}
+
+/** `adaptive` is Devin's recommended auto-router and the picker default. */
+const DEVIN_DEFAULT_MODEL_SLUG = "adaptive";
+
+interface ParsedVariant {
+ readonly uid: string;
+ readonly label: string | undefined;
+ readonly dims: DevinModelDims;
+ readonly isNew: boolean;
+}
+
+function effortRank(effort: string): number {
+ const index = EFFORT_ORDER.indexOf(effort as (typeof EFFORT_ORDER)[number]);
+ return index === -1 ? EFFORT_ORDER.length : index;
+}
+
+/**
+ * Label-derived dims for families whose variant uids are opaque
+ * (`MODEL_PRIVATE_*`): the variant label minus the family label carries the
+ * effort words — "GPT-5.1 Low Thinking" → `low`, "… No Thinking" → `none`,
+ * "… Thinking" → `thinking`. Returns `undefined` when the label does not
+ * extend the family label so callers fall back to uid parsing.
+ */
+export function parseDevinVariantLabel(
+ familyLabel: string | undefined,
+ variantLabel: string | undefined,
+): DevinModelDims | undefined {
+ if (!familyLabel || !variantLabel) return undefined;
+ const trimmedFamily = familyLabel.trim();
+ const trimmedVariant = variantLabel.trim();
+ if (
+ !trimmedVariant.toLowerCase().startsWith(trimmedFamily.toLowerCase()) ||
+ trimmedVariant.length === trimmedFamily.length
+ ) {
+ return undefined;
+ }
+ let rest = trimmedVariant.slice(trimmedFamily.length).trim().toLowerCase();
+
+ let context: string | undefined;
+ let speed: string | undefined;
+ for (const token of rest.split(/\s+/).reverse()) {
+ if (context === undefined && CONTEXT_TOKENS.has(token)) {
+ context = token;
+ rest = rest.slice(0, rest.length - token.length).trim();
+ continue;
+ }
+ if (speed === undefined && SPEED_TOKENS.has(token)) {
+ speed = token;
+ rest = rest.slice(0, rest.length - token.length).trim();
+ continue;
+ }
+ break;
+ }
+
+ let effort: string | undefined;
+ if (rest === "no thinking") {
+ effort = "none";
+ } else if (rest === "thinking") {
+ effort = "thinking";
+ } else {
+ const stripped = rest.replace(/\s*thinking$/, "");
+ if (stripped !== rest && EFFORT_TOKENS.has(stripped)) {
+ effort = stripped;
+ } else if (EFFORT_TOKENS.has(rest)) {
+ effort = rest;
+ }
+ }
+ return { base: "", effort, speed, context };
+}
+
+function effortChoice(effort: string, isDefault: boolean): ProviderOptionChoice {
+ return {
+ id: effort,
+ label: EFFORT_LABELS[effort] ?? effort,
+ ...(isDefault ? { isDefault: true } : {}),
+ };
+}
+
+/**
+ * One row per family. Variants sharing a uid base become `effort`/`speed`/
+ * `context` option descriptors (token-valued choices resolved by dims match);
+ * families whose uids share no base (`MODEL_PRIVATE_*` under gpt-5.1) emit a
+ * single effort select whose choices carry the concrete uid, which
+ * `resolveDevinModelUid` passes through. Variant uids land in `aliases` so
+ * stored flat selections like `swe-2-high` still resolve to the family row.
+ */
+export function devinModelsFromCatalog(
+ parsed: DevinModelsListJson | undefined,
+): ReadonlyArray {
+ const models: ServerProviderModel[] = [];
+ const seenSlugs = new Set();
+
+ for (const family of parsed?.families ?? []) {
+ const familyLabel =
+ typeof family.family_label === "string" && family.family_label.trim()
+ ? family.family_label.trim()
+ : undefined;
+ const familySlug = typeof family.slug === "string" ? family.slug.trim() : "";
+ const familyAliases = [
+ ...(family.aliases ?? []).filter(
+ (alias): alias is string => typeof alias === "string" && alias.trim().length > 0,
+ ),
+ ...(familySlug ? [familySlug] : []),
+ ];
+
+ const variants: Array = [];
+ for (const variant of family.variants ?? []) {
+ const uid = typeof variant.model_uid === "string" ? variant.model_uid.trim() : "";
+ if (!uid) continue;
+ const label =
+ typeof variant.label === "string" && variant.label.trim()
+ ? variant.label.trim()
+ : undefined;
+ variants.push({ uid, label, dims: parseDevinModelUid(uid), isNew: variant.is_new === true });
+ }
+ if (variants.length === 0) continue;
+
+ const distinctBases = new Set(variants.map((variant) => variant.dims.base));
+ const isNew = variants.some((variant) => variant.isNew);
+ const optionDescriptors: Array<{
+ id: string;
+ label: string;
+ type: "select";
+ options: Array;
+ }> = [];
+ let slug: string;
+
+ if (distinctBases.size <= 1) {
+ // Shared uid base — multi-axis dims from uid suffixes.
+ slug = variants[0]!.dims.base;
+ const efforts = new Set();
+ const speeds = new Set();
+ const contexts = new Set();
+ for (const variant of variants) {
+ if (variant.dims.effort) efforts.add(variant.dims.effort);
+ if (variant.dims.speed) speeds.add(variant.dims.speed);
+ if (variant.dims.context) contexts.add(variant.dims.context);
+ }
+ const hasBare = variants.some(
+ (variant) =>
+ variant.dims.effort === undefined &&
+ variant.dims.speed === undefined &&
+ variant.dims.context === undefined,
+ );
+
+ if (efforts.size > 0 || (hasBare && variants.length > 1)) {
+ const options: Array = [];
+ if (hasBare) {
+ options.push(effortChoice(DEFAULT_EFFORT_VALUE, true));
+ }
+ for (const effort of EFFORT_ORDER) {
+ if (effort === DEFAULT_EFFORT_VALUE || !efforts.has(effort)) continue;
+ options.push(effortChoice(effort, false));
+ }
+ if (options.length > 1) {
+ optionDescriptors.push({
+ id: DEVIN_EFFORT_OPTION_ID,
+ label: "Reasoning",
+ type: "select",
+ options,
+ });
+ }
+ }
+
+ if (speeds.size > 0) {
+ const options: Array = [];
+ const hasStandard = variants.some((variant) => variant.dims.speed === undefined);
+ if (hasStandard) {
+ options.push({
+ id: STANDARD_SPEED_VALUE,
+ label: SPEED_LABELS[STANDARD_SPEED_VALUE]!,
+ isDefault: true,
+ });
+ }
+ for (const speed of ["fast", "priority"]) {
+ if (!speeds.has(speed)) continue;
+ options.push({
+ id: speed,
+ label: SPEED_LABELS[speed]!,
+ ...(hasStandard ? {} : { isDefault: true }),
+ });
+ }
+ if (options.length > 1) {
+ optionDescriptors.push({
+ id: DEVIN_SPEED_OPTION_ID,
+ label: "Speed",
+ type: "select",
+ options,
+ });
+ }
+ }
+
+ if (contexts.size > 0) {
+ const options: Array = [];
+ const hasStandard = variants.some((variant) => variant.dims.context === undefined);
+ if (hasStandard) {
+ options.push({
+ id: STANDARD_CONTEXT_VALUE,
+ label: CONTEXT_LABELS[STANDARD_CONTEXT_VALUE]!,
+ isDefault: true,
+ });
+ }
+ for (const context of ["1m"]) {
+ if (!contexts.has(context)) continue;
+ options.push({
+ id: context,
+ label: CONTEXT_LABELS[context]!,
+ ...(hasStandard ? {} : { isDefault: true }),
+ });
+ }
+ if (options.length > 1) {
+ optionDescriptors.push({
+ id: DEVIN_CONTEXT_OPTION_ID,
+ label: "Context",
+ type: "select",
+ options,
+ });
+ }
+ }
+ } else {
+ // Opaque uids — one effort select with uid-valued choices, ordered by
+ // effort rank. `resolveDevinModelUid` passes a uid-valued selection
+ // straight through, so no shared base is needed.
+ const byVariant = variants.map((variant) => ({
+ ...variant,
+ labelDims: parseDevinVariantLabel(familyLabel, variant.label),
+ }));
+ const isBare = (variant: (typeof byVariant)[number]) =>
+ variant.labelDims?.effort === undefined &&
+ variant.labelDims?.speed === undefined &&
+ variant.labelDims?.context === undefined;
+ const defaultVariant =
+ byVariant.find(isBare) ??
+ byVariant.find((variant) => variant.labelDims?.effort === "medium") ??
+ byVariant[0]!;
+ slug = defaultVariant.uid;
+
+ const options = byVariant
+ .toSorted(
+ (left, right) =>
+ effortRank(left.labelDims?.effort ?? DEFAULT_EFFORT_VALUE) -
+ effortRank(right.labelDims?.effort ?? DEFAULT_EFFORT_VALUE),
+ )
+ .map((variant) => {
+ const suffix =
+ familyLabel &&
+ variant.label !== undefined &&
+ variant.label.toLowerCase().startsWith(familyLabel.toLowerCase())
+ ? variant.label.slice(familyLabel.length).trim()
+ : "";
+ return {
+ id: variant.uid,
+ label:
+ suffix !== ""
+ ? suffix
+ : EFFORT_LABELS[variant.labelDims?.effort ?? DEFAULT_EFFORT_VALUE]!,
+ ...(variant.uid === defaultVariant.uid ? { isDefault: true } : {}),
+ };
+ });
+ if (options.length > 1) {
+ optionDescriptors.push({
+ id: DEVIN_EFFORT_OPTION_ID,
+ label: "Reasoning",
+ type: "select",
+ options,
+ });
+ }
+ }
+
+ if (seenSlugs.has(slug)) continue;
+ seenSlugs.add(slug);
+ const aliases = [...familyAliases, ...variants.map((variant) => variant.uid)].filter(
+ (alias) => alias !== slug,
+ );
+ models.push({
+ slug,
+ name: familyLabel ?? slug,
+ ...(aliases.length > 0 ? { aliases } : {}),
+ ...(isNew ? { badge: "new" as const } : {}),
+ isCustom: false,
+ isDefault:
+ slug === DEVIN_DEFAULT_MODEL_SLUG ||
+ variants.some((v) => v.uid === DEVIN_DEFAULT_MODEL_SLUG),
+ capabilities: optionDescriptors.length > 0 ? { optionDescriptors } : null,
+ });
+ }
+ return models;
+}
+
+// ── selection → concrete uid ─────────────────────────────────────────────
+
+const DEVIN_DIM_OPTION_IDS = new Set([
+ DEVIN_EFFORT_OPTION_ID,
+ DEVIN_SPEED_OPTION_ID,
+ DEVIN_CONTEXT_OPTION_ID,
+]);
+
+/** Option ids that fold into the model uid rather than a config option. */
+export function isDevinModelDimOptionId(id: string): boolean {
+ return DEVIN_DIM_OPTION_IDS.has(id);
+}
+
+function wantedDims(selections: ReadonlyArray | null | undefined): {
+ effort: string | undefined;
+ speed: string | undefined;
+ context: string | undefined;
+ hasAny: boolean;
+} {
+ let effort: string | undefined;
+ let speed: string | undefined;
+ let context: string | undefined;
+ for (const selection of selections ?? []) {
+ if (selection.id === DEVIN_EFFORT_OPTION_ID && typeof selection.value === "string") {
+ effort = selection.value === DEFAULT_EFFORT_VALUE ? undefined : selection.value;
+ } else if (selection.id === DEVIN_SPEED_OPTION_ID && typeof selection.value === "string") {
+ speed = selection.value === STANDARD_SPEED_VALUE ? undefined : selection.value;
+ } else if (selection.id === DEVIN_CONTEXT_OPTION_ID && typeof selection.value === "string") {
+ context = selection.value === STANDARD_CONTEXT_VALUE ? undefined : selection.value;
+ }
+ }
+ return {
+ effort,
+ speed,
+ context,
+ hasAny: effort !== undefined || speed !== undefined || context !== undefined,
+ };
+}
+
+/**
+ * Resolve a grouped selection (`model` = base id + effort/speed/context
+ * options) to a concrete advertised uid. Resolution matches on parsed dims
+ * rather than string composition so irregular uids (`MODEL_GPT_5_2_LOW`,
+ * `claude-5-fable-*`) still land. Exact uid matches and unknown models pass
+ * through untouched.
+ */
+export function resolveDevinModelUid(input: {
+ readonly model: string;
+ readonly selections?: ReadonlyArray | null | undefined;
+ readonly advertisedValues: ReadonlyArray;
+ readonly currentValue?: string | undefined;
+}): string {
+ const model = input.model.trim();
+ if (!model) return model;
+
+ // Opaque families (no shared uid base, e.g. `MODEL_PRIVATE_*`) put the
+ // concrete uid in the dim choice id — a selection that is itself an
+ // advertised value wins outright.
+ const direct = (input.selections ?? []).find(
+ (selection) =>
+ isDevinModelDimOptionId(selection.id) &&
+ typeof selection.value === "string" &&
+ input.advertisedValues.includes(selection.value),
+ );
+ if (direct) return direct.value as string;
+
+ const wanted = wantedDims(input.selections);
+ // A base id like `swe-1-7` is often *also* an advertised uid (the family's
+ // default-effort variant), so the exact-match pass-through only applies
+ // when no dims were selected — otherwise the dims must drive resolution.
+ if (!wanted.hasAny && input.advertisedValues.includes(model)) return model;
+
+ const wantedBase = normalizeDevinModelId(model);
+ const candidates = input.advertisedValues.filter(
+ (uid) => parseDevinModelUid(uid).base === wantedBase,
+ );
+ if (candidates.length === 0) return model;
+
+ if (!wanted.hasAny) {
+ // No dims selected: keep the session's current value when it already
+ // belongs to this family, else the bare variant, else first advertised.
+ if (input.currentValue && candidates.includes(input.currentValue)) {
+ return input.currentValue;
+ }
+ const bare = candidates.find((uid) => {
+ const dims = parseDevinModelUid(uid);
+ return dims.effort === undefined && dims.speed === undefined && dims.context === undefined;
+ });
+ return bare ?? candidates[0]!;
+ }
+
+ const strict = candidates.find((uid) => {
+ const dims = parseDevinModelUid(uid);
+ return (
+ dims.effort === wanted.effort &&
+ dims.speed === wanted.speed &&
+ dims.context === wanted.context
+ );
+ });
+ if (strict) return strict;
+
+ // Relax: drop context, then speed, then accept the bare variant, then
+ // first advertised — never fail the turn over a missing variant.
+ const withoutContext = candidates.find((uid) => {
+ const dims = parseDevinModelUid(uid);
+ return dims.effort === wanted.effort && dims.speed === wanted.speed;
+ });
+ if (withoutContext) return withoutContext;
+ const effortOnly = candidates.find((uid) => parseDevinModelUid(uid).effort === wanted.effort);
+ if (effortOnly) return effortOnly;
+ const bare = candidates.find((uid) => {
+ const dims = parseDevinModelUid(uid);
+ return dims.effort === undefined && dims.speed === undefined && dims.context === undefined;
+ });
+ return bare ?? candidates[0]!;
+}
diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts
index a4b260f43bf6..de27b127309a 100644
--- a/apps/server/src/provider/providerStatusCache.ts
+++ b/apps/server/src/provider/providerStatusCache.ts
@@ -37,6 +37,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [
"codex",
"claudeAgent",
"cursor",
+ "devin",
"grok",
"opencode",
"antigravity",
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 5d2571e72dc3..cb5ca264dd5d 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1037,6 +1037,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
cursor: {
enabled: false,
},
+ devin: {
+ enabled: false,
+ },
grok: {
enabled: false,
},
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index 0b64d445adf8..5b523742154a 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -258,6 +258,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({
providers: Schema.optionalKey(
Schema.Struct({
cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
+ devin: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
}),
@@ -286,6 +287,7 @@ function restoreUsedProviders(
instanceId,
instance.enabled === undefined &&
(instance.driver === "cursor" ||
+ instance.driver === "devin" ||
instance.driver === "grok" ||
instance.driver === "opencode") &&
usedProviderInstances.has(instanceId)
@@ -302,6 +304,10 @@ function restoreUsedProviders(
...settings.providers.cursor,
enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"),
},
+ devin: {
+ ...settings.providers.devin,
+ enabled: persisted.providers?.devin?.enabled ?? usedProviders.has("devin"),
+ },
grok: {
...settings.providers.grok,
enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"),
@@ -361,6 +367,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = {
providers: {
...DEFAULT_SERVER_SETTINGS.providers,
cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },
+ devin: { ...DEFAULT_SERVER_SETTINGS.providers.devin, enabled: undefined },
grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined },
opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined },
},
@@ -472,13 +479,13 @@ const make = Effect.gen(function* () {
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM projection_thread_sessions
- WHERE provider_name IN ('cursor', 'grok', 'opencode')
+ WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode')
UNION
SELECT DISTINCT
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM provider_session_runtime
- WHERE provider_name IN ('cursor', 'grok', 'opencode')
+ WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode')
`.pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts
new file mode 100644
index 000000000000..425605eb478c
--- /dev/null
+++ b/apps/server/src/textGeneration/DevinTextGeneration.ts
@@ -0,0 +1,271 @@
+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 `makeCursorTextGeneration` 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();
+ // `ask` keeps text generation read-only regardless of the session's
+ // permission posture.
+ 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 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 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 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 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,
+ changeRequestTemplate: input.changeRequestTemplate,
+ policy: input.policy,
+ });
+
+ 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/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index 84730d639ac6..3031570932ec 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
+export type TextGenerationProvider =
+ | "codex"
+ | "claudeAgent"
+ | "cursor"
+ | "devin"
+ | "grok"
+ | "opencode";
export interface CommitMessageGenerationInput {
cwd: string;
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index b13040152e18..0835fdc5d18d 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -233,6 +233,20 @@ export const CursorIcon: Icon = ({ className, ...props }) => (
);
+export const DevinIcon: Icon = ({ className, ...props }) => (
+
+);
+
export const GrokIcon: Icon = ({ className, ...props }) => (