1/**
2 * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime.
3 *
4 * Mirrors the Codex / Claude drivers: a plain value whose `create()`
5 * bundles `snapshot` / `adapter` / `textGeneration` closures over the
6 * per-instance `OpenCodeSettings`.
7 *
8 * Two instances with different `serverUrl`s therefore talk to independent
9 * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation
10 * shares spin up their own scoped child processes, and those child
11 * processes are released when the registry scope closes.
12 *
13 * @module provider/Drivers/OpenCodeDriver
14 */
15import { ProviderDriverKind } from "@t3tools/contracts";
16import { OpenCodeSettings } from "../settings.ts";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import * as FileSystem from "effect/FileSystem";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import * as Stream from "effect/Stream";
25import * as HttpClient from "effect/http/HttpClient";
26import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
27
28import * as OpenCode2TextGeneration from "./v2/textGeneration.ts";
29import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
30import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
31import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
32import * as OpenCodeAdapterV2 from "./adapter.ts";
33import * as OpenCode2AdapterV2 from "./v2/adapter.ts";
34import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
35import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
36import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
37import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts";
38import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
39import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
40import {
41 checkOpenCodeProviderStatus,
42 loadOpenCode2Workspace,
43 makeOpenCode2ModelLoader,
44 makePendingOpenCodeProvider,
45 openCode2CommandsToServerProviderSlashCommands,
46 openCode2SkillsToServerProviderSkills,
47 openCodeSkillsToServerProviderSkills,
48 openCodeCommandsToServerProviderSlashCommands,
49} from "./status.ts";
50import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
51import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
52import {
53 makeOpenCodeRuntimeProbe,
54 probeOpenCodeRuntime,
55 type ProbedOpenCode,
56} from "./versionProbe.ts";
57import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
58import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
59import * as OpenCode2Server from "./v2/OpenCode2Server.ts";
60import {
61 defaultProviderContinuationIdentity,
62 type ProviderDriver,
63 type ProviderInstance,
64} from "@t3tools/provider-core/server/driver";
65import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
66import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
67import {
68 enrichProviderSnapshotWithVersionAdvisory,
69 makeCachedProviderMaintenanceResolution,
70 makeManualOnlyProviderMaintenanceCapabilities,
71 makePackageManagedProviderMaintenanceResolver,
72 normalizeCommandPath,
73 resolveProviderMaintenanceCapabilitiesEffect,
74} from "@t3tools/provider-core/server/maintenanceResolver";
75import {
76 haveProviderSnapshotSettingsChanged,
77 makeProviderSnapshotSettingsSource,
78 type ProviderSnapshotSettings,
79} from "@t3tools/provider-core/server/snapshotSettings";
80const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
81
82const DRIVER_KIND = ProviderDriverKind.make("opencode");
83
84function isOpenCodeNativeCommandPath(commandPath: string): boolean {
85 const normalized = normalizeCommandPath(commandPath);
86 return (
87 normalized.endsWith("/.opencode/bin/opencode") ||
88 normalized.endsWith("/.opencode/bin/opencode.exe")
89 );
90}
91
92/**
93 * OpenCode 1.x ships as `opencode-ai` and 2.x as `@opencode/cli`. An install is
94 * only ever updated within its own package: T3 never moves a 1.x install onto
95 * 2.x or back, since 2.x converts the shared database in place.
96 */
97export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) =>
98 makePackageManagedProviderMaintenanceResolver({
99 provider: DRIVER_KIND,
100 npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai",
101 nativeUpdate: {
102 args: ["upgrade"],
103 isCommandPath: isOpenCodeNativeCommandPath,
104 },
105 });
106
107type OpenCodeRuntimeProbe = Effect.Success<
108 ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>>
109>;
110
111/** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */
112function byOpenCodeRuntime<A, E, R, PE>(
113 probed: Effect.Effect<ProbedOpenCode | undefined, PE>,
114 paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> },
115): Effect.Effect<A, E, R> {
116 return probed.pipe(
117 Effect.orElseSucceed(() => undefined),
118 Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)),
119 );
120}
121
122/**
123 * Routes each adapter call to the runtime the instance's probe detected. Capability and selection
124 * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a
125 * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol.
126 */
127function selectOpenCodeRuntimeAdapter(input: {
128 readonly probe: OpenCodeRuntimeProbe;
129 readonly v1: ProviderAdapter.ProviderAdapterV2["Service"];
130 readonly v2: ProviderAdapter.ProviderAdapterV2["Service"];
131}): ProviderAdapter.ProviderAdapterV2["Service"] {
132 const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) =>
133 byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) });
134 const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined));
135 return {
136 instanceId: input.v1.instanceId,
137 driver: DRIVER_KIND,
138 getCapabilities: () => Effect.flatMap(hot, (adapter) => adapter.getCapabilities()),
139 planSelectionTransition: (transition) =>
140 Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)),
141 openSession: (session) =>
142 Effect.flatMap(pick(input.probe.get), (adapter) => adapter.openSession(session)),
143 };
144}
145
146/** Text generation runs on the server the instance's probe detected, each in its own protocol. */
147function selectOpenCodeRuntimeTextGeneration(
148 probe: OpenCodeRuntimeProbe,
149 v1: ProviderTextGeneration,
150 v2: ProviderTextGeneration,
151): ProviderTextGeneration {
152 return {
153 generateCommitMessage: (input) =>
154 byOpenCodeRuntime(probe.get, {
155 v1: v1.generateCommitMessage(input),
156 v2: v2.generateCommitMessage(input),
157 }),
158 generatePrContent: (input) =>
159 byOpenCodeRuntime(probe.get, {
160 v1: v1.generatePrContent(input),
161 v2: v2.generatePrContent(input),
162 }),
163 generateBranchName: (input) =>
164 byOpenCodeRuntime(probe.get, {
165 v1: v1.generateBranchName(input),
166 v2: v2.generateBranchName(input),
167 }),
168 generateThreadTitle: (input) =>
169 byOpenCodeRuntime(probe.get, {
170 v1: v1.generateThreadTitle(input),
171 v2: v2.generateThreadTitle(input),
172 }),
173 generateReviewGuide: (input) =>
174 byOpenCodeRuntime(probe.get, {
175 v1: v1.generateReviewGuide(input),
176 v2: v2.generateReviewGuide(input),
177 }),
178 };
179}
180
181export type OpenCodeDriverEnv =
182 | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv
183 | ProviderHost.ProviderHost
184 | ChildProcessSpawner.ChildProcessSpawner
185 | Crypto.Crypto
186 | FileSystem.FileSystem
187 | HttpClient.HttpClient
188 | ProviderLatestVersions.ProviderLatestVersions
189 | OpenCodeRuntime.OpenCodeRuntime
190 | Path.Path;
191
192export const OpenCodeDriver: ProviderDriver<
193 OpenCodeSettings,
194 OpenCodeDriverEnv,
195 OpenCodeUsageReaderEnv
196> = {
197 driverKind: DRIVER_KIND,
198 metadata: {
199 displayName: "OpenCode",
200 supportsMultipleInstances: true,
201 },
202 configSchema: OpenCodeSettings,
203 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
204 usage: openCodeUsageReader,
205 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
206 Effect.gen(function* () {
207 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
208 const fileSystem = yield* FileSystem.FileSystem;
209 const pathService = yield* Path.Path;
210 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
211 const httpClient = yield* HttpClient.HttpClient;
212 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
213 const crypto = yield* Crypto.Crypto;
214 const host = yield* ProviderHost.ProviderHost;
215 const processEnv = yield* mergeProviderInstanceEnvironment(environment);
216 const continuationIdentity = defaultProviderContinuationIdentity({
217 driverKind: DRIVER_KIND,
218 instanceId,
219 });
220 const stampIdentity = withInstanceIdentity({
221 instanceId,
222 driverKind: DRIVER_KIND,
223 displayName,
224 accentColor,
225 continuationGroupKey: continuationIdentity.continuationKey,
226 });
227 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
228 const runtimeProbe = yield* makeOpenCodeRuntimeProbe(
229 probeOpenCodeRuntime(effectiveConfig, processEnv).pipe(
230 Effect.provideService(HttpClient.HttpClient, httpClient),
231 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
232 ),
233 );
234 // Updates follow the installed package, which the version probe names. An
235 // unknown version offers no package update, since a guess could move a
236 // 2.x install onto 1.x's package or the reverse. A disabled instance never
237 // runs its binary, so it has no version and offers no update.
238 const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({
239 provider: DRIVER_KIND,
240 packageName: null,
241 });
242 const maintenanceFor = (probed: typeof runtimeProbe.get) =>
243 probed.pipe(
244 Effect.map((result) => result.generation),
245 Effect.option,
246 Effect.flatMap((generation) =>
247 Option.isNone(generation)
248 ? Effect.succeed(noUpdate)
249 : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), {
250 binaryPath: effectiveConfig.binaryPath,
251 env: processEnv,
252 }),
253 ),
254 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
255 Effect.provideService(FileSystem.FileSystem, fileSystem),
256 Effect.provideService(Path.Path, pathService),
257 );
258 const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution(
259 maintenanceFor(runtimeProbe.get),
260 );
261 // A fresh read (an update about to run, or a manual refresh) re-probes, so
262 // a binary replaced by the other major version gets its own package.
263 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
264 !effectiveConfig.enabled
265 ? Effect.succeed(noUpdate)
266 : options?.fresh === true
267 ? runtimeProbe.refresh.pipe(
268 Effect.ignore,
269 Effect.andThen(cachedMaintenance({ fresh: true })),
270 )
271 : cachedMaintenance();
272 const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({
273 instanceId,
274 displayName,
275 accentColor,
276 environment,
277 enabled,
278 config,
279 }).pipe(
280 Effect.mapError(
281 (cause) =>
282 new ProviderDriverError({
283 driver: DRIVER_KIND,
284 instanceId,
285 detail: "Failed to build OpenCode orchestration adapter.",
286 cause,
287 }),
288 ),
289 );
290 // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`.
291 const openCode2Server = yield* OpenCode2Server.make({
292 binaryPath: effectiveConfig.binaryPath,
293 serverUrl: effectiveConfig.serverUrl,
294 serverPassword: effectiveConfig.serverPassword,
295 directory: host.paths.cwd,
296 environment: processEnv,
297 }).pipe(
298 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
299 Effect.provideService(
300 OpenCode2Client.OpenCode2Client,
301 yield* OpenCode2Client.make.pipe(
302 Effect.provideService(HttpClient.HttpClient, httpClient),
303 ),
304 ),
305 );
306 const orchestrationAdapter = selectOpenCodeRuntimeAdapter({
307 probe: runtimeProbe,
308 v1: openCodeV1Adapter,
309 v2: yield* OpenCode2AdapterV2.make(instanceId).pipe(
310 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
311 ),
312 });
313 const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
314 openCode2Server.withConnection(({ client }) =>
315 loadOpenCode2Catalog(client, host.paths.cwd),
316 ),
317 );
318 // A 2.x server lists skills and commands per directory, so one server
319 // answers every workspace. Its event stream says when a directory it had
320 // not served yet finished scanning.
321 const listOpenCode2Workspace = (cwd: string) =>
322 openCode2Server.withConnection(({ client, events }) =>
323 Effect.gen(function* () {
324 const location = { directory: cwd };
325 const scanned = yield* Deferred.make<void>();
326 const pending = new Set(["command.updated", "skill.updated"]);
327 const stream = yield* events.pipe(Effect.option);
328 if (stream._tag === "Some") {
329 yield* stream.value.pipe(
330 Stream.runForEach((event) =>
331 "location" in event &&
332 event.location?.directory === cwd &&
333 pending.delete(event.type) &&
334 pending.size === 0
335 ? Deferred.succeed(scanned, undefined)
336 : Effect.void,
337 ),
338 Effect.ignore,
339 Effect.forkScoped,
340 );
341 }
342 return yield* loadOpenCode2Workspace(
343 Effect.all(
344 {
345 skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)),
346 commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)),
347 },
348 { concurrency: "unbounded" },
349 ),
350 Deferred.await(scanned),
351 );
352 }).pipe(Effect.scoped),
353 );
354 const serverOwner = yield* OpenCodeServerOwner.make({
355 binaryPath: effectiveConfig.binaryPath,
356 directory: host.paths.cwd,
357 ...(effectiveConfig.serverPassword
358 ? { serverPassword: effectiveConfig.serverPassword }
359 : {}),
360 environment: processEnv,
361 });
362 const textGeneration = selectOpenCodeRuntimeTextGeneration(
363 runtimeProbe,
364 yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
365 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
366 ),
367 yield* OpenCode2TextGeneration.make().pipe(
368 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
369 ),
370 );
371
372 const checkProvider = Effect.all(
373 {
374 provider: checkOpenCodeProviderStatus(
375 effectiveConfig,
376 host.paths.cwd,
377 runtimeProbe.refresh,
378 loadOpenCode2Models,
379 ),
380 usageLimits: readOpenCodeGoUsageLimits({
381 enabled: effectiveConfig.enabled,
382 serverUrl: effectiveConfig.serverUrl,
383 environment: processEnv,
384 }),
385 },
386 { concurrency: "unbounded" },
387 ).pipe(
388 Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })),
389 Effect.map(stampIdentity),
390 Effect.provideService(FileSystem.FileSystem, fileSystem),
391 Effect.provideService(Path.Path, pathService),
392 Effect.provideService(HttpClient.HttpClient, httpClient),
393 Effect.provideService(Crypto.Crypto, crypto),
394 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
395 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
396 );
397 // NOTE: the local branch intentionally uses the shared SDK server
398 // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes
399 // its full JSON inventory to stdout, but the Bun-compiled binary does
400 // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the
401 // piped output arrives truncated and unparseable — which degrades to an
402 // empty skill list and poisons the workspace snapshot the `$` picker
403 // reads. The SDK `app.skills` endpoint honors the per-request directory
404 // and returns complete results regardless of size.
405 const loadWorkspaceInventory = (
406 client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0],
407 ) =>
408 Effect.all(
409 {
410 skills: openCodeRuntime.loadOpenCodeSkills(client),
411 commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
412 Effect.timeout("10 seconds"),
413 Effect.orElseSucceed(() => []),
414 ),
415 },
416 { concurrency: "unbounded" },
417 );
418 const loadWorkspaceForCwd = (cwd: string) =>
419 effectiveConfig.serverUrl.trim().length > 0
420 ? Effect.scoped(
421 Effect.gen(function* () {
422 const server = yield* openCodeRuntime.connectToOpenCodeServer({
423 binaryPath: effectiveConfig.binaryPath,
424 directory: cwd,
425 serverUrl: effectiveConfig.serverUrl,
426 ...(effectiveConfig.serverPassword
427 ? { serverPassword: effectiveConfig.serverPassword }
428 : {}),
429 environment: processEnv,
430 });
431 const client = openCodeRuntime.createOpenCodeSdkClient({
432 baseUrl: server.url,
433 directory: cwd,
434 ...(effectiveConfig.serverPassword
435 ? { serverPassword: effectiveConfig.serverPassword }
436 : {}),
437 });
438 return yield* loadWorkspaceInventory(client);
439 }),
440 )
441 : serverOwner.withServer((server) =>
442 loadWorkspaceInventory(
443 openCodeRuntime.createOpenCodeSdkClient({
444 baseUrl: server.url,
445 directory: cwd,
446 ...(server.serverPassword !== undefined
447 ? { serverPassword: server.serverPassword }
448 : {}),
449 }),
450 ),
451 );
452
453 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
454 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
455 {
456 resolveMaintenance,
457 getSettings: snapshotSettings.getSettings,
458 streamSettings: snapshotSettings.streamSettings,
459 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
460 checkProviderOnSettingsChange: () => false,
461 refreshOnInterval: false,
462 initialSnapshot: (settings) =>
463 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
464 checkProvider,
465 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
466 resolveMaintenance().pipe(
467 Effect.flatMap((maintenanceCapabilities) =>
468 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
469 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
470 }),
471 ),
472 Effect.provideService(HttpClient.HttpClient, httpClient),
473 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
474 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
475 ),
476 },
477 ).pipe(
478 Effect.mapError(
479 (cause) =>
480 new ProviderDriverError({
481 driver: DRIVER_KIND,
482 instanceId,
483 detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`,
484 cause,
485 }),
486 ),
487 );
488
489 return {
490 instanceId,
491 driverKind: DRIVER_KIND,
492 continuationIdentity,
493 displayName,
494 accentColor,
495 enabled,
496 snapshot,
497 snapshotForCwd: (cwd) =>
498 !effectiveConfig.enabled
499 ? snapshot.getSnapshot
500 : byOpenCodeRuntime(runtimeProbe.get, {
501 v2: Effect.all([
502 snapshot.getSnapshot,
503 listOpenCode2Workspace(cwd).pipe(
504 Effect.timeout("20 seconds"),
505 Effect.mapError(
506 (cause) =>
507 new ProviderDriverError({
508 driver: DRIVER_KIND,
509 instanceId,
510 detail: `Failed to list OpenCode commands and skills for '${cwd}'`,
511 cause,
512 }),
513 ),
514 ),
515 ]).pipe(
516 Effect.map(([machineSnapshot, { skills, commands }]) => ({
517 ...machineSnapshot,
518 skills: openCode2SkillsToServerProviderSkills(skills),
519 slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
520 })),
521 ),
522 v1: Effect.all([
523 snapshot.getSnapshot,
524 loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")),
525 ]).pipe(
526 Effect.map(([machineSnapshot, { skills, commands }]) => ({
527 ...machineSnapshot,
528 skills: openCodeSkillsToServerProviderSkills(skills),
529 slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
530 })),
531 Effect.mapError(
532 (cause) =>
533 new ProviderDriverError({
534 driver: DRIVER_KIND,
535 instanceId,
536 detail: `Failed to probe OpenCode commands and skills for '${cwd}'`,
537 cause,
538 }),
539 ),
540 ),
541 }),
542 orchestrationAdapter,
543 textGeneration,
544 } satisfies ProviderInstance;
545 }),
546};