1import * as NodeURL from "node:url";
2
3import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
4import {
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";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as Data from "effect/Data";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";
22import * as Fiber from "effect/Fiber";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as P from "effect/Predicate";
26import * as Ref from "effect/Ref";
27import * as Result from "effect/Result";
28import * as Scope from "effect/Scope";
29import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import * as ChildProcess from "effect/process/ChildProcess";
32import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
33
34import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
35import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe";
36import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
37import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe";
38import * as NetService from "@t3tools/shared/Net";
39import { AgentScope } from "@t3tools/shared/AgentScope";
40import * as HostProcess from "@t3tools/shared/HostProcess";
41import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
42import { resolveSpawnCommand } from "@t3tools/shared/shell";
43const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
44const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
45
46export const MINIMUM_OPENCODE_VERSION = "1.14.19";
47const OPENCODE_HEALTH_TIMEOUT = "5 seconds";
48
49const OpenCodeHealthSchema = Schema.Struct({
50 healthy: Schema.Literal(true),
51 version: Schema.String,
52});
53const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema);
54
55export 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
66export 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
85const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
86const DEFAULT_HOSTNAME = "127.0.0.1";
87const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
88const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
89export 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
97export 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
105const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
106export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
107 readonly operation: string;
108 readonly cause?: unknown;
109 readonly detail: string;
110}> {}
111
112export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError =>
113 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
114
115function 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 */
124export 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
148export 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
158export 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
196export interface OpenCodeCommandResult {
197 readonly stdout: string;
198 readonly stderr: string;
199 readonly code: number;
200}
201
202export interface OpenCodeInventory {
203 readonly providerList: ProviderListResponse;
204 readonly agents: ReadonlyArray<Agent>;
205 readonly skills: ReadonlyArray<OpenCodeSkill>;
206 readonly commands?: ReadonlyArray<OpenCodeSlashCommand>;
207}
208
209export type OpenCodeSlashCommand = Pick<Command, "name" | "description" | "source" | "hints">;
210
211/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */
212export 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
224export interface ParsedOpenCodeModelSlug {
225 readonly providerID: string;
226 readonly modelID: string;
227}
228
229export interface OpenCodeSkill {
230 readonly name?: string | null;
231 readonly description?: string | null;
232 readonly location?: string | null;
233}
234
235const 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});
240const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(
241 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),
242);
243
244export 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
310function 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
318const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;
319const 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).
324const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]);
325
326/** @internal */
327export 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 */
389export 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 */
432export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
433 const result = decodeOpenCodeSkillsCliOutputExit(stdout);
434 return Exit.isSuccess(result) ? result.value : [];
435}
436
437export 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
456export 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 */
476const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
477const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024;
478
479function 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
494export 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
527export 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
565export 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
581export 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
600function 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
610const 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
1136export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(
1137 Layer.provide(NetService.layer),
1138);