import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";import { createOpencodeClient, type Agent, type FilePartInput, type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, type QuestionAnswer, type QuestionRequest,} from "@opencode-ai/sdk/v2";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Data from "effect/Data";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 Layer from "effect/Layer";import * as Option from "effect/Option";import * as P from "effect/Predicate";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Scope from "effect/Scope";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts";import { collectStreamAsString } from "./providerSnapshot.ts";import * as NetService from "@t3tools/shared/Net";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";import { resolveSpawnCommand } from "@t3tools/shared/shell";const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export const MINIMUM_OPENCODE_VERSION = "1.14.19";const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; const OpenCodeHealthSchema = Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String,});const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); export function resolveOpenCodeConfigContent( inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,): string { return ( inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? OPENCODE_EMPTY_CONFIG_CONTENT );} export function resolveOpenCodeServerPassword( input: { readonly external: boolean; readonly serverPassword?: string; readonly environment?: Readonly<Record<string, string | undefined>>; }, inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,): string | undefined { if (input.serverPassword !== undefined) { return input.serverPassword; } if (input.external) { return undefined; } return input.environment === undefined ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD : input.environment.OPENCODE_SERVER_PASSWORD;} const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;const DEFAULT_HOSTNAME = "127.0.0.1";const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;export interface OpenCodeServerProcess { readonly url: string; readonly serverPassword?: string; readonly version: string; readonly isRunning: Effect.Effect<boolean>; readonly exitCode: Effect.Effect<number, never>;} export interface OpenCodeServerConnection { readonly url: string; readonly serverPassword?: string; readonly version: string; readonly exitCode: Effect.Effect<number, never> | null; readonly external: boolean;} const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ readonly operation: string; readonly cause?: unknown; readonly detail: string;}> { static readonly is = (u: unknown): u is OpenCodeRuntimeError => P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);} function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined;} export function openCodeRuntimeErrorDetail(cause: unknown): string { if (OpenCodeRuntimeError.is(cause)) return cause.detail; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful const anyCause = cause as Record<string, unknown>; const status = (anyCause.response as { status?: number } | undefined)?.status; const body = anyCause.error ?? anyCause.data ?? anyCause.body; const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); if (encodedBody) { return `status=${status ?? "?"} body=${encodedBody}`; } } return String(cause);} export const runOpenCodeSdk = <A>( operation: string, fn: (signal: AbortSignal) => Promise<A>,): Effect.Effect<A, OpenCodeRuntimeError> => Effect.tryPromise({ try: fn, catch: (cause) => new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), }).pipe(Effect.withSpan(`opencode.${operation}`)); export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( client: OpencodeClient,) { const healthOption = yield* runOpenCodeSdk("global.health", (signal) => client.global.health({ signal }), ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); if (Option.isNone(healthOption)) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: "Timed out while checking the OpenCode server version.", }); } const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( Effect.mapError( (cause) => new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, cause, }), ), ); if (parseSemver(health.version) === null) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, }); } if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, }); } return health.version;}); export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; readonly code: number;} export interface OpenCodeInventory { readonly providerList: ProviderListResponse; readonly agents: ReadonlyArray<Agent>; readonly skills: ReadonlyArray<OpenCodeSkill>;} export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string;} export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; readonly location?: string | null;} const OpenCodeSkillSchema = Schema.Struct({ name: Schema.optionalKey(Schema.NullOr(Schema.String)), description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)),});const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),); export interface OpenCodeRuntimeShape { /** * Spawns a local OpenCode server process. Its lifetime is bound to the caller's * `Scope.Scope` — the child is killed automatically when that scope closes. * Consumers that want a long-lived server must create and hold a scope explicitly * (see {@link Scope.make}) and close it when done. */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; readonly directory: string; readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>; /** * Returns a handle to either an externally-managed OpenCode server (when * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a * freshly spawned local server whose lifetime is bound to the caller's scope. */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly directory: string; readonly serverUrl?: string | null; readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>; readonly runOpenCodeCommand: (input: { readonly binaryPath: string; readonly args: ReadonlyArray<string>; readonly environment?: NodeJS.ProcessEnv; readonly cwd?: string; readonly maxOutputBytes?: number; }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; readonly directory: string; readonly serverPassword?: string; }) => OpencodeClient; readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; readonly loadOpenCodeSkills: ( client: OpencodeClient, ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; readonly loadSkillsFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;} function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { continue; } const match = line.match(/on\s+(https?:\/\/[^\s]+)/); return match?.[1] ?? null; } return null;} const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command// does not expose the hidden flag. Keep in sync with OpenCode agent// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts).const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */export function parseModelsCliOutput(stdout: string): { readonly providers: ReadonlyMap< string, { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } >; readonly connected: ReadonlyArray<string>;} { const providers = new Map< string, { id: string; name: string; models: { [key: string]: Model } } >(); const lines = stdout.split("\n"); let currentSlug: string | null = null; const jsonLines: Array<string> = []; const flushModel = () => { if (currentSlug !== null && jsonLines.length > 0) { const jsonStr = jsonLines.join("\n").trim(); if (jsonStr.length > 0) { try { const model = JSON.parse(jsonStr) as Model; const separator = currentSlug.indexOf("/"); if (separator > 0) { const providerID = currentSlug.slice(0, separator); const modelID = currentSlug.slice(separator + 1); let provider = providers.get(providerID); if (!provider) { provider = { id: providerID, name: providerID, models: {} }; providers.set(providerID, provider); } provider.models[modelID] = model; } } catch { // Skip unparseable model JSON } } } currentSlug = null; jsonLines.length = 0; }; for (const line of lines) { // A model's JSON body is a single `JSON.stringify` line starting with `{`, // while a provider/model slug is a bare `provider/model` header. Only the // latter can be a slug: without this guard a body line with no interior // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against // an empty body and the model is silently dropped. const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); if (slugMatch) { flushModel(); currentSlug = slugMatch[1]!; } else if (currentSlug !== null) { jsonLines.push(line); } } flushModel(); return { providers, connected: [...providers.keys()] };} /** @internal */export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { const agents: Array<Agent> = []; const lines = stdout.split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array<string> = []; const flushAgent = () => { if (currentHeader !== null) { const jsonStr = blockLines.join("\n").trim(); if (jsonStr.length > 0) { try { const permission = JSON.parse(jsonStr); agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), permission, options: {}, }); } catch { // Skip unparseable agent } } } currentHeader = null; blockLines.length = 0; }; for (const line of lines) { const match = AGENT_HEADER_RE.exec(line); if (match) { flushAgent(); currentHeader = { name: match[1]!, mode: match[2]! }; } else if (currentHeader !== null) { blockLines.push(line); } } flushAgent(); return agents;} /** @internal */export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { const result = decodeOpenCodeSkillsCliOutputExit(stdout); return Exit.isSuccess(result) ? result.value : [];} export function parseOpenCodeModelSlug( slug: string | null | undefined,): ParsedOpenCodeModelSlug | null { if (typeof slug !== "string") { return null; } const trimmed = slug.trim(); const separator = trimmed.indexOf("/"); if (separator <= 0 || separator === trimmed.length - 1) { return null; } return { providerID: trimmed.slice(0, separator), modelID: trimmed.slice(separator + 1), };} export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number],): string { const header = question.header .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-"); return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;} /** * Attachments OpenCode can hand to a model as a native file part. Anything * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs * reject, or files over the direct-attachment size limit) would make the turn * fail before it starts, so those ride only as the file path ProviderService * puts in the prompt. */const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; export function isOpenCodeNativeFilePart(input: { readonly mimeType: string; readonly sizeBytes: number;}): boolean { if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { return false; } const normalized = input.mimeType.trim().toLowerCase(); return ( OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || normalized.startsWith("text/") || normalized === "application/pdf" );} export function toOpenCodeFileParts(input: { readonly attachments: ReadonlyArray<ChatAttachment> | undefined; readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;}): Array<FilePartInput> { const parts: Array<FilePartInput> = []; for (const attachment of input.attachments ?? []) { if (!isOpenCodeNativeFilePart(attachment)) { continue; } const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { continue; } parts.push({ type: "file", mime: attachment.mimeType, filename: attachment.name, url: NodeURL.pathToFileURL(attachmentPath).href, }); } return parts;} export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { if (runtimeMode === "full-access") { return [ { permission: "*", pattern: "*", action: "allow" }, { permission: "external_directory", pattern: "*", action: "allow" }, ]; } // "Auto-accept edits" is documented as "auto-approve edits, ask before other // actions", so prompting for every edit ignores the mode the user picked. // "auto" is left asking on purpose: the docs say providers without an AI // reviewer, OpenCode among them, fall back to Supervised for that mode. const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; // Session rules override OpenCode's agent defaults. Allow reads and task // updates, but keep its default approval rules for environment files. return [Lines 1–500 of 1060. Load more
import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; import { createOpencodeClient, type Agent, type FilePartInput, type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, type QuestionAnswer, type QuestionRequest, } from "@opencode-ai/sdk/v2"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as P from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export const MINIMUM_OPENCODE_VERSION = "1.14.19"; const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; const OpenCodeHealthSchema = Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, }); const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); export function resolveOpenCodeConfigContent( inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, ): string { return ( inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? OPENCODE_EMPTY_CONFIG_CONTENT ); } export function resolveOpenCodeServerPassword( input: { readonly external: boolean; readonly serverPassword?: string; readonly environment?: Readonly<Record<string, string | undefined>>; }, inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, ): string | undefined { if (input.serverPassword !== undefined) { return input.serverPassword; } if (input.external) { return undefined; } return input.environment === undefined ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD : input.environment.OPENCODE_SERVER_PASSWORD; } const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; readonly serverPassword?: string; readonly version: string; readonly isRunning: Effect.Effect<boolean>; readonly exitCode: Effect.Effect<number, never>; } export interface OpenCodeServerConnection { readonly url: string; readonly serverPassword?: string; readonly version: string; readonly exitCode: Effect.Effect<number, never> | null; readonly external: boolean; } const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ readonly operation: string; readonly cause?: unknown; readonly detail: string; }> { static readonly is = (u: unknown): u is OpenCodeRuntimeError => P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); } function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined; } export function openCodeRuntimeErrorDetail(cause: unknown): string { if (OpenCodeRuntimeError.is(cause)) return cause.detail; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful const anyCause = cause as Record<string, unknown>; const status = (anyCause.response as { status?: number } | undefined)?.status; const body = anyCause.error ?? anyCause.data ?? anyCause.body; const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); if (encodedBody) { return `status=${status ?? "?"} body=${encodedBody}`; } } return String(cause); } export const runOpenCodeSdk = <A>( operation: string, fn: (signal: AbortSignal) => Promise<A>, ): Effect.Effect<A, OpenCodeRuntimeError> => Effect.tryPromise({ try: fn, catch: (cause) => new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), }).pipe(Effect.withSpan(`opencode.${operation}`)); export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( client: OpencodeClient, ) { const healthOption = yield* runOpenCodeSdk("global.health", (signal) => client.global.health({ signal }), ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); if (Option.isNone(healthOption)) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: "Timed out while checking the OpenCode server version.", }); } const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( Effect.mapError( (cause) => new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, cause, }), ), ); if (parseSemver(health.version) === null) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, }); } if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { return yield* new OpenCodeRuntimeError({ operation: "global.health", detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, }); } return health.version; }); export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; readonly code: number; } export interface OpenCodeInventory { readonly providerList: ProviderListResponse; readonly agents: ReadonlyArray<Agent>; readonly skills: ReadonlyArray<OpenCodeSkill>; } export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string; } export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; readonly location?: string | null; } const OpenCodeSkillSchema = Schema.Struct({ name: Schema.optionalKey(Schema.NullOr(Schema.String)), description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), ); export interface OpenCodeRuntimeShape { /** * Spawns a local OpenCode server process. Its lifetime is bound to the caller's * `Scope.Scope` — the child is killed automatically when that scope closes. * Consumers that want a long-lived server must create and hold a scope explicitly * (see {@link Scope.make}) and close it when done. */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; readonly directory: string; readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>; /** * Returns a handle to either an externally-managed OpenCode server (when * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a * freshly spawned local server whose lifetime is bound to the caller's scope. */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly directory: string; readonly serverUrl?: string | null; readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>; readonly runOpenCodeCommand: (input: { readonly binaryPath: string; readonly args: ReadonlyArray<string>; readonly environment?: NodeJS.ProcessEnv; readonly cwd?: string; readonly maxOutputBytes?: number; }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; readonly directory: string; readonly serverPassword?: string; }) => OpencodeClient; readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; readonly loadOpenCodeSkills: ( client: OpencodeClient, ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; readonly loadSkillsFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; } function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { continue; } const match = line.match(/on\s+(https?:\/\/[^\s]+)/); return match?.[1] ?? null; } return null; } const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */ export function parseModelsCliOutput(stdout: string): { readonly providers: ReadonlyMap< string, { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } >; readonly connected: ReadonlyArray<string>; } { const providers = new Map< string, { id: string; name: string; models: { [key: string]: Model } } >(); const lines = stdout.split("\n"); let currentSlug: string | null = null; const jsonLines: Array<string> = []; const flushModel = () => { if (currentSlug !== null && jsonLines.length > 0) { const jsonStr = jsonLines.join("\n").trim(); if (jsonStr.length > 0) { try { const model = JSON.parse(jsonStr) as Model; const separator = currentSlug.indexOf("/"); if (separator > 0) { const providerID = currentSlug.slice(0, separator); const modelID = currentSlug.slice(separator + 1); let provider = providers.get(providerID); if (!provider) { provider = { id: providerID, name: providerID, models: {} }; providers.set(providerID, provider); } provider.models[modelID] = model; } } catch { // Skip unparseable model JSON } } } currentSlug = null; jsonLines.length = 0; }; for (const line of lines) { // A model's JSON body is a single `JSON.stringify` line starting with `{`, // while a provider/model slug is a bare `provider/model` header. Only the // latter can be a slug: without this guard a body line with no interior // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against // an empty body and the model is silently dropped. const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); if (slugMatch) { flushModel(); currentSlug = slugMatch[1]!; } else if (currentSlug !== null) { jsonLines.push(line); } } flushModel(); return { providers, connected: [...providers.keys()] }; } /** @internal */ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { const agents: Array<Agent> = []; const lines = stdout.split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array<string> = []; const flushAgent = () => { if (currentHeader !== null) { const jsonStr = blockLines.join("\n").trim(); if (jsonStr.length > 0) { try { const permission = JSON.parse(jsonStr); agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), permission, options: {}, }); } catch { // Skip unparseable agent } } } currentHeader = null; blockLines.length = 0; }; for (const line of lines) { const match = AGENT_HEADER_RE.exec(line); if (match) { flushAgent(); currentHeader = { name: match[1]!, mode: match[2]! }; } else if (currentHeader !== null) { blockLines.push(line); } } flushAgent(); return agents; } /** @internal */ export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { const result = decodeOpenCodeSkillsCliOutputExit(stdout); return Exit.isSuccess(result) ? result.value : []; } export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { if (typeof slug !== "string") { return null; } const trimmed = slug.trim(); const separator = trimmed.indexOf("/"); if (separator <= 0 || separator === trimmed.length - 1) { return null; } return { providerID: trimmed.slice(0, separator), modelID: trimmed.slice(separator + 1), }; } export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number], ): string { const header = question.header .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-"); return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; } /** * Attachments OpenCode can hand to a model as a native file part. Anything * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs * reject, or files over the direct-attachment size limit) would make the turn * fail before it starts, so those ride only as the file path ProviderService * puts in the prompt. */ const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; export function isOpenCodeNativeFilePart(input: { readonly mimeType: string; readonly sizeBytes: number; }): boolean { if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { return false; } const normalized = input.mimeType.trim().toLowerCase(); return ( OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || normalized.startsWith("text/") || normalized === "application/pdf" ); } export function toOpenCodeFileParts(input: { readonly attachments: ReadonlyArray<ChatAttachment> | undefined; readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; }): Array<FilePartInput> { const parts: Array<FilePartInput> = []; for (const attachment of input.attachments ?? []) { if (!isOpenCodeNativeFilePart(attachment)) { continue; } const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { continue; } parts.push({ type: "file", mime: attachment.mimeType, filename: attachment.name, url: NodeURL.pathToFileURL(attachmentPath).href, }); } return parts; } export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { if (runtimeMode === "full-access") { return [ { permission: "*", pattern: "*", action: "allow" }, { permission: "external_directory", pattern: "*", action: "allow" }, ]; } // "Auto-accept edits" is documented as "auto-approve edits, ask before other // actions", so prompting for every edit ignores the mode the user picked. // "auto" is left asking on purpose: the docs say providers without an AI // reviewer, OpenCode among them, fall back to Supervised for that mode. const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; // Session rules override OpenCode's agent defaults. Allow reads and task // updates, but keep its default approval rules for environment files. return [