| 1 | import { |
| 2 | type ModelCapabilities, |
| 3 | type ServerProviderModel, |
| 4 | type ServerProviderSkill, |
| 5 | type ServerProviderSlashCommand, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import type { OpenCodeSettings } from "../settings.ts"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as Data from "effect/Data"; |
| 10 | import * as DateTime from "effect/DateTime"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | |
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 16 | import { |
| 17 | buildServerProvider, |
| 18 | COMPACT_SLASH_COMMAND, |
| 19 | nonEmptyTrimmed, |
| 20 | providerModelsFromSettings, |
| 21 | type ServerProviderDraft, |
| 22 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 23 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 24 | import type { ProbedOpenCode } from "./versionProbe.ts"; |
| 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; |
| 26 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; |
| 27 | |
| 28 | const OPENCODE_PRESENTATION = { |
| 29 | displayName: "OpenCode", |
| 30 | showInteractionModeToggle: false, |
| 31 | } as const; |
| 32 | |
| 33 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ |
| 34 | readonly cause?: unknown; |
| 35 | readonly detail: string; |
| 36 | }> {} |
| 37 | |
| 38 | function normalizeProbeMessage(message: string): string | undefined { |
| 39 | const trimmed = message.trim(); |
| 40 | if (trimmed.length === 0) { |
| 41 | return undefined; |
| 42 | } |
| 43 | if ( |
| 44 | trimmed === "An error occurred in Effect.tryPromise" || |
| 45 | trimmed === "An error occurred in Effect.try" |
| 46 | ) { |
| 47 | return undefined; |
| 48 | } |
| 49 | return trimmed; |
| 50 | } |
| 51 | |
| 52 | function normalizedErrorMessage(cause: unknown): string | undefined { |
| 53 | if (cause instanceof OpenCodeProbeError) { |
| 54 | return normalizeProbeMessage(cause.detail); |
| 55 | } |
| 56 | |
| 57 | if (!(cause instanceof Error)) { |
| 58 | return undefined; |
| 59 | } |
| 60 | |
| 61 | return normalizeProbeMessage(cause.message); |
| 62 | } |
| 63 | |
| 64 | function formatOpenCodeProbeError(input: { |
| 65 | readonly cause: unknown; |
| 66 | readonly isExternalServer: boolean; |
| 67 | readonly phase: "version" | "inventory"; |
| 68 | readonly serverUrl: string; |
| 69 | }): { readonly installed: boolean; readonly message: string } { |
| 70 | const detail = normalizedErrorMessage(input.cause); |
| 71 | const lower = detail?.toLowerCase() ?? ""; |
| 72 | |
| 73 | if (input.isExternalServer) { |
| 74 | if ( |
| 75 | lower.includes("401") || |
| 76 | lower.includes("403") || |
| 77 | lower.includes("unauthorized") || |
| 78 | lower.includes("forbidden") |
| 79 | ) { |
| 80 | return { |
| 81 | installed: true, |
| 82 | message: "OpenCode server rejected authentication. Check the server URL and password.", |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | if ( |
| 87 | lower.includes("econnrefused") || |
| 88 | lower.includes("enotfound") || |
| 89 | lower.includes("fetch failed") || |
| 90 | lower.includes("networkerror") || |
| 91 | lower.includes("timed out") || |
| 92 | lower.includes("timeout") || |
| 93 | lower.includes("socket hang up") |
| 94 | ) { |
| 95 | return { |
| 96 | installed: true, |
| 97 | message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | return { |
| 102 | installed: true, |
| 103 | message: detail ?? "Failed to connect to the configured OpenCode server.", |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | if (lower.includes("enoent") || lower.includes("notfound")) { |
| 108 | return { |
| 109 | installed: false, |
| 110 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", |
| 111 | }; |
| 112 | } |
| 113 | |
| 114 | if (lower.includes("quarantine")) { |
| 115 | return { |
| 116 | installed: true, |
| 117 | message: |
| 118 | "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | if (lower.includes("invalid code signature") || lower.includes("corrupted")) { |
| 123 | return { |
| 124 | installed: true, |
| 125 | message: |
| 126 | "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | const failureLabel = |
| 131 | input.phase === "inventory" |
| 132 | ? "Failed to load OpenCode provider inventory" |
| 133 | : "Failed to execute OpenCode CLI health check"; |
| 134 | return { |
| 135 | installed: true, |
| 136 | message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | function titleCaseSlug(value: string): string { |
| 141 | const segments: Array<string> = []; |
| 142 | for (const segment of value.split(/[-_/]+/)) { |
| 143 | if (segment.length > 0) { |
| 144 | segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); |
| 145 | } |
| 146 | } |
| 147 | return segments.join(" "); |
| 148 | } |
| 149 | |
| 150 | function inferDefaultVariant( |
| 151 | providerID: string, |
| 152 | variants: ReadonlyArray<string>, |
| 153 | ): string | undefined { |
| 154 | if (variants.length === 1) { |
| 155 | return variants[0]; |
| 156 | } |
| 157 | if (providerID === "anthropic" || providerID.startsWith("google")) { |
| 158 | return variants.includes("high") ? "high" : undefined; |
| 159 | } |
| 160 | if (providerID === "openai" || providerID === "opencode") { |
| 161 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; |
| 162 | } |
| 163 | return undefined; |
| 164 | } |
| 165 | |
| 166 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { |
| 167 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; |
| 168 | } |
| 169 | |
| 170 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 171 | optionDescriptors: [ |
| 172 | { |
| 173 | id: "variant", |
| 174 | label: "Reasoning", |
| 175 | type: "select", |
| 176 | options: [ |
| 177 | { id: "low", label: "Low" }, |
| 178 | { id: "medium", label: "Medium", isDefault: true }, |
| 179 | { id: "high", label: "High" }, |
| 180 | { id: "xhigh", label: "Extra High" }, |
| 181 | ], |
| 182 | currentValue: "medium", |
| 183 | }, |
| 184 | { |
| 185 | id: "agent", |
| 186 | label: "Agent", |
| 187 | type: "select", |
| 188 | options: [ |
| 189 | { id: "build", label: "Build", isDefault: true }, |
| 190 | { id: "plan", label: "Plan" }, |
| 191 | ], |
| 192 | currentValue: "build", |
| 193 | }, |
| 194 | ], |
| 195 | }); |
| 196 | |
| 197 | function openCodeCapabilitiesForModel(input: { |
| 198 | readonly providerID: string; |
| 199 | readonly model: ProviderListResponse["all"][number]["models"][string]; |
| 200 | readonly agents: ReadonlyArray<Agent>; |
| 201 | }): ModelCapabilities { |
| 202 | const rawVariantValues = Object.keys(input.model.variants ?? {}); |
| 203 | // When a model advertises no variants, synthesize the standard reasoning |
| 204 | // levels so the composer still offers a Reasoning selector (mirrors the |
| 205 | // Codex/Grok experience where reasoning is always configurable). The set |
| 206 | // covers the common OpenCode variant spectrum; `inferDefaultVariant` |
| 207 | // picks the provider-appropriate default (e.g. medium for openai/opencode). |
| 208 | const variantValues = |
| 209 | rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; |
| 210 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); |
| 211 | const variantOptions = variantValues.map((value) => |
| 212 | defaultVariant === value |
| 213 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } |
| 214 | : { id: value, label: titleCaseSlug(value) }, |
| 215 | ); |
| 216 | const primaryAgents = input.agents.filter( |
| 217 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), |
| 218 | ); |
| 219 | const defaultAgent = inferDefaultAgent(primaryAgents); |
| 220 | const agentOptions = primaryAgents.map((agent) => |
| 221 | defaultAgent === agent.name |
| 222 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } |
| 223 | : { id: agent.name, label: titleCaseSlug(agent.name) }, |
| 224 | ); |
| 225 | return createModelCapabilities({ |
| 226 | optionDescriptors: [ |
| 227 | ...(variantOptions.length > 0 |
| 228 | ? [ |
| 229 | { |
| 230 | id: "variant", |
| 231 | label: "Reasoning", |
| 232 | type: "select" as const, |
| 233 | options: variantOptions, |
| 234 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), |
| 235 | }, |
| 236 | ] |
| 237 | : []), |
| 238 | ...(agentOptions.length > 0 |
| 239 | ? [ |
| 240 | { |
| 241 | id: "agent", |
| 242 | label: "Agent", |
| 243 | type: "select" as const, |
| 244 | options: agentOptions, |
| 245 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), |
| 246 | }, |
| 247 | ] |
| 248 | : []), |
| 249 | ], |
| 250 | }); |
| 251 | } |
| 252 | |
| 253 | function flattenOpenCodeModels( |
| 254 | input: OpenCodeRuntime.OpenCodeInventory, |
| 255 | ): ReadonlyArray<ServerProviderModel> { |
| 256 | const connected = new Set(input.providerList.connected); |
| 257 | const models: Array<ServerProviderModel> = []; |
| 258 | |
| 259 | for (const provider of input.providerList.all) { |
| 260 | if (!connected.has(provider.id)) { |
| 261 | continue; |
| 262 | } |
| 263 | |
| 264 | for (const model of Object.values(provider.models)) { |
| 265 | const name = nonEmptyTrimmed(model.name); |
| 266 | if (!name) { |
| 267 | continue; |
| 268 | } |
| 269 | |
| 270 | const subProvider = nonEmptyTrimmed(provider.name); |
| 271 | models.push({ |
| 272 | slug: `${provider.id}/${model.id}`, |
| 273 | name, |
| 274 | ...(subProvider ? { subProvider } : {}), |
| 275 | isCustom: false, |
| 276 | capabilities: openCodeCapabilitiesForModel({ |
| 277 | providerID: provider.id, |
| 278 | model, |
| 279 | agents: input.agents, |
| 280 | }), |
| 281 | }); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); |
| 286 | } |
| 287 | |
| 288 | function trimOptional(value: string | null | undefined): string | undefined { |
| 289 | const trimmed = value?.trim(); |
| 290 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 291 | } |
| 292 | |
| 293 | export function openCodeSkillsToServerProviderSkills( |
| 294 | input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, |
| 295 | ): ReadonlyArray<ServerProviderSkill> { |
| 296 | const skills: ServerProviderSkill[] = []; |
| 297 | for (const skill of input ?? []) { |
| 298 | const name = trimOptional(skill.name); |
| 299 | const path = trimOptional(skill.location); |
| 300 | if (!name || !path) { |
| 301 | continue; |
| 302 | } |
| 303 | |
| 304 | const description = trimOptional(skill.description); |
| 305 | skills.push({ |
| 306 | name, |
| 307 | path, |
| 308 | enabled: true, |
| 309 | ...(description ? { description, shortDescription: description } : {}), |
| 310 | }); |
| 311 | } |
| 312 | |
| 313 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); |
| 314 | } |
| 315 | |
| 316 | export function openCodeCommandsToServerProviderSlashCommands( |
| 317 | input: OpenCodeRuntime.OpenCodeInventory["commands"], |
| 318 | ): ReadonlyArray<ServerProviderSlashCommand> { |
| 319 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; |
| 320 | const names = new Set([COMPACT_SLASH_COMMAND.name]); |
| 321 | for (const command of input ?? []) { |
| 322 | const name = trimOptional(command.name); |
| 323 | if (!name || names.has(name) || command.source === "skill") continue; |
| 324 | names.add(name); |
| 325 | const description = trimOptional(command.description); |
| 326 | const hint = trimOptional(command.hints.join(" ")); |
| 327 | commands.push({ |
| 328 | name, |
| 329 | ...(description ? { description } : {}), |
| 330 | ...(hint ? { input: { hint } } : {}), |
| 331 | }); |
| 332 | } |
| 333 | return commands; |
| 334 | } |
| 335 | |
| 336 | export const makePendingOpenCodeProvider = ( |
| 337 | openCodeSettings: OpenCodeSettings, |
| 338 | ): Effect.Effect<ServerProviderDraft> => |
| 339 | Effect.gen(function* () { |
| 340 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); |
| 341 | const models = providerModelsFromSettings( |
| 342 | [], |
| 343 | openCodeSettings.customModels, |
| 344 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 345 | ); |
| 346 | |
| 347 | if (!openCodeSettings.enabled) { |
| 348 | return buildServerProvider({ |
| 349 | presentation: OPENCODE_PRESENTATION, |
| 350 | enabled: false, |
| 351 | checkedAt, |
| 352 | models, |
| 353 | probe: { |
| 354 | installed: false, |
| 355 | version: null, |
| 356 | status: "warning", |
| 357 | auth: { status: "unknown" }, |
| 358 | message: |
| 359 | openCodeSettings.serverUrl.trim().length > 0 |
| 360 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." |
| 361 | : "OpenCode is disabled in T3 Code settings.", |
| 362 | }, |
| 363 | }); |
| 364 | } |
| 365 | |
| 366 | return buildServerProvider({ |
| 367 | presentation: OPENCODE_PRESENTATION, |
| 368 | enabled: true, |
| 369 | checkedAt, |
| 370 | models, |
| 371 | probe: { |
| 372 | installed: false, |
| 373 | version: null, |
| 374 | status: "warning", |
| 375 | auth: { status: "unknown" }, |
| 376 | message: "OpenCode provider status has not been checked in this session yet.", |
| 377 | }, |
| 378 | }); |
| 379 | }); |
| 380 | |
| 381 | /** One model an OpenCode 2 server lists, as the status check reads it. */ |
| 382 | export interface OpenCode2Model { |
| 383 | readonly providerID: string; |
| 384 | readonly id: string; |
| 385 | readonly name: string; |
| 386 | /** |
| 387 | * Name of the provider the model belongs to, when the server names it. One |
| 388 | * OpenCode instance fronts several: OpenCode Zen, OpenCode Go, and whatever |
| 389 | * the user added themselves. |
| 390 | */ |
| 391 | readonly providerName?: string; |
| 392 | readonly variants: ReadonlyArray<{ readonly id: string }>; |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * One OpenCode instance fronts several providers, and their catalogues |
| 397 | * overlap: OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming |
| 398 | * the sub-provider is what tells two such rows apart. The label only earns its |
| 399 | * place when it adds to the instance name the row already shows, which is why a |
| 400 | * provider called plain "OpenCode" keeps none, and the id stands in while the |
| 401 | * server has not named its providers yet. |
| 402 | */ |
| 403 | function subProviderLabelFor(input: { |
| 404 | readonly providerID: string; |
| 405 | readonly providerName?: string; |
| 406 | }): string | undefined { |
| 407 | const label = nonEmptyTrimmed(input.providerName) ?? input.providerID; |
| 408 | return label.toLowerCase() === OPENCODE_PRESENTATION.displayName.toLowerCase() |
| 409 | ? undefined |
| 410 | : label; |
| 411 | } |
| 412 | |
| 413 | /** |
| 414 | * A server loads its catalog lazily and lists nothing for its first few |
| 415 | * hundred milliseconds, and a local instance starts a fresh server for each |
| 416 | * status check. So an empty list is read again for a while, and the last |
| 417 | * non-empty list stands in if the catalog still has not loaded. |
| 418 | */ |
| 419 | export const makeOpenCode2ModelLoader = <E>( |
| 420 | list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>, |
| 421 | ) => |
| 422 | Effect.sync(() => { |
| 423 | let lastLoaded: ReadonlyArray<OpenCode2Model> = []; |
| 424 | return list.pipe( |
| 425 | Effect.repeat({ |
| 426 | until: (models) => models.length > 0, |
| 427 | schedule: Schedule.spaced("250 millis"), |
| 428 | }), |
| 429 | Effect.timeoutOption("5 seconds"), |
| 430 | Effect.map((loaded) => { |
| 431 | if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; |
| 432 | return lastLoaded; |
| 433 | }), |
| 434 | ); |
| 435 | }); |
| 436 | |
| 437 | /** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ |
| 438 | export interface OpenCode2Workspace { |
| 439 | readonly skills: ReadonlyArray<{ |
| 440 | readonly id: string; |
| 441 | readonly name: string; |
| 442 | readonly path: string; |
| 443 | readonly description?: string | undefined; |
| 444 | }>; |
| 445 | readonly commands: ReadonlyArray<{ |
| 446 | readonly name: string; |
| 447 | readonly description?: string | undefined; |
| 448 | }>; |
| 449 | } |
| 450 | |
| 451 | /** |
| 452 | * A server scans a directory it has not served yet in the background: its |
| 453 | * first read lists no commands at all (not even the built-ins), and |
| 454 | * `command.updated` plus `skill.updated` for the directory end the scan. A |
| 455 | * directory it already serves lists everything at once. `scanned` must be |
| 456 | * listening before `read` runs, since the read is what starts the scan. |
| 457 | */ |
| 458 | export const loadOpenCode2Workspace = <E>( |
| 459 | read: Effect.Effect<OpenCode2Workspace, E>, |
| 460 | scanned: Effect.Effect<void>, |
| 461 | ) => |
| 462 | Effect.gen(function* () { |
| 463 | const first = yield* read; |
| 464 | if (first.commands.length > 0) return first; |
| 465 | yield* scanned.pipe(Effect.timeoutOption("10 seconds")); |
| 466 | return yield* read; |
| 467 | }); |
| 468 | |
| 469 | export function openCode2SkillsToServerProviderSkills( |
| 470 | skills: OpenCode2Workspace["skills"], |
| 471 | ): ReadonlyArray<ServerProviderSkill> { |
| 472 | return skills |
| 473 | .map((skill) => { |
| 474 | const description = trimOptional(skill.description); |
| 475 | return { |
| 476 | name: skill.id, |
| 477 | path: skill.path, |
| 478 | enabled: true, |
| 479 | ...(skill.name === skill.id ? {} : { displayName: skill.name }), |
| 480 | ...(description ? { description, shortDescription: description } : {}), |
| 481 | }; |
| 482 | }) |
| 483 | .toSorted((left, right) => left.name.localeCompare(right.name)); |
| 484 | } |
| 485 | |
| 486 | export function openCode2CommandsToServerProviderSlashCommands( |
| 487 | commands: OpenCode2Workspace["commands"], |
| 488 | ): ReadonlyArray<ServerProviderSlashCommand> { |
| 489 | const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; |
| 490 | const names = new Set([COMPACT_SLASH_COMMAND.name]); |
| 491 | for (const command of commands) { |
| 492 | const name = trimOptional(command.name); |
| 493 | if (!name || names.has(name)) continue; |
| 494 | names.add(name); |
| 495 | const description = trimOptional(command.description); |
| 496 | slashCommands.push({ name, ...(description ? { description } : {}) }); |
| 497 | } |
| 498 | return slashCommands; |
| 499 | } |
| 500 | |
| 501 | /** |
| 502 | * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: |
| 503 | * OpenCode has no reviewer that approves routine actions. Plan mode is its |
| 504 | * `plan` agent, so the composer's mode toggle drives it. |
| 505 | */ |
| 506 | const OPENCODE_2_PRESENTATION = { |
| 507 | ...OPENCODE_PRESENTATION, |
| 508 | showInteractionModeToggle: true, |
| 509 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], |
| 510 | } as const; |
| 511 | |
| 512 | function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { |
| 513 | const variants = model.variants.map((variant) => variant.id); |
| 514 | const defaultVariant = inferDefaultVariant(model.providerID, variants); |
| 515 | return createModelCapabilities({ |
| 516 | optionDescriptors: |
| 517 | variants.length === 0 |
| 518 | ? [] |
| 519 | : [ |
| 520 | { |
| 521 | id: "variant", |
| 522 | label: "Reasoning", |
| 523 | type: "select", |
| 524 | options: variants.map((id) => ({ |
| 525 | id, |
| 526 | label: titleCaseSlug(id), |
| 527 | ...(id === defaultVariant ? { isDefault: true } : {}), |
| 528 | })), |
| 529 | ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), |
| 530 | }, |
| 531 | ], |
| 532 | }); |
| 533 | } |
| 534 | |
| 535 | const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( |
| 536 | settings: OpenCodeSettings, |
| 537 | version: string, |
| 538 | checkedAt: string, |
| 539 | loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>, |
| 540 | ) { |
| 541 | const result = yield* Effect.exit(loadModels); |
| 542 | const probe = (status: "ready" | "warning" | "error", message: string) => ({ |
| 543 | installed: true, |
| 544 | version, |
| 545 | status, |
| 546 | auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, |
| 547 | message, |
| 548 | }); |
| 549 | if (result._tag === "Failure") { |
| 550 | // The detail can carry a response body; it stays in the log, not the status. |
| 551 | yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); |
| 552 | return buildServerProvider({ |
| 553 | presentation: OPENCODE_2_PRESENTATION, |
| 554 | enabled: true, |
| 555 | checkedAt, |
| 556 | models: providerModelsFromSettings( |
| 557 | [], |
| 558 | settings.customModels, |
| 559 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 560 | ), |
| 561 | // Compaction does not depend on the model catalog. |
| 562 | slashCommands: [COMPACT_SLASH_COMMAND], |
| 563 | probe: probe("error", "OpenCode could not load its model list."), |
| 564 | }); |
| 565 | } |
| 566 | const models = providerModelsFromSettings( |
| 567 | result.value |
| 568 | .map((model) => { |
| 569 | const subProvider = subProviderLabelFor(model); |
| 570 | return { |
| 571 | slug: `${model.providerID}/${model.id}`, |
| 572 | name: model.name, |
| 573 | ...(subProvider ? { subProvider } : {}), |
| 574 | isCustom: false, |
| 575 | capabilities: openCode2ModelCapabilities(model), |
| 576 | }; |
| 577 | }) |
| 578 | .toSorted((left, right) => left.name.localeCompare(right.name)), |
| 579 | settings.customModels, |
| 580 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 581 | ); |
| 582 | return buildServerProvider({ |
| 583 | presentation: OPENCODE_2_PRESENTATION, |
| 584 | enabled: true, |
| 585 | checkedAt, |
| 586 | models, |
| 587 | // Every session can compact; a workspace snapshot adds that directory's commands. |
| 588 | slashCommands: [COMPACT_SLASH_COMMAND], |
| 589 | probe: |
| 590 | result.value.length > 0 |
| 591 | ? probe( |
| 592 | "ready", |
| 593 | `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, |
| 594 | ) |
| 595 | : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), |
| 596 | }); |
| 597 | }); |
| 598 | |
| 599 | /** |
| 600 | * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, |
| 601 | * the version endpoints for a configured server. The status check refreshes it, so an in-place |
| 602 | * upgrade re-routes the instance. |
| 603 | */ |
| 604 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( |
| 605 | openCodeSettings: OpenCodeSettings, |
| 606 | cwd: string, |
| 607 | probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>, |
| 608 | loadOpenCode2Models: Effect.Effect< |
| 609 | ReadonlyArray<OpenCode2Model>, |
| 610 | OpenCodeRuntime.OpenCodeRuntimeError |
| 611 | >, |
| 612 | ): Effect.fn.Return< |
| 613 | ServerProviderDraft, |
| 614 | never, |
| 615 | OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner |
| 616 | > { |
| 617 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; |
| 618 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; |
| 619 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 620 | const customModels = openCodeSettings.customModels; |
| 621 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; |
| 622 | |
| 623 | const fallback = ( |
| 624 | cause: unknown, |
| 625 | version: string | null = null, |
| 626 | phase: "version" | "inventory" = "version", |
| 627 | ) => { |
| 628 | const failure = formatOpenCodeProbeError({ |
| 629 | cause, |
| 630 | isExternalServer, |
| 631 | phase, |
| 632 | serverUrl: openCodeSettings.serverUrl, |
| 633 | }); |
| 634 | return buildServerProvider({ |
| 635 | presentation: OPENCODE_PRESENTATION, |
| 636 | enabled: openCodeSettings.enabled, |
| 637 | checkedAt, |
| 638 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), |
| 639 | probe: { |
| 640 | installed: failure.installed, |
| 641 | version, |
| 642 | status: "error", |
| 643 | auth: { status: "unknown" }, |
| 644 | message: failure.message, |
| 645 | }, |
| 646 | }); |
| 647 | }; |
| 648 | |
| 649 | if (!openCodeSettings.enabled) { |
| 650 | return buildServerProvider({ |
| 651 | presentation: OPENCODE_PRESENTATION, |
| 652 | enabled: false, |
| 653 | checkedAt, |
| 654 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), |
| 655 | probe: { |
| 656 | installed: false, |
| 657 | version: null, |
| 658 | status: "warning", |
| 659 | auth: { status: "unknown" }, |
| 660 | message: isExternalServer |
| 661 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." |
| 662 | : "OpenCode is disabled in T3 Code settings.", |
| 663 | }, |
| 664 | }); |
| 665 | } |
| 666 | |
| 667 | const probedExit = yield* Effect.exit( |
| 668 | probeRuntime.pipe( |
| 669 | Effect.mapError( |
| 670 | (cause) => |
| 671 | new OpenCodeProbeError({ |
| 672 | cause, |
| 673 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), |
| 674 | }), |
| 675 | ), |
| 676 | ), |
| 677 | ); |
| 678 | if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); |
| 679 | const probed = probedExit.value; |
| 680 | if (probed.generation === "v2") { |
| 681 | return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); |
| 682 | } |
| 683 | let version: string | null = probed.version; |
| 684 | if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { |
| 685 | return buildServerProvider({ |
| 686 | presentation: OPENCODE_PRESENTATION, |
| 687 | enabled: openCodeSettings.enabled, |
| 688 | checkedAt, |
| 689 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), |
| 690 | probe: { |
| 691 | installed: true, |
| 692 | version: probed.version, |
| 693 | status: "error", |
| 694 | auth: { status: "unknown" }, |
| 695 | message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, |
| 696 | }, |
| 697 | }); |
| 698 | } |
| 699 | |
| 700 | const loadInventory = (server: { |
| 701 | readonly url: string; |
| 702 | readonly serverPassword?: string; |
| 703 | readonly version: string; |
| 704 | }) => |
| 705 | openCodeRuntime |
| 706 | .loadOpenCodeInventory( |
| 707 | openCodeRuntime.createOpenCodeSdkClient({ |
| 708 | baseUrl: server.url, |
| 709 | directory: cwd, |
| 710 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), |
| 711 | }), |
| 712 | ) |
| 713 | .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); |
| 714 | const inventoryEffect = isExternalServer |
| 715 | ? openCodeRuntime |
| 716 | .connectToOpenCodeServer({ |
| 717 | binaryPath: openCodeSettings.binaryPath, |
| 718 | directory: cwd, |
| 719 | serverUrl: openCodeSettings.serverUrl, |
| 720 | ...(openCodeSettings.serverPassword |
| 721 | ? { serverPassword: openCodeSettings.serverPassword } |
| 722 | : {}), |
| 723 | }) |
| 724 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) |
| 725 | : serverOwner.withServer(loadInventory); |
| 726 | const inventoryExit = yield* Effect.exit( |
| 727 | inventoryEffect.pipe( |
| 728 | Effect.mapError( |
| 729 | (cause) => |
| 730 | new OpenCodeProbeError({ |
| 731 | cause, |
| 732 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), |
| 733 | }), |
| 734 | ), |
| 735 | ), |
| 736 | ); |
| 737 | if (inventoryExit._tag === "Failure") { |
| 738 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); |
| 739 | } |
| 740 | |
| 741 | version = inventoryExit.value.version; |
| 742 | |
| 743 | const models = providerModelsFromSettings( |
| 744 | flattenOpenCodeModels(inventoryExit.value.inventory), |
| 745 | customModels, |
| 746 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, |
| 747 | ); |
| 748 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); |
| 749 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; |
| 750 | return buildServerProvider({ |
| 751 | presentation: OPENCODE_PRESENTATION, |
| 752 | enabled: true, |
| 753 | checkedAt, |
| 754 | models, |
| 755 | skills, |
| 756 | slashCommands: openCodeCommandsToServerProviderSlashCommands( |
| 757 | inventoryExit.value.inventory.commands, |
| 758 | ), |
| 759 | probe: { |
| 760 | installed: true, |
| 761 | version, |
| 762 | status: connectedCount > 0 ? "ready" : "warning", |
| 763 | auth: { |
| 764 | status: connectedCount > 0 ? "authenticated" : "unknown", |
| 765 | type: "opencode", |
| 766 | }, |
| 767 | message: |
| 768 | connectedCount > 0 |
| 769 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` |
| 770 | : isExternalServer |
| 771 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." |
| 772 | : "OpenCode is available, but it did not report any connected upstream providers.", |
| 773 | }, |
| 774 | }); |
| 775 | }); |