Skip to content
Source
Sign inSign up

driver.ts

786 lines32.0 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 ProviderDriverKind,
4 settingsSecretLiteral,
5 type ServerProviderModel,
6} from "@t3tools/contracts";
7import { OpenCodeSettings } from "../settings.ts";
8import * as Digest from "effect/crypto/Digest";
9import * as SecureRandom from "effect/crypto/SecureRandom";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as DateTime from "effect/DateTime";
13import * as Equal from "effect/Equal";
14import * as Option from "effect/Option";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as HttpClient from "effect/http/HttpClient";
20import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
21​
22import * as OpenCode2TextGeneration from "./v2/textGeneration.ts";
23import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
24import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
26import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
27import { AgentScope } from "@t3tools/shared/AgentScope";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import { compareSemverVersions } from "@t3tools/shared/semver";
30import * as OpenCodeAdapterV2 from "./adapter.ts";
31import * as OpenCode2AdapterV2 from "./v2/adapter.ts";
32import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
33import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
34import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
35import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts";
36import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
37import { loadOpenCode2Catalog, loadOpenCode2DirectoryCatalog } from "./openCode2Catalog.ts";
38import {
39 checkOpenCodeProviderStatus,
40 loadOpenCode2Workspace,
41 makeOpenCode2ModelLoader,
42 makePendingOpenCodeProvider,
43 openCode2CommandsToServerProviderSlashCommands,
44 openCode2ProviderModels,
45 openCode2SkillsToServerProviderSkills,
46 openCodeSkillsToServerProviderSkills,
47 openCodeCommandsToServerProviderSlashCommands,
48 openCodeProviderModels,
49 readSettledOpenCode2Catalog,
50} from "./status.ts";
51import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
52import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
53import {
54 makeOpenCodeRuntimeProbe,
55 probeOpenCodeRuntime,
56 probeOpenCodeBinary,
57 type ProbedOpenCode,
58} from "./versionProbe.ts";
59import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
60import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
61import * as OpenCode2Server from "./v2/OpenCode2Server.ts";
62import { replaceOutdatedService } from "./v2/OpenCode2Service.ts";
63import {
64 defaultProviderContinuationIdentity,
65 type ProviderDriver,
66 type ProviderInstance,
67} from "@t3tools/provider-core/server/driver";
68import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
69import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
70import { providerProbeEnvironment } from "@t3tools/provider-core/server/snapshotProbe";
71import {
72 enrichProviderSnapshotWithVersionAdvisory,
73 makeCachedProviderMaintenanceResolution,
74 makeManualOnlyProviderMaintenanceCapabilities,
75 makePackageManagedProviderMaintenanceResolver,
76 normalizeCommandPath,
77 resolveProviderMaintenanceCapabilitiesEffect,
78} from "@t3tools/provider-core/server/maintenanceResolver";
79import {
80 haveProviderSnapshotSettingsChanged,
81 makeProviderSnapshotSettingsSource,
82 type ProviderSnapshotSettings,
83} from "@t3tools/provider-core/server/snapshotSettings";
84const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
85​
86const DRIVER_KIND = ProviderDriverKind.make("opencode");
87​
88// A loopback server runs on this environment, but can outlive its CLI installation.
89// Its reported version may therefore differ from the installed executable after an update.
90export function isLocalOpenCodeServerUrl(serverUrl: string): boolean {
91 const url = URL.parse(serverUrl.trim());
92 if (url?.protocol !== "http:" && url?.protocol !== "https:") return false;
93 return (
94 url.hostname === "localhost" ||
95 url.hostname === "[::1]" ||
96 /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname)
97 );
99​
100function isOpenCodeNativeCommandPath(commandPath: string): boolean {
101 const normalized = normalizeCommandPath(commandPath);
102 return (
103 normalized.endsWith("/.opencode/bin/opencode") ||
104 normalized.endsWith("/.opencode/bin/opencode.exe")
105 );
107​
108function openCodeNativeHome(commandPath: string): string | null {
109 const slashed = commandPath.replaceAll("\\", "/");
110 const match = /\/\.opencode\/bin\/opencode(?:\.exe)?$/i.exec(slashed);
111 if (!match) return null;
112 return match.index === 0 ? "/" : slashed.slice(0, match.index);
114​
115/** Update only within the installed package generation; 2.x converts the shared database in place. */
116export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) =>
117 makePackageManagedProviderMaintenanceResolver({
118 provider: DRIVER_KIND,
119 npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai",
120 nativeUpdate: {
121 // `isOpenCodeNativeCommandPath` is the curl installer's own binary. Name the
122 // method so OpenCode does not have to infer it from `process.execPath`, which
123 // fails when the binary is launched through a wrapper or shim.
124 args: ["upgrade", "--method", "curl"],
125 isCommandPath: isOpenCodeNativeCommandPath,
126 requiresCommandPath: true,
127 // The curl installer writes to `$HOME/.opencode/bin`, so point HOME at the install it replaces.
128 env: (context) => {
129 const home =
130 openCodeNativeHome(context.realCommandPath) ??
131 openCodeNativeHome(context.resolvedCommandPath);
132 return home ? { HOME: home } : {};
133 },
134 },
135 // mise's `opencode` follows releases into 2.x, so it would upgrade 1.x across that line.
136 mise: { tool: "opencode", upgrade: generation === "v2" },
137 });
138​
139type OpenCodeRuntimeProbe = Effect.Success<
140 ReturnType<typeof makeOpenCodeRuntimeProbe<OpenCodeRuntime.OpenCodeRuntimeError>>
141>;
142​
143function byOpenCodeRuntime<A, E, R, PE>(
144 probed: Effect.Effect<ProbedOpenCode | undefined, PE>,
145 paths: { readonly v1: Effect.Effect<A, E, R>; readonly v2: Effect.Effect<A, E, R> },
146): Effect.Effect<A, E, R> {
147 return probed.pipe(
148 Effect.orElseSucceed(() => undefined),
149 Effect.flatMap((result) => (result?.generation === "v2" ? paths.v2 : paths.v1)),
150 );
152​
153// An unread catalog keeps what the registry found before rather than the machine catalog.
154function workspaceModelsFor(
155 machineModels: ReadonlyArray<ServerProviderModel>,
156 cwdModels: Option.Option<ReadonlyArray<ServerProviderModel>>,
157) {
158 if (Option.isNone(cwdModels)) return { workspaceModelsPending: true };
159 return Equal.equals(cwdModels.value, machineModels)
160 ? {}
161 : { models: cwdModels.value, workspaceModels: cwdModels.value };
163​
164// Hot reads use the last successful probe; session opens await it to select the correct protocol.
165function selectOpenCodeRuntimeAdapter(input: {
166 readonly probe: OpenCodeRuntimeProbe;
167 readonly resolve: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>;
168 readonly v1: ProviderAdapter.ProviderAdapterV2["Service"];
169 readonly v2: ProviderAdapter.ProviderAdapterV2["Service"];
170}): ProviderAdapter.ProviderAdapterV2["Service"] {
171 const pick = <PE>(probed: Effect.Effect<ProbedOpenCode | undefined, PE>) =>
172 byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) });
173 const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined));
174 return {
175 instanceId: input.v1.instanceId,
176 driver: DRIVER_KIND,
177 getCapabilities: Effect.fn("OpenCodeDriver.getCapabilities")(function* () {
178 const probed = Option.getOrUndefined(yield* input.probe.lastSuccess);
179 const adapter = yield* pick(Effect.succeed(probed));
180 const capabilities = yield* adapter.getCapabilities();
181 return {
182 ...capabilities,
183 turns: {
184 ...capabilities.turns,
185 supportsNativeGoals:
186 capabilities.turns.supportsNativeGoals ??
187 (probed?.generation === "v2" && compareSemverVersions(probed.version, "2.2.5") >= 0),
188 },
189 };
190 }),
191 planSelectionTransition: (transition) =>
192 Effect.flatMap(hot, (adapter) => adapter.planSelectionTransition(transition)),
193 openSession: (session) =>
194 Effect.flatMap(pick(input.resolve), (adapter) => adapter.openSession(session)),
195 };
197​
198function selectOpenCodeRuntimeTextGeneration(
199 resolve: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
200 v1: ProviderTextGeneration,
201 v2: ProviderTextGeneration,
202): ProviderTextGeneration {
203 return {
204 generateCommitMessage: (input) =>
205 byOpenCodeRuntime(resolve, {
206 v1: v1.generateCommitMessage(input),
207 v2: v2.generateCommitMessage(input),
208 }),
209 generatePrContent: (input) =>
210 byOpenCodeRuntime(resolve, {
211 v1: v1.generatePrContent(input),
212 v2: v2.generatePrContent(input),
213 }),
214 generateBranchName: (input) =>
215 byOpenCodeRuntime(resolve, {
216 v1: v1.generateBranchName(input),
217 v2: v2.generateBranchName(input),
218 }),
219 generateThreadTitle: (input) =>
220 byOpenCodeRuntime(resolve, {
221 v1: v1.generateThreadTitle(input),
222 v2: v2.generateThreadTitle(input),
223 }),
224 generateReviewGuide: (input) =>
225 byOpenCodeRuntime(resolve, {
226 v1: v1.generateReviewGuide(input),
227 v2: v2.generateReviewGuide(input),
228 }),
229 generateTranscriptionPostProcessing: (input) =>
230 byOpenCodeRuntime(resolve, {
231 v1: v1.generateTranscriptionPostProcessing(input),
232 v2: v2.generateTranscriptionPostProcessing(input),
233 }),
234 };
236​
237export type OpenCodeDriverEnv =
238 | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv
239 | ProviderHost.ProviderHost
240 | ChildProcessSpawner.ChildProcessSpawner
241 | Digest.Digest
242 | SecureRandom.SecureRandom
243 | FileSystem.FileSystem
244 | HttpClient.HttpClient
245 | ProviderLatestVersions.ProviderLatestVersions
246 | OpenCodeRuntime.OpenCodeRuntime
247 | Path.Path;
248​
249export const OpenCodeDriver: ProviderDriver<
250 OpenCodeSettings,
251 OpenCodeDriverEnv,
252 OpenCodeUsageReaderEnv
253> = {
254 driverKind: DRIVER_KIND,
255 metadata: {
256 displayName: "OpenCode",
257 supportsMultipleInstances: true,
258 },
259 configSchema: OpenCodeSettings,
260 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
261 usage: openCodeUsageReader,
262 create: Effect.fn("OpencodeDriver.create")(function* ({
263 instanceId,
264 displayName,
265 accentColor,
266 environment,
267 enabled,
268 config,
269 }) {
270 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
271 const fileSystem = yield* FileSystem.FileSystem;
272 const pathService = yield* Path.Path;
273 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
274 const agentScope = yield* AgentScope;
275 const httpClient = yield* HttpClient.HttpClient;
276 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
277 const digest = yield* Digest.Digest;
278 const host = yield* ProviderHost.ProviderHost;
279 const processEnv = yield* mergeProviderInstanceEnvironment(environment);
280 const continuationIdentity = defaultProviderContinuationIdentity({
281 driverKind: DRIVER_KIND,
282 instanceId,
283 });
284 const stampIdentity = withInstanceIdentity({
285 instanceId,
286 driverKind: DRIVER_KIND,
287 displayName,
288 accentColor,
289 continuationGroupKey: continuationIdentity.continuationKey,
290 });
291 const effectiveConfig = {
292 ...config,
293 enabled,
294 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
295 // The instance registry has already read a referenced password from its secret provider.
296 serverPassword: settingsSecretLiteral(config.serverPassword),
297 } satisfies OpenCodeSettings;
298 const probeRuntime = (probeEnv: NodeJS.ProcessEnv) =>
299 probeOpenCodeRuntime(effectiveConfig, probeEnv).pipe(
300 Effect.provideService(HttpClient.HttpClient, httpClient),
301 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
302 );
303 const runtimeProbe = yield* makeOpenCodeRuntimeProbe(
304 probeRuntime(providerProbeEnvironment(processEnv)),
305 );
306 // A mise shim cannot install OpenCode offline; a session or text generation the user started
307 // may, so without a cached result it probes once online and caches what mise installed.
308 const resolveRuntime =
309 effectiveConfig.serverUrl.trim().length > 0
310 ? runtimeProbe.get
311 : runtimeProbe.lastSuccess.pipe(
312 Effect.flatMap(
313 Option.match({
314 onSome: Effect.succeed,
315 onNone: () => probeRuntime(processEnv).pipe(Effect.tap(runtimeProbe.record)),
316 }),
317 ),
318 );
319 const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({
320 provider: DRIVER_KIND,
321 packageName: null,
322 });
323 const maintenanceFor = (probed: typeof runtimeProbe.get) =>
324 probed.pipe(
325 Effect.map((result) => result.generation),
326 Effect.option,
327 Effect.flatMap((generation) =>
328 Option.isNone(generation)
329 ? Effect.succeed(noUpdate)
330 : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), {
331 binaryPath: effectiveConfig.binaryPath,
332 env: processEnv,
333 }),
334 ),
335 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
336 Effect.provideService(FileSystem.FileSystem, fileSystem),
337 Effect.provideService(Path.Path, pathService),
338 );
339 const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution(
340 maintenanceFor(
341 isLocalOpenCodeServerUrl(effectiveConfig.serverUrl)
342 ? probeOpenCodeBinary(
343 effectiveConfig.binaryPath,
344 providerProbeEnvironment(processEnv),
345 ).pipe(
346 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
347 Effect.catch(() => runtimeProbe.get),
348 )
349 : runtimeProbe.get,
350 ),
351 );
352 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
353 !effectiveConfig.enabled
354 ? Effect.succeed(noUpdate)
355 : options?.fresh === true
356 ? runtimeProbe.refresh.pipe(
357 Effect.ignore,
358 Effect.andThen(cachedMaintenance({ fresh: true })),
359 )
360 : cachedMaintenance();
361 const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({
362 instanceId,
363 displayName,
364 accentColor,
365 environment,
366 enabled,
367 config: effectiveConfig,
368 }).pipe(
369 Effect.mapError(
370 (cause) =>
371 new ProviderDriverError({
372 driver: DRIVER_KIND,
373 instanceId,
374 detail: "Failed to build OpenCode orchestration adapter.",
375 cause,
376 }),
377 ),
378 );
379 const openCode2Server = yield* OpenCode2Server.make({
380 binaryPath: effectiveConfig.binaryPath,
381 serverUrl: effectiveConfig.serverUrl,
382 serverPassword: effectiveConfig.serverPassword,
383 directory: host.paths.cwd,
384 environment: processEnv,
385 }).pipe(
386 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
387 Effect.provideService(
388 OpenCode2Client.OpenCode2Client,
389 yield* OpenCode2Client.make.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
390 ),
391 );
392 const orchestrationAdapter = selectOpenCodeRuntimeAdapter({
393 probe: runtimeProbe,
394 resolve: resolveRuntime,
395 v1: openCodeV1Adapter,
396 v2: yield* OpenCode2AdapterV2.make(instanceId).pipe(
397 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
398 ),
399 });
400 const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
401 openCode2Server.withConnection(({ client }) => loadOpenCode2Catalog(client, host.paths.cwd)),
402 processEnv,
403 );
404 const listOpenCode2Workspace = (cwd: string) =>
405 openCode2Server.withConnection(({ client, events }) =>
406 Effect.gen(function* () {
407 // OpenCode names a location by its resolved path (no trailing slash or `..`).
408 const directory = pathService.resolve(cwd);
409 const location = { directory };
410 const scanned = yield* Deferred.make<void>();
411 const pending = new Set(["command.updated", "skill.updated"]);
412 const stream = yield* events.pipe(Effect.option);
413 if (stream._tag === "Some") {
414 yield* stream.value.pipe(
415 Stream.runForEach((event) =>
416 "location" in event &&
417 event.location?.directory === directory &&
418 pending.delete(event.type) &&
419 pending.size === 0
420 ? Deferred.succeed(scanned, undefined)
421 : Effect.void,
422 ),
423 Effect.ignore,
424 Effect.forkScoped,
425 );
426 }
427 return yield* loadOpenCode2Workspace(
428 Effect.all(
429 {
430 skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)),
431 commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)),
432 },
433 { concurrency: "unbounded" },
434 ),
435 Deferred.await(scanned),
436 // Without events no scan can be awaited, so trust the first read as before.
437 stream._tag === "None"
438 ? Effect.succeed(true)
439 : client.debug.location.list().pipe(
440 Effect.map((loaded) => loaded.some((entry) => entry.directory === directory)),
441 Effect.timeout("2 seconds"),
442 Effect.orElseSucceed(() => true),
443 ),
444 // OpenCode answers provider.list for a location only once its skill scan has ended.
445 client.provider.list({ location }).pipe(Effect.timeout("10 seconds"), Effect.ignore),
446 processEnv,
447 );
448 }).pipe(Effect.scoped),
449 );
450 const serverOwner = yield* OpenCodeServerOwner.make({
451 binaryPath: effectiveConfig.binaryPath,
452 directory: host.paths.cwd,
453 ...(effectiveConfig.serverPassword ? { serverPassword: effectiveConfig.serverPassword } : {}),
454 environment: processEnv,
455 });
456 const textGeneration = selectOpenCodeRuntimeTextGeneration(
457 resolveRuntime,
458 yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
459 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
460 ),
461 yield* OpenCode2TextGeneration.make().pipe(
462 Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
463 ),
464 );
465​
466 const legacyUsageLimits = readOpenCodeGoUsageLimits({
467 enabled: effectiveConfig.enabled,
468 serverUrl: effectiveConfig.serverUrl,
469 environment: processEnv,
470 }).pipe(
471 Effect.provideService(FileSystem.FileSystem, fileSystem),
472 Effect.provideService(Path.Path, pathService),
473 Effect.provideService(HttpClient.HttpClient, httpClient),
474 Effect.provideService(Digest.Digest, digest),
475 );
476 let unsupportedUsageVersion: string | undefined;
477 const checkUsageLimits = Effect.gen(function* () {
478 if (!effectiveConfig.enabled) return yield* legacyUsageLimits;
479 const runtime = yield* runtimeProbe.get;
480 if (runtime.generation === "v1" || runtime.version === unsupportedUsageVersion)
481 return yield* legacyUsageLimits;
482 const limits = yield* openCode2Server.withConnection((connection) =>
483 connection.usageLimits(host.paths.cwd),
484 );
485 if (limits === undefined) unsupportedUsageVersion = runtime.version;
486 return limits ?? (yield* legacyUsageLimits);
487 }).pipe(
488 Effect.timeout("10 seconds"),
489 Effect.catch(() =>
490 Effect.map(DateTime.now, (now) => ({
491 checkedAt: DateTime.formatIso(now),
492 windows: [],
493 unavailable: {
494 reason: "probeFailed" as const,
495 message: "OpenCode could not read workspace usage.",
496 },
497 })),
498 ),
499 );
500 const checkProvider = Effect.all(
501 {
502 // A configured loopback server reports its running version. Probe the local
503 // CLI separately so a completed npm update is not mistaken for a failure
504 // while that independently managed server still runs the previous binary.
505 installedVersion:
506 effectiveConfig.enabled && isLocalOpenCodeServerUrl(effectiveConfig.serverUrl)
507 ? probeOpenCodeBinary(
508 effectiveConfig.binaryPath,
509 providerProbeEnvironment(processEnv),
510 ).pipe(
511 Effect.map((probed) => probed.version),
512 Effect.orElseSucceed(() => undefined),
513 )
514 : Effect.void,
515 provider: checkOpenCodeProviderStatus(
516 effectiveConfig,
517 host.paths.cwd,
518 runtimeProbe.refresh,
519 loadOpenCode2Models,
520 processEnv,
521 ).pipe(
522 Effect.annotateLogs({ providerInstanceId: instanceId }),
523 Effect.annotateSpans({ providerInstanceId: instanceId }),
524 ),
525 usageLimits: checkUsageLimits,
526 },
527 { concurrency: "unbounded" },
528 ).pipe(
529 Effect.map(({ provider, usageLimits, installedVersion }) => ({
530 ...provider,
531 usageLimits,
532 ...(installedVersion === undefined ? {} : { installedVersion }),
533 })),
534 Effect.map(stampIdentity),
535 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
536 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
537 );
538 // The CLI writes its full JSON inventory to stdout, but the Bun-compiled binary does not
539 // flush more than one 64KB pipe buffer to a non-TTY stdout, so the piped output arrives
540 // truncated and unparseable — which degrades to an empty skill list and poisons the workspace
541 // snapshot the `$` picker reads.
542 const loadWorkspaceInventory = (
543 client: Parameters<typeof OpenCodeRuntime.loadOpenCodeCommands>[0],
544 ) =>
545 Effect.all(
546 {
547 skills: openCodeRuntime.loadOpenCodeSkills(client),
548 commandInventory: OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
549 Effect.timeoutOption(resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv)),
550 Effect.map((commands) => ({
551 commands: Option.getOrElse(commands, () => []),
552 slashCommandsPending: Option.isNone(commands),
553 })),
554 // SDK errors leave command discovery to the provider's health.
555 Effect.orElseSucceed(() => ({
556 commands: [],
557 slashCommandsPending: undefined,
558 })),
559 ),
560 // A project's opencode.jsonc can define its own providers.
561 models: OpenCodeRuntime.loadOpenCodeProviderCatalog(client).pipe(
562 Effect.timeoutOption("10 seconds"),
563 Effect.map(
564 Option.flatMapNullishOr((catalog) =>
565 openCodeProviderModels(effectiveConfig, catalog),
566 ),
567 ),
568 Effect.orElseSucceed(() => Option.none<ReadonlyArray<ServerProviderModel>>()),
569 ),
570 },
571 { concurrency: "unbounded" },
572 ).pipe(
573 Effect.map(({ skills, commandInventory, models }) => ({
574 skills,
575 models,
576 ...commandInventory,
577 })),
578 );
579 const loadWorkspaceForCwd = (cwd: string) =>
580 effectiveConfig.serverUrl.trim().length > 0
581 ? Effect.scoped(
582 Effect.gen(function* () {
583 const server = yield* openCodeRuntime.connectToOpenCodeServer({
584 binaryPath: effectiveConfig.binaryPath,
585 directory: cwd,
586 serverUrl: effectiveConfig.serverUrl,
587 ...(effectiveConfig.serverPassword
588 ? { serverPassword: effectiveConfig.serverPassword }
589 : {}),
590 environment: processEnv,
591 });
592 const client = openCodeRuntime.createOpenCodeSdkClient({
593 baseUrl: server.url,
594 directory: cwd,
595 ...(effectiveConfig.serverPassword
596 ? { serverPassword: effectiveConfig.serverPassword }
597 : {}),
598 });
599 return yield* loadWorkspaceInventory(client);
600 }),
601 )
602 : serverOwner.withServer((server) =>
603 loadWorkspaceInventory(
604 openCodeRuntime.createOpenCodeSdkClient({
605 baseUrl: server.url,
606 directory: cwd,
607 ...(server.serverPassword !== undefined
608 ? { serverPassword: server.serverPassword }
609 : {}),
610 }),
611 ),
612 );
613​
614 const serverUrl = effectiveConfig.serverUrl.trim();
615 const afterUpdate =
616 serverUrl.length > 0
617 ? replaceOutdatedService({
618 binaryPath: effectiveConfig.binaryPath,
619 serverUrl,
620 environment: processEnv,
621 }).pipe(
622 Effect.ensuring(openCode2Server.forget),
623 Effect.mapError(
624 (cause) =>
625 new ProviderDriverError({
626 driver: DRIVER_KIND,
627 instanceId,
628 detail: "Failed to replace the OpenCode service after an update.",
629 cause,
630 }),
631 ),
632 Effect.provideService(AgentScope, agentScope),
633 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
634 Effect.provideService(FileSystem.FileSystem, fileSystem),
635 Effect.provideService(Path.Path, pathService),
636 )
637 : Effect.void;
638​
639 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
640 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>({
641 resolveMaintenance,
642 getSettings: snapshotSettings.getSettings,
643 streamSettings: snapshotSettings.streamSettings,
644 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
645 checkProviderOnSettingsChange: () => false,
646 // A failed or still-loading catalog must recover without a manual
647 // refresh. Healthy instances need no recurring server starts.
648 refreshOnInterval: (snapshot) =>
649 snapshot.enabled &&
650 snapshot.installed &&
651 snapshot.status !== "ready" &&
652 snapshot.auth.status === "unknown",
653 initialSnapshot: (settings) =>
654 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
655 checkProvider,
656 checkUsageLimits,
657 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
658 resolveMaintenance().pipe(
659 Effect.flatMap((maintenanceCapabilities) =>
660 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
661 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
662 }),
663 ),
664 Effect.provideService(HttpClient.HttpClient, httpClient),
665 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
666 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
667 ),
668 }).pipe(
669 Effect.mapError(
670 (cause) =>
671 new ProviderDriverError({
672 driver: DRIVER_KIND,
673 instanceId,
674 detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`,
675 cause,
676 }),
677 ),
678 );
679​
680 return {
681 instanceId,
682 driverKind: DRIVER_KIND,
683 continuationIdentity,
684 displayName,
685 accentColor,
686 enabled,
687 snapshot,
688 // A scan never installs OpenCode: without an offline probe result it fails and keeps the
689 // last inventory rather than guessing 1.x.
690 snapshotForCwd: (cwd) =>
691 !effectiveConfig.enabled
692 ? snapshot.getSnapshot
693 : runtimeProbe.get.pipe(
694 Effect.mapError(
695 (cause) =>
696 new ProviderDriverError({
697 driver: DRIVER_KIND,
698 instanceId,
699 detail: `Failed to probe OpenCode for '${cwd}'`,
700 cause,
701 }),
702 ),
703 Effect.flatMap((probed) =>
704 byOpenCodeRuntime(Effect.succeed(probed), {
705 v2: Effect.all(
706 [
707 snapshot.getSnapshot,
708 // A project's opencode.jsonc can define its own providers.
709 readSettledOpenCode2Catalog(
710 openCode2Server.withConnection(({ client }) =>
711 loadOpenCode2DirectoryCatalog(client, cwd),
712 ),
713 processEnv,
714 ).pipe(
715 Effect.map(
716 Option.map((catalog) =>
717 openCode2ProviderModels(effectiveConfig, catalog),
718 ),
719 ),
720 ),
721 listOpenCode2Workspace(cwd).pipe(
722 // Leaves room for the reads around the scan wait, which uses the same override.
723 Effect.timeout(
724 2 * resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv),
725 ),
726 Effect.mapError(
727 (cause) =>
728 new ProviderDriverError({
729 driver: DRIVER_KIND,
730 instanceId,
731 detail: `Failed to list OpenCode commands and skills for '${cwd}'`,
732 cause,
733 }),
734 ),
735 ),
736 ],
737 { concurrency: "unbounded" },
738 ).pipe(
739 Effect.map(([machineSnapshot, cwdModels, { skills, commands }]) => ({
740 ...machineSnapshot,
741 ...workspaceModelsFor(machineSnapshot.models, cwdModels),
742 skills: openCode2SkillsToServerProviderSkills(skills),
743 slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
744 // A finished scan lists at least the built-ins. None means it
745 // timed out, so the registry decides from the provider's health.
746 slashCommandsPending: commands.length > 0 ? false : undefined,
747 })),
748 ),
749 v1: Effect.all([
750 snapshot.getSnapshot,
751 loadWorkspaceForCwd(cwd).pipe(
752 // Leaves room for connection setup before the command fallback below.
753 Effect.timeout(
754 2 * resolveProviderProbeTimeoutMs("opencode", 10_000, processEnv),
755 ),
756 ),
757 ]).pipe(
758 Effect.map(
759 ([machineSnapshot, { skills, models, commands, slashCommandsPending }]) => ({
760 ...machineSnapshot,
761 ...workspaceModelsFor(machineSnapshot.models, models),
762 skills: openCodeSkillsToServerProviderSkills(skills),
763 slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
764 slashCommandsPending,
765 }),
766 ),
767 Effect.mapError(
768 (cause) =>
769 new ProviderDriverError({
770 driver: DRIVER_KIND,
771 instanceId,
772 detail: `Failed to probe OpenCode commands and skills for '${cwd}'`,
773 cause,
774 }),
775 ),
776 ),
777 }),
778 ),
779 ),
780 invalidateCaches: openCode2Server.forget,
781 afterUpdate,
782 orchestrationAdapter,
783 textGeneration,
784 } satisfies ProviderInstance;
785 }),
786};