| 1 | import * as NodeURL from "node:url"; |
| 2 | |
| 3 | import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; |
| 4 | import { |
| 5 | createOpencodeClient, |
| 6 | type Agent, |
| 7 | type Command, |
| 8 | type FilePartInput, |
| 9 | type Model, |
| 10 | type OpencodeClient, |
| 11 | type PermissionRuleset, |
| 12 | type ProviderListResponse, |
| 13 | type QuestionAnswer, |
| 14 | type QuestionRequest, |
| 15 | } from "@opencode-ai/sdk/v2"; |
| 16 | import * as Cause from "effect/Cause"; |
| 17 | import * as Context from "effect/Context"; |
| 18 | import * as Data from "effect/Data"; |
| 19 | import * as Deferred from "effect/Deferred"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Exit from "effect/Exit"; |
| 22 | import * as Fiber from "effect/Fiber"; |
| 23 | import * as Layer from "effect/Layer"; |
| 24 | import * as Option from "effect/Option"; |
| 25 | import * as P from "effect/Predicate"; |
| 26 | import * as Ref from "effect/Ref"; |
| 27 | import * as Result from "effect/Result"; |
| 28 | import * as Scope from "effect/Scope"; |
| 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Stream from "effect/Stream"; |
| 31 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 32 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 33 | |
| 34 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 35 | import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; |
| 36 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; |
| 37 | import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; |
| 38 | import * as NetService from "@t3tools/shared/Net"; |
| 39 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 40 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 41 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; |
| 42 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 43 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); |
| 44 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; |
| 45 | |
| 46 | export const MINIMUM_OPENCODE_VERSION = "1.14.19"; |
| 47 | const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; |
| 48 | |
| 49 | const OpenCodeHealthSchema = Schema.Struct({ |
| 50 | healthy: Schema.Literal(true), |
| 51 | version: Schema.String, |
| 52 | }); |
| 53 | const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); |
| 54 | |
| 55 | export function resolveOpenCodeConfigContent( |
| 56 | inputEnvironment: Readonly<Record<string, string | undefined>> | undefined, |
| 57 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, |
| 58 | ): string { |
| 59 | return ( |
| 60 | inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? |
| 61 | inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? |
| 62 | OPENCODE_EMPTY_CONFIG_CONTENT |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | export function resolveOpenCodeServerPassword( |
| 67 | input: { |
| 68 | readonly external: boolean; |
| 69 | readonly serverPassword?: string; |
| 70 | readonly environment?: Readonly<Record<string, string | undefined>>; |
| 71 | }, |
| 72 | inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env, |
| 73 | ): string | undefined { |
| 74 | if (input.serverPassword !== undefined) { |
| 75 | return input.serverPassword; |
| 76 | } |
| 77 | if (input.external) { |
| 78 | return undefined; |
| 79 | } |
| 80 | return input.environment === undefined |
| 81 | ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD |
| 82 | : input.environment.OPENCODE_SERVER_PASSWORD; |
| 83 | } |
| 84 | |
| 85 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; |
| 86 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 87 | const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; |
| 88 | const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 89 | export interface OpenCodeServerProcess { |
| 90 | readonly url: string; |
| 91 | readonly serverPassword?: string; |
| 92 | readonly version: string; |
| 93 | readonly isRunning: Effect.Effect<boolean>; |
| 94 | readonly exitCode: Effect.Effect<number, never>; |
| 95 | } |
| 96 | |
| 97 | export interface OpenCodeServerConnection { |
| 98 | readonly url: string; |
| 99 | readonly serverPassword?: string; |
| 100 | readonly version: string; |
| 101 | readonly exitCode: Effect.Effect<number, never> | null; |
| 102 | readonly external: boolean; |
| 103 | } |
| 104 | |
| 105 | const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; |
| 106 | export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ |
| 107 | readonly operation: string; |
| 108 | readonly cause?: unknown; |
| 109 | readonly detail: string; |
| 110 | }> {} |
| 111 | |
| 112 | export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError => |
| 113 | P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); |
| 114 | |
| 115 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { |
| 116 | const result = encodeUnknownJsonStringExit(input); |
| 117 | return Exit.isSuccess(result) ? result.value : undefined; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Formats OpenCode SDK and runtime failures, including errors with missing or |
| 122 | * non-string messages. |
| 123 | */ |
| 124 | export function openCodeRuntimeErrorDetail(cause: unknown): string { |
| 125 | if (isOpenCodeRuntimeError(cause)) return cause.detail; |
| 126 | // Effect's TimeoutError can be constructed without a message, so `message` |
| 127 | // is not guaranteed to be a string on every Error that reaches here. |
| 128 | if (cause instanceof Error) { |
| 129 | const message = cause.message; |
| 130 | if (typeof message === "string") { |
| 131 | const trimmedMessage = message.trim(); |
| 132 | if (trimmedMessage.length > 0) return trimmedMessage; |
| 133 | } |
| 134 | } |
| 135 | if (cause && typeof cause === "object") { |
| 136 | // SDK v2 throws { response, request, error? } shapes — extract what's useful |
| 137 | const anyCause = cause as Record<string, unknown>; |
| 138 | const status = (anyCause.response as { status?: number } | undefined)?.status; |
| 139 | const body = anyCause.error ?? anyCause.data ?? anyCause.body; |
| 140 | const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); |
| 141 | if (encodedBody) { |
| 142 | return `status=${status ?? "?"} body=${encodedBody}`; |
| 143 | } |
| 144 | } |
| 145 | return String(cause); |
| 146 | } |
| 147 | |
| 148 | export const runOpenCodeSdk = <A>( |
| 149 | operation: string, |
| 150 | fn: (signal: AbortSignal) => Promise<A>, |
| 151 | ): Effect.Effect<A, OpenCodeRuntimeError> => |
| 152 | Effect.tryPromise({ |
| 153 | try: fn, |
| 154 | catch: (cause) => |
| 155 | new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), |
| 156 | }).pipe(Effect.withSpan(`opencode.${operation}`)); |
| 157 | |
| 158 | export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( |
| 159 | client: OpencodeClient, |
| 160 | ) { |
| 161 | const healthOption = yield* runOpenCodeSdk("global.health", (signal) => |
| 162 | client.global.health({ signal }), |
| 163 | ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); |
| 164 | if (Option.isNone(healthOption)) { |
| 165 | return yield* new OpenCodeRuntimeError({ |
| 166 | operation: "global.health", |
| 167 | detail: "Timed out while checking the OpenCode server version.", |
| 168 | }); |
| 169 | } |
| 170 | |
| 171 | const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( |
| 172 | Effect.mapError( |
| 173 | (cause) => |
| 174 | new OpenCodeRuntimeError({ |
| 175 | operation: "global.health", |
| 176 | detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, |
| 177 | cause, |
| 178 | }), |
| 179 | ), |
| 180 | ); |
| 181 | if (parseSemver(health.version) === null) { |
| 182 | return yield* new OpenCodeRuntimeError({ |
| 183 | operation: "global.health", |
| 184 | detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, |
| 185 | }); |
| 186 | } |
| 187 | if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { |
| 188 | return yield* new OpenCodeRuntimeError({ |
| 189 | operation: "global.health", |
| 190 | detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, |
| 191 | }); |
| 192 | } |
| 193 | return health.version; |
| 194 | }); |
| 195 | |
| 196 | export interface OpenCodeCommandResult { |
| 197 | readonly stdout: string; |
| 198 | readonly stderr: string; |
| 199 | readonly code: number; |
| 200 | } |
| 201 | |
| 202 | export interface OpenCodeInventory { |
| 203 | readonly providerList: ProviderListResponse; |
| 204 | readonly agents: ReadonlyArray<Agent>; |
| 205 | readonly skills: ReadonlyArray<OpenCodeSkill>; |
| 206 | readonly commands?: ReadonlyArray<OpenCodeSlashCommand>; |
| 207 | } |
| 208 | |
| 209 | export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">; |
| 210 | |
| 211 | /** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ |
| 212 | export const loadOpenCodeCommands = (client: OpencodeClient) => |
| 213 | runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( |
| 214 | Effect.map((result): ReadonlyArray<OpenCodeSlashCommand> => |
| 215 | (result.data ?? []).map(({ name, description, source, hints }) => ({ |
| 216 | name, |
| 217 | ...(description === undefined ? {} : { description }), |
| 218 | ...(source === undefined ? {} : { source }), |
| 219 | hints, |
| 220 | })), |
| 221 | ), |
| 222 | ); |
| 223 | |
| 224 | export interface ParsedOpenCodeModelSlug { |
| 225 | readonly providerID: string; |
| 226 | readonly modelID: string; |
| 227 | } |
| 228 | |
| 229 | export interface OpenCodeSkill { |
| 230 | readonly name?: string | null; |
| 231 | readonly description?: string | null; |
| 232 | readonly location?: string | null; |
| 233 | } |
| 234 | |
| 235 | const OpenCodeSkillSchema = Schema.Struct({ |
| 236 | name: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 237 | description: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 238 | location: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 239 | }); |
| 240 | const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( |
| 241 | Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), |
| 242 | ); |
| 243 | |
| 244 | export class OpenCodeRuntime extends Context.Service< |
| 245 | OpenCodeRuntime, |
| 246 | { |
| 247 | /** |
| 248 | * Spawns a local OpenCode server process. Its lifetime is bound to the caller's |
| 249 | * `Scope.Scope` — the child is killed automatically when that scope closes. |
| 250 | * Consumers that want a long-lived server must create and hold a scope explicitly |
| 251 | * (see {@link Scope.make}) and close it when done. |
| 252 | */ |
| 253 | readonly startOpenCodeServerProcess: (input: { |
| 254 | readonly binaryPath: string; |
| 255 | readonly directory: string; |
| 256 | readonly serverPassword?: string; |
| 257 | readonly environment?: NodeJS.ProcessEnv; |
| 258 | readonly port?: number; |
| 259 | readonly hostname?: string; |
| 260 | readonly timeoutMs?: number; |
| 261 | /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ |
| 262 | readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntimeError>; |
| 263 | }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>; |
| 264 | /** |
| 265 | * Returns a handle to either an externally-managed OpenCode server (when |
| 266 | * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a |
| 267 | * freshly spawned local server whose lifetime is bound to the caller's scope. |
| 268 | */ |
| 269 | readonly connectToOpenCodeServer: (input: { |
| 270 | readonly binaryPath: string; |
| 271 | readonly directory: string; |
| 272 | readonly serverUrl?: string | null; |
| 273 | readonly serverPassword?: string; |
| 274 | readonly environment?: NodeJS.ProcessEnv; |
| 275 | readonly port?: number; |
| 276 | readonly hostname?: string; |
| 277 | readonly timeoutMs?: number; |
| 278 | }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>; |
| 279 | readonly runOpenCodeCommand: (input: { |
| 280 | readonly binaryPath: string; |
| 281 | readonly args: ReadonlyArray<string>; |
| 282 | readonly environment?: NodeJS.ProcessEnv; |
| 283 | readonly cwd?: string; |
| 284 | readonly maxOutputBytes?: number; |
| 285 | }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>; |
| 286 | readonly createOpenCodeSdkClient: (input: { |
| 287 | readonly baseUrl: string; |
| 288 | readonly directory: string; |
| 289 | readonly serverPassword?: string; |
| 290 | }) => OpencodeClient; |
| 291 | readonly loadOpenCodeInventory: ( |
| 292 | client: OpencodeClient, |
| 293 | ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 294 | readonly loadOpenCodeSkills: ( |
| 295 | client: OpencodeClient, |
| 296 | ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; |
| 297 | readonly loadInventoryFromCli: (input: { |
| 298 | readonly binaryPath: string; |
| 299 | readonly cwd: string; |
| 300 | readonly environment?: NodeJS.ProcessEnv; |
| 301 | }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>; |
| 302 | readonly loadSkillsFromCli: (input: { |
| 303 | readonly binaryPath: string; |
| 304 | readonly cwd: string; |
| 305 | readonly environment?: NodeJS.ProcessEnv; |
| 306 | }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>; |
| 307 | } |
| 308 | >()("@t3tools/provider-opencode/server/OpenCodeRuntime") {} |
| 309 | |
| 310 | function parseServerUrlFromOutput(output: string): string | null { |
| 311 | for (const line of output.split("\n")) { |
| 312 | const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); |
| 313 | if (match?.[1]) return match[1]; |
| 314 | } |
| 315 | return null; |
| 316 | } |
| 317 | |
| 318 | const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; |
| 319 | const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; |
| 320 | |
| 321 | // Agents that are always hidden in OpenCode but the CLI "agent list" command |
| 322 | // does not expose the hidden flag. Keep in sync with OpenCode agent |
| 323 | // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). |
| 324 | const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); |
| 325 | |
| 326 | /** @internal */ |
| 327 | export function parseModelsCliOutput(stdout: string): { |
| 328 | readonly providers: ReadonlyMap< |
| 329 | string, |
| 330 | { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } |
| 331 | >; |
| 332 | readonly connected: ReadonlyArray<string>; |
| 333 | } { |
| 334 | const providers = new Map< |
| 335 | string, |
| 336 | { id: string; name: string; models: { [key: string]: Model } } |
| 337 | >(); |
| 338 | const lines = stdout.split("\n"); |
| 339 | let currentSlug: string | null = null; |
| 340 | const jsonLines: Array<string> = []; |
| 341 | |
| 342 | const flushModel = () => { |
| 343 | if (currentSlug !== null && jsonLines.length > 0) { |
| 344 | const jsonStr = jsonLines.join("\n").trim(); |
| 345 | if (jsonStr.length > 0) { |
| 346 | try { |
| 347 | const model = JSON.parse(jsonStr) as Model; |
| 348 | const separator = currentSlug.indexOf("/"); |
| 349 | if (separator > 0) { |
| 350 | const providerID = currentSlug.slice(0, separator); |
| 351 | const modelID = currentSlug.slice(separator + 1); |
| 352 | let provider = providers.get(providerID); |
| 353 | if (!provider) { |
| 354 | provider = { id: providerID, name: providerID, models: {} }; |
| 355 | providers.set(providerID, provider); |
| 356 | } |
| 357 | provider.models[modelID] = model; |
| 358 | } |
| 359 | } catch { |
| 360 | // Skip unparseable model JSON |
| 361 | } |
| 362 | } |
| 363 | } |
| 364 | currentSlug = null; |
| 365 | jsonLines.length = 0; |
| 366 | }; |
| 367 | |
| 368 | for (const line of lines) { |
| 369 | // A model's JSON body is a single `JSON.stringify` line starting with `{`, |
| 370 | // while a provider/model slug is a bare `provider/model` header. Only the |
| 371 | // latter can be a slug: without this guard a body line with no interior |
| 372 | // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose |
| 373 | // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against |
| 374 | // an empty body and the model is silently dropped. |
| 375 | const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); |
| 376 | if (slugMatch) { |
| 377 | flushModel(); |
| 378 | currentSlug = slugMatch[1]!; |
| 379 | } else if (currentSlug !== null) { |
| 380 | jsonLines.push(line); |
| 381 | } |
| 382 | } |
| 383 | flushModel(); |
| 384 | |
| 385 | return { providers, connected: [...providers.keys()] }; |
| 386 | } |
| 387 | |
| 388 | /** @internal */ |
| 389 | export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> { |
| 390 | const agents: Array<Agent> = []; |
| 391 | const lines = stdout.split("\n"); |
| 392 | let currentHeader: { name: string; mode: string } | null = null; |
| 393 | const blockLines: Array<string> = []; |
| 394 | |
| 395 | const flushAgent = () => { |
| 396 | if (currentHeader !== null) { |
| 397 | const jsonStr = blockLines.join("\n").trim(); |
| 398 | if (jsonStr.length > 0) { |
| 399 | try { |
| 400 | const permission = JSON.parse(jsonStr); |
| 401 | agents.push({ |
| 402 | name: currentHeader.name, |
| 403 | mode: currentHeader.mode as Agent["mode"], |
| 404 | hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), |
| 405 | permission, |
| 406 | options: {}, |
| 407 | }); |
| 408 | } catch { |
| 409 | // Skip unparseable agent |
| 410 | } |
| 411 | } |
| 412 | } |
| 413 | currentHeader = null; |
| 414 | blockLines.length = 0; |
| 415 | }; |
| 416 | |
| 417 | for (const line of lines) { |
| 418 | const match = AGENT_HEADER_RE.exec(line); |
| 419 | if (match) { |
| 420 | flushAgent(); |
| 421 | currentHeader = { name: match[1]!, mode: match[2]! }; |
| 422 | } else if (currentHeader !== null) { |
| 423 | blockLines.push(line); |
| 424 | } |
| 425 | } |
| 426 | flushAgent(); |
| 427 | |
| 428 | return agents; |
| 429 | } |
| 430 | |
| 431 | /** @internal */ |
| 432 | export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> { |
| 433 | const result = decodeOpenCodeSkillsCliOutputExit(stdout); |
| 434 | return Exit.isSuccess(result) ? result.value : []; |
| 435 | } |
| 436 | |
| 437 | export function parseOpenCodeModelSlug( |
| 438 | slug: string | null | undefined, |
| 439 | ): ParsedOpenCodeModelSlug | null { |
| 440 | if (typeof slug !== "string") { |
| 441 | return null; |
| 442 | } |
| 443 | |
| 444 | const trimmed = slug.trim(); |
| 445 | const separator = trimmed.indexOf("/"); |
| 446 | if (separator <= 0 || separator === trimmed.length - 1) { |
| 447 | return null; |
| 448 | } |
| 449 | |
| 450 | return { |
| 451 | providerID: trimmed.slice(0, separator), |
| 452 | modelID: trimmed.slice(separator + 1), |
| 453 | }; |
| 454 | } |
| 455 | |
| 456 | export function openCodeQuestionId( |
| 457 | index: number, |
| 458 | question: QuestionRequest["questions"][number], |
| 459 | ): string { |
| 460 | const header = question.header |
| 461 | .trim() |
| 462 | .toLowerCase() |
| 463 | .replace(/[^a-z0-9_-]+/g, "-"); |
| 464 | return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * Attachments OpenCode can hand to a model as a native file part. Anything |
| 469 | * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs |
| 470 | * reject, or files over the direct-attachment size limit) would make the turn |
| 471 | * fail before it starts, so those ride only as the file path ProviderService |
| 472 | * puts in the prompt. Text is native only as text/plain, which OpenCode reads |
| 473 | * itself; other text types (HTML, Markdown, CSV) reach the model API as raw |
| 474 | * file parts, which Anthropic and OpenAI reject. |
| 475 | */ |
| 476 | const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); |
| 477 | const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; |
| 478 | |
| 479 | function isOpenCodeNativeFilePart(input: { |
| 480 | readonly mimeType: string; |
| 481 | readonly sizeBytes: number; |
| 482 | }): boolean { |
| 483 | if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { |
| 484 | return false; |
| 485 | } |
| 486 | const normalized = input.mimeType.trim().toLowerCase(); |
| 487 | return ( |
| 488 | OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || |
| 489 | normalized === "text/plain" || |
| 490 | normalized === "application/pdf" |
| 491 | ); |
| 492 | } |
| 493 | |
| 494 | export function toOpenCodeFileParts(input: { |
| 495 | readonly attachments: ReadonlyArray<ChatAttachment> | undefined; |
| 496 | readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; |
| 497 | }): Array<FilePartInput> { |
| 498 | const parts: Array<FilePartInput> = []; |
| 499 | |
| 500 | for (const attachment of input.attachments ?? []) { |
| 501 | if ( |
| 502 | attachment.type === "file" && |
| 503 | "source" in attachment && |
| 504 | attachment.source?._tag === "pasted-text" |
| 505 | ) { |
| 506 | continue; |
| 507 | } |
| 508 | if (!isOpenCodeNativeFilePart(attachment)) { |
| 509 | continue; |
| 510 | } |
| 511 | const attachmentPath = input.resolveAttachmentPath(attachment); |
| 512 | if (!attachmentPath) { |
| 513 | continue; |
| 514 | } |
| 515 | |
| 516 | parts.push({ |
| 517 | type: "file", |
| 518 | mime: attachment.mimeType, |
| 519 | filename: attachment.name, |
| 520 | url: NodeURL.pathToFileURL(attachmentPath).href, |
| 521 | }); |
| 522 | } |
| 523 | |
| 524 | return parts; |
| 525 | } |
| 526 | |
| 527 | export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { |
| 528 | if (runtimeMode === "full-access") { |
| 529 | return [ |
| 530 | { permission: "*", pattern: "*", action: "allow" }, |
| 531 | { permission: "external_directory", pattern: "*", action: "allow" }, |
| 532 | ]; |
| 533 | } |
| 534 | |
| 535 | // "Auto-accept edits" is documented as "auto-approve edits, ask before other |
| 536 | // actions", so prompting for every edit ignores the mode the user picked. |
| 537 | // "auto" is left asking on purpose: the docs say providers without an AI |
| 538 | // reviewer, OpenCode among them, fall back to Supervised for that mode. |
| 539 | const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; |
| 540 | |
| 541 | // Session rules override OpenCode's agent defaults. Allow reads and task |
| 542 | // updates, but keep its default approval rules for environment files. |
| 543 | return [ |
| 544 | { permission: "*", pattern: "*", action: "ask" }, |
| 545 | { permission: "read", pattern: "*", action: "allow" }, |
| 546 | { permission: "read", pattern: "*.env", action: "ask" }, |
| 547 | { permission: "read", pattern: "*.env.*", action: "ask" }, |
| 548 | { permission: "read", pattern: "*.env.example", action: "allow" }, |
| 549 | { permission: "glob", pattern: "*", action: "allow" }, |
| 550 | { permission: "grep", pattern: "*", action: "allow" }, |
| 551 | { permission: "lsp", pattern: "*", action: "allow" }, |
| 552 | { permission: "skill", pattern: "*", action: "allow" }, |
| 553 | { permission: "todowrite", pattern: "*", action: "allow" }, |
| 554 | { permission: "bash", pattern: "*", action: "ask" }, |
| 555 | { permission: "edit", pattern: "*", action: editAction }, |
| 556 | { permission: "webfetch", pattern: "*", action: "ask" }, |
| 557 | { permission: "websearch", pattern: "*", action: "ask" }, |
| 558 | { permission: "codesearch", pattern: "*", action: "ask" }, |
| 559 | { permission: "external_directory", pattern: "*", action: "ask" }, |
| 560 | { permission: "doom_loop", pattern: "*", action: "ask" }, |
| 561 | { permission: "question", pattern: "*", action: "allow" }, |
| 562 | ]; |
| 563 | } |
| 564 | |
| 565 | export function toOpenCodePermissionReply( |
| 566 | decision: ProviderApprovalDecision, |
| 567 | ): "once" | "always" | "reject" { |
| 568 | switch (decision) { |
| 569 | case "accept": |
| 570 | return "once"; |
| 571 | case "acceptForSession": |
| 572 | case "acceptAlways": |
| 573 | return "always"; |
| 574 | case "decline": |
| 575 | case "cancel": |
| 576 | default: |
| 577 | return "reject"; |
| 578 | } |
| 579 | } |
| 580 | |
| 581 | export function toOpenCodeQuestionAnswers( |
| 582 | request: QuestionRequest, |
| 583 | answers: Record<string, unknown>, |
| 584 | ): Array<QuestionAnswer> { |
| 585 | return request.questions.map((question, index) => { |
| 586 | const raw = |
| 587 | answers[openCodeQuestionId(index, question)] ?? |
| 588 | answers[question.header] ?? |
| 589 | answers[question.question]; |
| 590 | if (Array.isArray(raw)) { |
| 591 | return raw.filter((value): value is string => typeof value === "string"); |
| 592 | } |
| 593 | if (typeof raw === "string") { |
| 594 | return raw.trim().length > 0 ? [raw] : []; |
| 595 | } |
| 596 | return []; |
| 597 | }); |
| 598 | } |
| 599 | |
| 600 | function ensureRuntimeError( |
| 601 | operation: OpenCodeRuntimeError["operation"], |
| 602 | detail: string, |
| 603 | cause: unknown, |
| 604 | ): OpenCodeRuntimeError { |
| 605 | return isOpenCodeRuntimeError(cause) |
| 606 | ? cause |
| 607 | : new OpenCodeRuntimeError({ operation, detail, cause }); |
| 608 | } |
| 609 | |
| 610 | const makeOpenCodeRuntime = Effect.gen(function* () { |
| 611 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 612 | const netService = yield* NetService.NetService; |
| 613 | const hostPlatform = yield* HostProcess.Platform; |
| 614 | const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; |
| 615 | const agentScope = yield* AgentScope; |
| 616 | const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => |
| 617 | resolveSpawnCommand(command, args, env ? { env } : {}); |
| 618 | |
| 619 | const runOpenCodeCommand: OpenCodeRuntime["Service"]["runOpenCodeCommand"] = (input) => |
| 620 | Effect.gen(function* () { |
| 621 | const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); |
| 622 | const child = yield* spawner.spawn( |
| 623 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 624 | detached: hostPlatform !== "win32", |
| 625 | shell: spawnCommand.shell, |
| 626 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 627 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), |
| 628 | }), |
| 629 | ); |
| 630 | const terminateCommandGroup = |
| 631 | hostPlatform === "win32" |
| 632 | ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) |
| 633 | : Effect.sync(() => { |
| 634 | try { |
| 635 | signalProcessGroup(Number(child.pid), "SIGKILL"); |
| 636 | } catch { |
| 637 | // The command and its process group may already have exited. |
| 638 | } |
| 639 | }); |
| 640 | yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); |
| 641 | const collectOptions = |
| 642 | input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; |
| 643 | const [stdout, stderr, code] = yield* Effect.all( |
| 644 | [ |
| 645 | collectStreamAsString(child.stdout, collectOptions), |
| 646 | collectStreamAsString(child.stderr, collectOptions), |
| 647 | child.exitCode, |
| 648 | ], |
| 649 | { concurrency: "unbounded" }, |
| 650 | ); |
| 651 | const exitCode = Number(code); |
| 652 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { |
| 653 | return yield* new OpenCodeRuntimeError({ |
| 654 | operation: "runOpenCodeCommand", |
| 655 | detail: `spawn ${input.binaryPath} ENOENT`, |
| 656 | }); |
| 657 | } |
| 658 | return { |
| 659 | stdout, |
| 660 | stderr, |
| 661 | code: exitCode, |
| 662 | } satisfies OpenCodeCommandResult; |
| 663 | }).pipe( |
| 664 | Effect.scoped, |
| 665 | Effect.mapError((cause) => |
| 666 | ensureRuntimeError( |
| 667 | "runOpenCodeCommand", |
| 668 | `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, |
| 669 | cause, |
| 670 | ), |
| 671 | ), |
| 672 | ); |
| 673 | |
| 674 | const createOpenCodeSdkClient: OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] = (input) => |
| 675 | createOpencodeClient({ |
| 676 | baseUrl: input.baseUrl, |
| 677 | directory: input.directory, |
| 678 | ...(input.serverPassword |
| 679 | ? { |
| 680 | headers: { |
| 681 | Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, |
| 682 | }, |
| 683 | } |
| 684 | : {}), |
| 685 | throwOnError: true, |
| 686 | }); |
| 687 | |
| 688 | const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = ( |
| 689 | input, |
| 690 | ) => |
| 691 | Effect.gen(function* () { |
| 692 | // Bind this server's lifetime to the caller's scope. When the caller's |
| 693 | // scope closes, the spawned child is killed and all associated fibers |
| 694 | // are interrupted automatically — no `close()` method needed. |
| 695 | const runtimeScope = yield* Scope.Scope; |
| 696 | |
| 697 | const hostname = input.hostname ?? DEFAULT_HOSTNAME; |
| 698 | const port = |
| 699 | input.port ?? |
| 700 | (yield* netService.findAvailablePort(0).pipe( |
| 701 | Effect.mapError( |
| 702 | (cause) => |
| 703 | new OpenCodeRuntimeError({ |
| 704 | operation: "startOpenCodeServerProcess", |
| 705 | detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, |
| 706 | cause, |
| 707 | }), |
| 708 | ), |
| 709 | )); |
| 710 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; |
| 711 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; |
| 712 | // One server runs every session of the instance, so its scope has no thread. |
| 713 | const launch = yield* agentScope.wrap({ |
| 714 | command: input.binaryPath, |
| 715 | args, |
| 716 | name: "opencode", |
| 717 | env: input.environment, |
| 718 | }); |
| 719 | const spawnCommand = yield* resolveCommand(launch.command, launch.args, input.environment); |
| 720 | const serverPassword = resolveOpenCodeServerPassword({ |
| 721 | external: false, |
| 722 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), |
| 723 | ...(input.environment !== undefined ? { environment: input.environment } : {}), |
| 724 | }); |
| 725 | |
| 726 | // Scopes close in reverse order. Forking this before the group kill is |
| 727 | // registered forgets the ledger entry only once the group is stopped. |
| 728 | const ledgerScope = yield* Scope.fork(runtimeScope); |
| 729 | const child = yield* spawner |
| 730 | .spawn( |
| 731 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 732 | detached: hostPlatform !== "win32", |
| 733 | shell: spawnCommand.shell, |
| 734 | env: { |
| 735 | ...input.environment, |
| 736 | ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), |
| 737 | // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or |
| 738 | // the inherited process environment, only falling back to the |
| 739 | // empty config when neither is set. Setting it unconditionally |
| 740 | // previously clobbered the user's opencode config, hiding their |
| 741 | // providers/models. The value is set explicitly (rather than |
| 742 | // relying on inheritance) because `extendEnv` is false whenever |
| 743 | // `input.environment` is provided. |
| 744 | OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), |
| 745 | }, |
| 746 | extendEnv: input.environment === undefined, |
| 747 | }), |
| 748 | ) |
| 749 | .pipe( |
| 750 | Effect.provideService(Scope.Scope, runtimeScope), |
| 751 | Effect.mapError( |
| 752 | (cause) => |
| 753 | new OpenCodeRuntimeError({ |
| 754 | operation: "startOpenCodeServerProcess", |
| 755 | detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, |
| 756 | cause, |
| 757 | }), |
| 758 | ), |
| 759 | ); |
| 760 | |
| 761 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => |
| 762 | hostPlatform === "win32" |
| 763 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) |
| 764 | : Effect.sync(() => { |
| 765 | try { |
| 766 | signalProcessGroup(Number(child.pid), signal); |
| 767 | } catch { |
| 768 | // The direct child may already have exited after starting the |
| 769 | // server; the process group kill is best-effort cleanup for |
| 770 | // any serve process left in that group. |
| 771 | } |
| 772 | }); |
| 773 | const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( |
| 774 | Effect.andThen(Effect.sleep("1 second")), |
| 775 | Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), |
| 776 | Effect.ignore, |
| 777 | ); |
| 778 | // Registered before recording, so an interrupt while the ledger writes |
| 779 | // still stops the group. |
| 780 | yield* Scope.addFinalizer(runtimeScope, terminateChild); |
| 781 | const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); |
| 782 | yield* Scope.addFinalizer(ledgerScope, forgetServer); |
| 783 | |
| 784 | const stdoutRef = yield* Ref.make<string | null>(""); |
| 785 | const stderrRef = yield* Ref.make<string | null>(""); |
| 786 | const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>(); |
| 787 | |
| 788 | const setReadyFromStdoutChunk = (chunk: string) => |
| 789 | Ref.modify(stdoutRef, (stdout) => { |
| 790 | if (stdout === null) { |
| 791 | return [null, null] as const; |
| 792 | } |
| 793 | const nextStdout = `${stdout}${chunk}`; |
| 794 | return [ |
| 795 | parseServerUrlFromOutput(nextStdout), |
| 796 | nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), |
| 797 | ] as const; |
| 798 | }).pipe( |
| 799 | Effect.flatMap((parsed) => |
| 800 | parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, |
| 801 | ), |
| 802 | ); |
| 803 | |
| 804 | const stdoutFiber = yield* child.stdout.pipe( |
| 805 | Stream.decodeText(), |
| 806 | Stream.runForEach(setReadyFromStdoutChunk), |
| 807 | Effect.ignore, |
| 808 | Effect.forkIn(runtimeScope), |
| 809 | ); |
| 810 | const stderrFiber = yield* child.stderr.pipe( |
| 811 | Stream.decodeText(), |
| 812 | Stream.runForEach((chunk) => |
| 813 | Ref.update(stderrRef, (stderr) => |
| 814 | stderr === null |
| 815 | ? null |
| 816 | : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), |
| 817 | ), |
| 818 | ), |
| 819 | Effect.ignore, |
| 820 | Effect.forkIn(runtimeScope), |
| 821 | ); |
| 822 | |
| 823 | const exitFiber = yield* child.exitCode.pipe( |
| 824 | Effect.flatMap((code) => |
| 825 | Effect.gen(function* () { |
| 826 | const stdout = (yield* Ref.get(stdoutRef)) ?? ""; |
| 827 | const stderr = (yield* Ref.get(stderrRef)) ?? ""; |
| 828 | const exitCode = Number(code); |
| 829 | yield* Deferred.fail( |
| 830 | readyDeferred, |
| 831 | new OpenCodeRuntimeError({ |
| 832 | operation: "startOpenCodeServerProcess", |
| 833 | detail: [ |
| 834 | `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, |
| 835 | stdout.trim() ? `stdout:\n${stdout.trim()}` : null, |
| 836 | stderr.trim() ? `stderr:\n${stderr.trim()}` : null, |
| 837 | ] |
| 838 | .filter(Boolean) |
| 839 | .join("\n\n"), |
| 840 | cause: { exitCode, stdout, stderr }, |
| 841 | }), |
| 842 | ).pipe(Effect.ignore); |
| 843 | }), |
| 844 | ), |
| 845 | Effect.ignore, |
| 846 | Effect.forkIn(runtimeScope), |
| 847 | ); |
| 848 | |
| 849 | const readyExit = yield* Effect.exit( |
| 850 | Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), |
| 851 | ); |
| 852 | |
| 853 | if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { |
| 854 | yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); |
| 855 | } |
| 856 | |
| 857 | if (Exit.isFailure(readyExit)) { |
| 858 | const squashed = Cause.squash(readyExit.cause); |
| 859 | return yield* ensureRuntimeError( |
| 860 | "startOpenCodeServerProcess", |
| 861 | `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, |
| 862 | squashed, |
| 863 | ); |
| 864 | } |
| 865 | |
| 866 | const readyOption = readyExit.value; |
| 867 | if (Option.isNone(readyOption)) { |
| 868 | return yield* new OpenCodeRuntimeError({ |
| 869 | operation: "startOpenCodeServerProcess", |
| 870 | detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, |
| 871 | }); |
| 872 | } |
| 873 | |
| 874 | // Keep draining both pipes until the process scope closes. Stopping the |
| 875 | // readers can block OpenCode when its output buffers fill. Startup output |
| 876 | // is no longer needed, so discard later output instead of retaining it. |
| 877 | yield* Ref.set(stdoutRef, null); |
| 878 | yield* Ref.set(stderrRef, null); |
| 879 | |
| 880 | const url = readyOption.value; |
| 881 | const version = yield* ( |
| 882 | input.verify?.(url) ?? |
| 883 | verifyOpenCodeServerVersion( |
| 884 | createOpenCodeSdkClient({ |
| 885 | baseUrl: url, |
| 886 | directory: input.directory, |
| 887 | ...(serverPassword !== undefined ? { serverPassword } : {}), |
| 888 | }), |
| 889 | ) |
| 890 | ); |
| 891 | |
| 892 | return { |
| 893 | url, |
| 894 | ...(serverPassword !== undefined ? { serverPassword } : {}), |
| 895 | version, |
| 896 | isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), |
| 897 | exitCode: child.exitCode.pipe( |
| 898 | Effect.map(Number), |
| 899 | Effect.orElseSucceed(() => 0), |
| 900 | ), |
| 901 | } satisfies OpenCodeServerProcess; |
| 902 | }); |
| 903 | |
| 904 | const connectToOpenCodeServer: OpenCodeRuntime["Service"]["connectToOpenCodeServer"] = ( |
| 905 | input, |
| 906 | ) => { |
| 907 | const serverUrl = input.serverUrl?.trim(); |
| 908 | if (serverUrl) { |
| 909 | const serverPassword = resolveOpenCodeServerPassword({ |
| 910 | external: true, |
| 911 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), |
| 912 | }); |
| 913 | return verifyOpenCodeServerVersion( |
| 914 | createOpenCodeSdkClient({ |
| 915 | baseUrl: serverUrl, |
| 916 | directory: input.directory, |
| 917 | ...(serverPassword !== undefined ? { serverPassword } : {}), |
| 918 | }), |
| 919 | ).pipe( |
| 920 | Effect.map((version) => ({ |
| 921 | url: serverUrl, |
| 922 | ...(serverPassword !== undefined ? { serverPassword } : {}), |
| 923 | version, |
| 924 | exitCode: null, |
| 925 | external: true, |
| 926 | })), |
| 927 | ); |
| 928 | } |
| 929 | |
| 930 | return startOpenCodeServerProcess({ |
| 931 | binaryPath: input.binaryPath, |
| 932 | directory: input.directory, |
| 933 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), |
| 934 | ...(input.environment !== undefined ? { environment: input.environment } : {}), |
| 935 | ...(input.port !== undefined ? { port: input.port } : {}), |
| 936 | ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), |
| 937 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), |
| 938 | }).pipe( |
| 939 | Effect.map((server) => ({ |
| 940 | url: server.url, |
| 941 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), |
| 942 | version: server.version, |
| 943 | exitCode: server.exitCode, |
| 944 | external: false, |
| 945 | })), |
| 946 | ); |
| 947 | }; |
| 948 | |
| 949 | const loadProviders = (client: OpencodeClient) => |
| 950 | runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( |
| 951 | Effect.filterMapOrFail( |
| 952 | (list) => |
| 953 | list.data |
| 954 | ? Result.succeed(list.data) |
| 955 | : Result.fail( |
| 956 | new OpenCodeRuntimeError({ |
| 957 | operation: "provider.list", |
| 958 | detail: "OpenCode provider list was empty.", |
| 959 | }), |
| 960 | ), |
| 961 | (result) => result, |
| 962 | ), |
| 963 | ); |
| 964 | |
| 965 | const loadAgents = (client: OpencodeClient) => |
| 966 | runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( |
| 967 | Effect.map((result) => result.data ?? []), |
| 968 | Effect.orElseSucceed((): ReadonlyArray<Agent> => []), |
| 969 | ); |
| 970 | |
| 971 | const loadOpenCodeSkills: OpenCodeRuntime["Service"]["loadOpenCodeSkills"] = (client) => |
| 972 | runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( |
| 973 | Effect.map((result) => |
| 974 | (result.data ?? []).map((skill) => ({ |
| 975 | name: skill.name, |
| 976 | ...(skill.description === undefined ? {} : { description: skill.description }), |
| 977 | location: skill.location, |
| 978 | })), |
| 979 | ), |
| 980 | ); |
| 981 | const loadSkills = (client: OpencodeClient) => |
| 982 | loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => [])); |
| 983 | |
| 984 | const loadOpenCodeInventory: OpenCodeRuntime["Service"]["loadOpenCodeInventory"] = (client) => |
| 985 | Effect.all( |
| 986 | [ |
| 987 | loadProviders(client), |
| 988 | loadAgents(client), |
| 989 | loadSkills(client), |
| 990 | loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), |
| 991 | ], |
| 992 | { |
| 993 | concurrency: "unbounded", |
| 994 | }, |
| 995 | ).pipe( |
| 996 | Effect.map(([providerList, agents, skills, commands]) => ({ |
| 997 | providerList, |
| 998 | agents, |
| 999 | skills, |
| 1000 | commands, |
| 1001 | })), |
| 1002 | ); |
| 1003 | |
| 1004 | const loadInventoryFromCli: OpenCodeRuntime["Service"]["loadInventoryFromCli"] = (input) => |
| 1005 | Effect.gen(function* () { |
| 1006 | const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); |
| 1007 | const commandContext = { cwd: input.cwd, ...env }; |
| 1008 | |
| 1009 | const runModelsCli = () => |
| 1010 | runOpenCodeCommand({ |
| 1011 | binaryPath: input.binaryPath, |
| 1012 | args: ["models", "--verbose"], |
| 1013 | ...commandContext, |
| 1014 | }).pipe(Effect.exit); |
| 1015 | const runAgentsCli = () => |
| 1016 | runOpenCodeCommand({ |
| 1017 | binaryPath: input.binaryPath, |
| 1018 | args: ["agent", "list"], |
| 1019 | ...commandContext, |
| 1020 | }).pipe(Effect.exit); |
| 1021 | const runSkillsCli = () => |
| 1022 | runOpenCodeCommand({ |
| 1023 | binaryPath: input.binaryPath, |
| 1024 | args: ["debug", "skill"], |
| 1025 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, |
| 1026 | ...commandContext, |
| 1027 | }).pipe(Effect.exit); |
| 1028 | |
| 1029 | // Every OpenCode CLI command opens the same shared SQLite database. Running them |
| 1030 | // concurrently causes "database is locked" failures, so run them one at a time. |
| 1031 | const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( |
| 1032 | [runModelsCli(), runAgentsCli(), runSkillsCli()], |
| 1033 | { concurrency: 1 }, |
| 1034 | ); |
| 1035 | let modelsResult = initialModelsResult; |
| 1036 | let agentsResult = initialAgentsResult; |
| 1037 | let skillsResult = initialSkillsResult; |
| 1038 | |
| 1039 | // Retry once after 1s on transient failures (e.g. SQLite "database is locked") |
| 1040 | const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; |
| 1041 | const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; |
| 1042 | const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; |
| 1043 | if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { |
| 1044 | yield* Effect.sleep("1 second"); |
| 1045 | const [m2, a2, s2] = yield* Effect.all( |
| 1046 | [ |
| 1047 | needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), |
| 1048 | needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), |
| 1049 | needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), |
| 1050 | ], |
| 1051 | { concurrency: 1 }, |
| 1052 | ); |
| 1053 | modelsResult = m2; |
| 1054 | agentsResult = a2; |
| 1055 | skillsResult = s2; |
| 1056 | } |
| 1057 | |
| 1058 | if (modelsResult._tag === "Failure") { |
| 1059 | const cause = Cause.squash(modelsResult.cause); |
| 1060 | return yield* ensureRuntimeError( |
| 1061 | "loadInventoryFromCli", |
| 1062 | `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, |
| 1063 | cause, |
| 1064 | ); |
| 1065 | } |
| 1066 | if (modelsResult.value.code !== 0) { |
| 1067 | return yield* new OpenCodeRuntimeError({ |
| 1068 | operation: "loadInventoryFromCli", |
| 1069 | detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, |
| 1070 | }); |
| 1071 | } |
| 1072 | |
| 1073 | const parsed = parseModelsCliOutput(modelsResult.value.stdout); |
| 1074 | const connected = [...parsed.connected]; |
| 1075 | const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( |
| 1076 | (provider) => ({ |
| 1077 | id: provider.id, |
| 1078 | name: provider.name, |
| 1079 | source: "config" as const, |
| 1080 | env: [], |
| 1081 | options: {}, |
| 1082 | models: provider.models, |
| 1083 | }), |
| 1084 | ); |
| 1085 | |
| 1086 | // Agent and skill metadata enrich the provider snapshot but are not required |
| 1087 | // for an authoritative model inventory, so either may degrade to an empty list. |
| 1088 | let agents: ReadonlyArray<Agent> = []; |
| 1089 | if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { |
| 1090 | agents = parseAgentListCliOutput(agentsResult.value.stdout); |
| 1091 | } |
| 1092 | let skills: ReadonlyArray<OpenCodeSkill> = []; |
| 1093 | if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { |
| 1094 | skills = parseSkillsCliOutput(skillsResult.value.stdout); |
| 1095 | } |
| 1096 | |
| 1097 | return { |
| 1098 | providerList: { all: allProviders, default: {}, connected }, |
| 1099 | agents, |
| 1100 | skills, |
| 1101 | }; |
| 1102 | }); |
| 1103 | |
| 1104 | const loadSkillsFromCli: OpenCodeRuntime["Service"]["loadSkillsFromCli"] = (input) => |
| 1105 | runOpenCodeCommand({ |
| 1106 | binaryPath: input.binaryPath, |
| 1107 | args: ["debug", "skill"], |
| 1108 | cwd: input.cwd, |
| 1109 | maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, |
| 1110 | ...(input.environment !== undefined ? { environment: input.environment } : {}), |
| 1111 | }).pipe( |
| 1112 | Effect.flatMap((result) => |
| 1113 | result.code === 0 |
| 1114 | ? Effect.succeed(parseSkillsCliOutput(result.stdout)) |
| 1115 | : Effect.fail( |
| 1116 | new OpenCodeRuntimeError({ |
| 1117 | operation: "loadSkillsFromCli", |
| 1118 | detail: `OpenCode skills command exited with code ${result.code}.`, |
| 1119 | }), |
| 1120 | ), |
| 1121 | ), |
| 1122 | ); |
| 1123 | |
| 1124 | return { |
| 1125 | startOpenCodeServerProcess, |
| 1126 | connectToOpenCodeServer, |
| 1127 | runOpenCodeCommand, |
| 1128 | createOpenCodeSdkClient, |
| 1129 | loadOpenCodeInventory, |
| 1130 | loadOpenCodeSkills, |
| 1131 | loadInventoryFromCli, |
| 1132 | loadSkillsFromCli, |
| 1133 | } satisfies OpenCodeRuntime["Service"]; |
| 1134 | }); |
| 1135 | |
| 1136 | export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( |
| 1137 | Layer.provide(NetService.layer), |
| 1138 | ); |