From 6f5aa154be439bc371d6f6c77961197e9e6f3372 Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Sun, 27 Sep 2026 18:59:21 +0100 Subject: [PATCH 1/2] feat(server): honor resolved permission mode and report approval review for opencode OpenCode's serve API exposes per-tool permission asks but no native turn-review verdict, so getApprovalReviewSupport honestly reports unavailable and the adapter leaves the approval-review capability undeclared. Resolved modes now reach the wire path: Full Access auto-answers permission asks with "always" instead of carding, while questions still surface to the user. --- .../features/opencode-pooled-serve.md | 1 + .../opencode-provider-approval-review.test.ts | 230 ++++++++++++++++++ .../__tests__/opencode-provider-idle.test.ts | 3 +- .../adapters/opencode/opencode-provider.ts | 66 ++++- 4 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts diff --git a/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md b/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md index 826641e2d..3e6e4b9b4 100644 --- a/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md +++ b/.agents/skills/verify-mcode/references/features/opencode-pooled-serve.md @@ -12,6 +12,7 @@ - Event mapping (#1624): every schema-valid upstream event classifies as mapped, state-only, diagnostic, or ignored-with-reason across wrapped and flat envelopes. Unknown valid types become one bounded provider-neutral diagnostic; oversized envelopes are rejected at the adapter boundary. Raw upstream payload never reaches ingress, persistence, transport, or UI. - Supervised permissions (#1624): permission and question asks surface as inline cards through the existing permission flow (`permission.request` → `permission.respond` → upstream reply). A shell approval relays once. A question reply carries the user’s ordered selected labels; questions do not offer session approval. Reject relays once and the turn settles cleanly. Duplicate replies and terminal outcomes are blocked. - Provider notices (#1624): reroute (`session.next.model.switched`) uses the canonical model-fallback event. Warning/error toasts, configuration signals, and authentication failures use bounded provider-neutral notices with replay deduplication. The screen and thread state stay intact. +- Resolved dispatch modes (#1626): every turn carries the resolved permission and review modes. Full Access auto-answers upstream permission asks with `always` and shows no card; questions still card; supervised behavior is unchanged. `getApprovalReviewSupport` honestly reports `unavailable` — OpenCode has no native turn-review verdict — so the resolved review mode is always manual and the turn footer reflects it. The approval-review capability stays undeclared, so the Composer never offers Auto for OpenCode. The adapter is registered in the shared conformance suite (`opencode-core.synthetic.json`). ## How to get to it (user POV) diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts new file mode 100644 index 000000000..ae8dbecd8 --- /dev/null +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-approval-review.test.ts @@ -0,0 +1,230 @@ +import "reflect-metadata"; +import { describe, expect, it, vi } from "vitest"; +import { OpenCodeProvider } from "../opencode-provider.js"; +import { OpenCodeServerPool } from "../opencode-server-pool.js"; +import type { PermissionRequest, TurnRequest } from "@mcode/contracts"; + +function testPool(): OpenCodeServerPool { + return new OpenCodeServerPool({ + spawn: () => ({ pid: 1, on: () => {}, off: () => {}, kill: () => true }) as never, + waitForHealth: async () => {}, + terminateTree: async () => {}, + findFreePort: async () => 4096, + now: () => Date.now(), + env: () => ({}), + }); +} + +interface FakeHttp { + createSession: ReturnType; + promptAsync: ReturnType; + abortSession: ReturnType; + listModels: ReturnType; + listSessionMessages: ReturnType; + replyPermission: ReturnType; + replyQuestion: ReturnType; + rejectQuestion: ReturnType; + subscribeEvents: ReturnType; + getSessionStatus: ReturnType; +} + +function fakeHttp(envelopes: unknown[]): FakeHttp { + let emit: ((e: unknown) => void) | null = null; + const idle = { type: "session.idle", properties: { sessionID: "ses_1" } }; + const fake = { + createSession: vi.fn(async () => ({ id: "ses_1" })), + promptAsync: vi.fn(async () => {}), + abortSession: vi.fn(async () => {}), + listModels: vi.fn(async () => []), + listSessionMessages: vi.fn(async () => []), + // A relayed decision unblocks the fake upstream: the next step ends idle, + // which is what lets the turn settle. + replyPermission: vi.fn(async () => { + setTimeout(() => emit?.(idle), 0); + }), + replyQuestion: vi.fn(async () => { + setTimeout(() => emit?.(idle), 0); + }), + rejectQuestion: vi.fn(async () => { + setTimeout(() => emit?.(idle), 0); + }), + getSessionStatus: vi.fn(async () => new Proxy({}, { get: () => ({ type: "idle" }) })), + subscribeEvents: vi.fn(async (_url: string, signal: AbortSignal, onEnvelope: (e: unknown) => void) => { + emit = onEnvelope; + for (const envelope of envelopes) onEnvelope(envelope); + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }), + }; + return fake; +} + +function testProvider(http: FakeHttp) { + const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) }; + const envService = { getEnv: () => ({}) }; + const host = { + events: { submit: async () => ({ commit: {}, delivery: { ingress: "queued" } }) }, + processes: { attach: () => {}, terminateTree: async () => {} }, + runtime: { platform: "win32" }, + environment: { snapshot: () => ({}) }, + browser: {}, + threadControl: {}, + grants: {}, + }; + const provider = new OpenCodeProvider(settingsService as never, envService as never, host as never); + provider.configureTestSeams({ + pool: testPool(), + http: http as never, + probeCli: async () => ({ binaryPath: "opencode", version: "test" }), + idleConfirm: { intervalMs: 5, requiredPolls: 2, timeoutMs: 500, maxPollErrors: 2 }, + }); + return provider; +} + +function turnRequest(permissionMode: "full" | "supervised" = "supervised"): TurnRequest<"opencode"> { + return { + turnId: "turn-1", + turnExecutionId: "55555555-5555-4555-8555-555555555555", + sessionId: "mcode-thread-1", + workspaceId: "ws-1", + threadId: "thread-1", + message: "run the thing", + cwd: "/w/a", + model: "anthropic/claude-sonnet-4-6", + permissionMode, + approvalReviewMode: "manual", + interactionMode: "build", + providerOptions: {}, + } as TurnRequest<"opencode">; +} + +function shellAsk(id = "per_1") { + return { + type: "permission.v2.asked", + properties: { id, sessionID: "ses_1", action: "bash", resources: ["echo hi"] }, + }; +} + +function questionAsk(id = "que_1") { + return { + type: "question.v2.asked", + properties: { + id, + sessionID: "ses_1", + questions: [ + { header: "Deploy", question: "Deploy now?", options: [{ label: "Yes" }, { label: "No" }] }, + ], + }, + }; +} + +describe("OpenCodeProvider approval-review support", () => { + it("honestly reports unavailable for every input without touching upstream", async () => { + const http = fakeHttp([]); + const provider = testProvider(http); + + const expected = { + status: "unavailable", + supportedModes: ["manual"], + reason: "opencode-lacks-native-approval-review", + liveChangeScope: "none", + }; + await expect(provider.getApprovalReviewSupport({ + permissionMode: "supervised", + interactionMode: "build", + requestedMode: "automatic", + model: "anthropic/claude-sonnet-4-6", + })).resolves.toEqual(expected); + await expect(provider.getApprovalReviewSupport({ + permissionMode: "full", + interactionMode: "build", + requestedMode: "automatic", + model: "anthropic/claude-sonnet-4-6", + })).resolves.toEqual(expected); + await expect(provider.getApprovalReviewSupport({ + permissionMode: "supervised", + interactionMode: "plan", + requestedMode: "manual", + model: "anthropic/claude-sonnet-4-6", + })).resolves.toEqual(expected); + + expect(http.subscribeEvents).not.toHaveBeenCalled(); + expect(http.createSession).not.toHaveBeenCalled(); + expect(http.promptAsync).not.toHaveBeenCalled(); + expect(http.listModels).not.toHaveBeenCalled(); + await provider.shutdown(); + }); +}); + +describe("OpenCodeProvider full-access permission bypass", () => { + it("auto-replies always to a permission ask in full mode without carding", async () => { + const http = fakeHttp([shellAsk()]); + const provider = testProvider(http); + const cards: PermissionRequest[] = []; + const resolved: unknown[] = []; + provider.on("permission_request", (request) => cards.push(request as PermissionRequest)); + provider.on("permission_resolved", (payload) => resolved.push(payload)); + + await provider.sendTurn(turnRequest("full")); + + expect(cards).toHaveLength(0); + expect(http.replyPermission).toHaveBeenCalledTimes(1); + expect(http.replyPermission).toHaveBeenCalledWith( + "http://127.0.0.1:4096", "ses_1", "per_1", "always", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(resolved).toEqual([{ requestId: "per_1", decision: "allow-session" }]); + expect(provider.listPendingPermissions("thread-1")).toHaveLength(0); + await provider.shutdown(); + }); + + it("still cards a question ask in full mode", async () => { + const http = fakeHttp([questionAsk()]); + const provider = testProvider(http); + const cards: PermissionRequest[] = []; + provider.on("permission_request", (request) => cards.push(request as PermissionRequest)); + + const sending = provider.sendTurn(turnRequest("full")); + await vi.waitFor(() => expect(cards).toHaveLength(1)); + expect(cards[0]?.toolName).toBe("Question"); + expect(http.replyPermission).not.toHaveBeenCalled(); + expect(http.rejectQuestion).not.toHaveBeenCalled(); + + expect(provider.resolvePermission("que_1", "deny")).toBe(true); + await sending; + expect(http.rejectQuestion).toHaveBeenCalledTimes(1); + await provider.shutdown(); + }); + + it("cards a permission ask without auto-replying in supervised mode", async () => { + const http = fakeHttp([shellAsk()]); + const provider = testProvider(http); + const cards: PermissionRequest[] = []; + provider.on("permission_request", (request) => cards.push(request as PermissionRequest)); + + const sending = provider.sendTurn(turnRequest("supervised")); + await vi.waitFor(() => expect(cards).toHaveLength(1)); + expect(http.replyPermission).not.toHaveBeenCalled(); + + expect(provider.resolvePermission("per_1", "allow")).toBe(true); + await sending; + expect(http.replyPermission).toHaveBeenCalledTimes(1); + expect(http.replyPermission).toHaveBeenCalledWith( + "http://127.0.0.1:4096", "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + await provider.shutdown(); + }); + + it("replies once to a replayed permission ask in full mode", async () => { + const http = fakeHttp([shellAsk(), shellAsk()]); + const provider = testProvider(http); + const resolved: unknown[] = []; + provider.on("permission_resolved", (payload) => resolved.push(payload)); + + await provider.sendTurn(turnRequest("full")); + + expect(http.replyPermission).toHaveBeenCalledTimes(1); + expect(resolved).toEqual([{ requestId: "per_1", decision: "allow-session" }]); + await provider.shutdown(); + }); +}); diff --git a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts index fbb1a23ee..9c9702590 100644 --- a/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts +++ b/apps/server/src/features/providers/adapters/opencode/__tests__/opencode-provider-idle.test.ts @@ -208,7 +208,8 @@ describe("OpenCodeProvider idle confirmation", () => { }), }); const { provider, submitted } = testProvider(http as never); - const sending = provider.sendTurn(turnRequest()); + // Full access auto-answers permission asks; a pending card requires supervised. + const sending = provider.sendTurn({ ...turnRequest(), permissionMode: "supervised" }); await vi.waitFor(() => expect(provider.listPendingPermissions("thread-1")).toHaveLength(1)); await new Promise((resolve) => setTimeout(resolve, 30)); expect(endedOutcomes(submitted)).toEqual([]); diff --git a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts index 623e5935f..3be92a954 100644 --- a/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts +++ b/apps/server/src/features/providers/adapters/opencode/opencode-provider.ts @@ -3,7 +3,9 @@ import { inject, injectable } from "tsyringe"; import { logger } from "@mcode/shared"; import type { AgentEvent, + ApprovalReviewSupport, IAgentProvider, + IApprovalReviewCapable, ISessionEvictable, PermissionDecision, PermissionQuestion, @@ -243,7 +245,7 @@ function partRoleOf( * events; stop aborts the upstream session while the server stays warm. */ @injectable() -export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentProvider, ISessionEvictable { +export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentProvider, ISessionEvictable, IApprovalReviewCapable { readonly id: ProviderId = "opencode"; readonly descriptor = Object.freeze({ id: "opencode" as const, @@ -300,6 +302,25 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP throw Object.assign(new Error("OpenCode side-channel query is not available in the minimal turn slice"), { code: "ETIMEDOUT" }); } + /** + * Upstream OpenCode exposes only per-tool permission asks; no native + * turn-review verdict exists, so automatic approval review is honestly + * unavailable for every input. Side-effect-free: no probing, no I/O. + */ + async getApprovalReviewSupport(_input: { + permissionMode: "full" | "supervised"; + interactionMode: "plan" | "build"; + requestedMode: "manual" | "automatic"; + model: string; + }): Promise { + return { + status: "unavailable", + supportedModes: ["manual"], + reason: "opencode-lacks-native-approval-review", + liveChangeScope: "none", + }; + } + async listModels(): Promise { const cliPath = this.cliPath(); const entry = await this.pool.acquire({ binaryPath: cliPath, cwd: process.cwd(), hostname: OPENCODE_SERVE_HOSTNAME }).catch((error: unknown) => { @@ -964,15 +985,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP deliveryAttempt: req.deliveryAttempt ?? 1, }; if (reason === "permission-request") { - const request = synthesizeOpenCodePermissionRequest({ threadId, properties: normalized.properties }); - if (!request) return this.emitAskDiagnostic(req, threadId); - if (this.pendingPermissions.has(request.requestId)) return; - this.pendingPermissions.set(request.requestId, { - request, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl, - kind: "permission", version: askVersion(normalized.type), - signal: state.abortController.signal, routing, replying: false, - }); - this.emit("permission_request", request); + this.maybeEmitPermissionAsk(req, baseUrl, upstreamId, normalized, state, routing); return; } const synthesized = synthesizeOpenCodeQuestionRequest({ threadId, properties: normalized.properties }); @@ -986,6 +999,39 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP this.emit("permission_request", synthesized); } + /** + * Register one upstream permission ask, then either card it for the user + * (supervised) or auto-answer it "always" (full access — the serve API has + * no session-wide bypass, so the gate is adapter-side). The entry stays in + * the pending map either way so dedupe, draining, and session-invalidation + * bookkeeping stay identical. + */ + private maybeEmitPermissionAsk( + req: TurnRequest<"opencode">, + baseUrl: string, + upstreamId: string, + normalized: { type: string; properties: Record }, + state: OpenCodeTurnState, + routing: CanonicalLiveEventRouting, + ): void { + const threadId = this.threadIdFor(req.sessionId); + const request = synthesizeOpenCodePermissionRequest({ threadId, properties: normalized.properties }); + if (!request) return this.emitAskDiagnostic(req, threadId); + if (this.pendingPermissions.has(request.requestId)) return; + const entry: OpenCodePendingAsk = { + request, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl, + kind: "permission", version: askVersion(normalized.type), + signal: state.abortController.signal, routing, replying: false, + }; + this.pendingPermissions.set(request.requestId, entry); + if (req.permissionMode === "full") { + entry.replying = true; + void this.relayDecision(entry, "allow-session"); + return; + } + this.emit("permission_request", request); + } + private emitAskDiagnostic( req: TurnRequest<"opencode">, threadId: string, From 6e47e50594885ce1124c88387745225986fba4b2 Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Sun, 27 Sep 2026 18:59:29 +0100 Subject: [PATCH 2/2] feat(providers): register opencode in the shared conformance suite Add the opencode boundary factory, boundary id, and core synthetic fixture so the suite validates the adapter as a second server-side provider alongside codex. Version evidence names the serve request generations the adapter speaks, since no upstream semver is pinned. --- .../contracts/src/providers/interfaces.ts | 2 +- .../providers/src/__tests__/factories.test.ts | 10 +++++++++ .../conformance/__tests__/conformance.test.ts | 2 ++ .../src/conformance/fixture-safety.ts | 2 +- .../fixtures/opencode-core.synthetic.json | 21 +++++++++++++++++++ .../providers/src/conformance/registry.ts | 13 ++++++++++++ packages/providers/src/factories.ts | 5 +++++ packages/providers/src/factory-types.ts | 2 +- packages/providers/src/index.ts | 1 + 9 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 packages/providers/src/conformance/fixtures/opencode-core.synthetic.json diff --git a/packages/contracts/src/providers/interfaces.ts b/packages/contracts/src/providers/interfaces.ts index 6e3060bfa..e752371c3 100644 --- a/packages/contracts/src/providers/interfaces.ts +++ b/packages/contracts/src/providers/interfaces.ts @@ -16,7 +16,7 @@ import type { Provider } from "../compat/agent-model.js"; /** * Identifier for a supported AI provider. - * "opencode" remains catalog-only until a server adapter ships. + * "gemini" remains catalog-only until a server adapter ships. */ export type ProviderId = "claude" | "codex" | "gemini" | "copilot" | "cursor" | "opencode" | "devin"; diff --git a/packages/providers/src/__tests__/factories.test.ts b/packages/providers/src/__tests__/factories.test.ts index a9710db8e..4f8d709a8 100644 --- a/packages/providers/src/__tests__/factories.test.ts +++ b/packages/providers/src/__tests__/factories.test.ts @@ -5,6 +5,7 @@ import { createCodexProvider, createCopilotProvider, createCursorProvider, + createOpencodeProvider, type ProviderFactoryInput, type ProviderHostPorts, } from "../index.js"; @@ -67,6 +68,7 @@ describe("Provider factories", () => { ["codex", createCodexProvider], ["copilot", createCopilotProvider], ["cursor", createCursorProvider], + ["opencode", createOpencodeProvider], ] as const)("creates an inert %s Provider boundary", (id, createProvider) => { const input = createInput(); @@ -104,6 +106,14 @@ describe("Provider factories", () => { { name: "child-cancellation", support: "unsupported" }, ); } + if (id === "opencode") { + expect(provider.descriptor.capabilities).toEqual([ + { name: "build", support: "supported" }, + { name: "plan", support: "supported" }, + { name: "permissions", support: "supported" }, + { name: "session-eviction", support: "supported" }, + ]); + } const hostPortMethods = Object.entries(input.host) .filter(([name]) => name !== "runtime") .flatMap(([, port]) => Object.values(port)); diff --git a/packages/providers/src/conformance/__tests__/conformance.test.ts b/packages/providers/src/conformance/__tests__/conformance.test.ts index 3ab49d3a1..604bed0b0 100644 --- a/packages/providers/src/conformance/__tests__/conformance.test.ts +++ b/packages/providers/src/conformance/__tests__/conformance.test.ts @@ -44,12 +44,14 @@ describe("Provider conformance registry", () => { "codex", "copilot", "cursor", + "opencode", ]); await expect(Promise.all(ENABLED_PROVIDER_CONFORMANCE.map(runFactoryCoreProfile))).resolves.toEqual([ { providerId: "claude", spawnCount: 1, terminalType: "turn.completed" }, { providerId: "codex", spawnCount: 1, terminalType: "turn.completed" }, { providerId: "copilot", spawnCount: 1, terminalType: "turn.completed" }, { providerId: "cursor", spawnCount: 1, terminalType: "turn.completed" }, + { providerId: "opencode", spawnCount: 1, terminalType: "turn.completed" }, ]); }); diff --git a/packages/providers/src/conformance/fixture-safety.ts b/packages/providers/src/conformance/fixture-safety.ts index 5a9dcde6e..7c37ae5ac 100644 --- a/packages/providers/src/conformance/fixture-safety.ts +++ b/packages/providers/src/conformance/fixture-safety.ts @@ -9,7 +9,7 @@ import type { } from "./types.js"; import { PROVIDER_CONFORMANCE_CONTRACT_VERSION } from "./types.js"; -const PROVIDER_IDS = new Set(["claude", "codex", "copilot", "cursor"]); +const PROVIDER_IDS = new Set(["claude", "codex", "copilot", "cursor", "opencode"]); const PROFILES = new Set([ "core", "build", diff --git a/packages/providers/src/conformance/fixtures/opencode-core.synthetic.json b/packages/providers/src/conformance/fixtures/opencode-core.synthetic.json new file mode 100644 index 000000000..39af8e19f --- /dev/null +++ b/packages/providers/src/conformance/fixtures/opencode-core.synthetic.json @@ -0,0 +1,21 @@ +{ + "contractVersion": 1, + "providerId": "opencode", + "cliVersion": "synthetic", + "protocolVersion": "synthetic-v1", + "provenance": "synthetic", + "requiredProfiles": ["core"], + "scenario": "core lifecycle structure", + "sourceHash": "3d1d522bf48832b459e3cb26962e6a7220c64d837a1c512b67e0164d71cf944c", + "redaction": { "reviewed": true, "removedFields": ["prompt", "response", "environment", "tool output", "absolute paths"] }, + "input": { + "events": [ + { "kind": "session", "sequence": 1, "nativeId": "SESSION_1", "status": "started" }, + { "kind": "turn", "sequence": 2, "nativeId": "TURN_1", "status": "started" }, + { "kind": "item", "sequence": 3, "nativeId": "ITEM_1", "pairId": "PAIR_1", "size": 12, "status": "started" }, + { "kind": "item", "sequence": 4, "nativeId": "ITEM_2", "pairId": "PAIR_1", "size": 24, "status": "completed" }, + { "kind": "terminal", "sequence": 5, "nativeId": "TURN_1", "status": "completed" } + ] + }, + "expected": { "orderedKinds": ["session", "turn", "item", "item", "terminal"], "terminal": "completed", "toolPairs": [["PAIR_1", "PAIR_1"]] } +} diff --git a/packages/providers/src/conformance/registry.ts b/packages/providers/src/conformance/registry.ts index c9b1404b8..27a7cafe6 100644 --- a/packages/providers/src/conformance/registry.ts +++ b/packages/providers/src/conformance/registry.ts @@ -4,6 +4,7 @@ import { createCodexProvider, createCopilotProvider, createCursorProvider, + createOpencodeProvider, } from "../factories.js"; import type { ProviderConformanceRegistration } from "./types.js"; @@ -98,4 +99,16 @@ export const ENABLED_PROVIDER_CONFORMANCE: readonly ProviderConformanceRegistrat source: "apps/server/package.json and bun.lock", }], }, + { + providerId: "opencode", + factory: createOpencodeProvider, + requiredProfiles: ["core", "build", "plan", "permissions", "session-eviction"], + fixtureFiles: [fixtureFile("opencode")], + supportedVersions: [{ + component: "opencode-cli", + oldestSupported: "legacy", + currentTested: "v2", + source: "apps/server/src/features/providers/adapters/opencode/opencode-http-client.ts (OpenCodeRequestVersion) and opencode-cli.ts", + }], + }, ]; diff --git a/packages/providers/src/factories.ts b/packages/providers/src/factories.ts index b22409b5f..19fc70215 100644 --- a/packages/providers/src/factories.ts +++ b/packages/providers/src/factories.ts @@ -37,6 +37,11 @@ export function createDevinProvider(input: ProviderFactoryInput): DevinProviderB return createDevinAcpProvider(input); } +/** Prepares the OpenCode Provider boundary without inspecting or spawning its CLI. */ +export function createOpencodeProvider(input: ProviderFactoryInput): ProviderBoundary { + return createProviderBoundary("opencode", ["build", "plan", "permissions", "session-eviction"], input); +} + function validateCodexPorts(ports: NonNullable): void { const methods = [ ["settings", "get"], diff --git a/packages/providers/src/factory-types.ts b/packages/providers/src/factory-types.ts index 53dedd5f8..ea3ef7445 100644 --- a/packages/providers/src/factory-types.ts +++ b/packages/providers/src/factory-types.ts @@ -59,7 +59,7 @@ export interface DevinProviderPorts { /** Prepared Provider boundary returned without CLI inspection or process startup. */ export interface ProviderBoundary { - readonly id: "claude" | "codex" | "copilot" | "cursor" | "devin"; + readonly id: "claude" | "codex" | "copilot" | "cursor" | "devin" | "opencode"; readonly descriptor: Provider; } diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 75c035dbb..f9bd1c953 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -4,6 +4,7 @@ export { createCopilotProvider, createCursorProvider, createDevinProvider, + createOpencodeProvider, } from "./factories.js"; export type { ProviderBoundary,