From 21776a6e5645e721f4ac25c76bec75d699a1e2f0 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sun, 19 Jul 2026 19:59:44 +0530 Subject: [PATCH 1/9] fix(server): use CLI for OpenCode health check instead of spawning server The OpenCode provider health check previously spawned a full opencode serve process to fetch inventory (models + agents) via HTTP. On Windows under heavy load, this takes 15-20s+, causing timeout errors. Instead, use 'opencode models --verbose' and 'opencode agent list' CLI commands to fetch the same data without starting a server. These complete in under 2s. External server configurations still use the existing HTTP SDK path. When CLI commands fail non-zero, surface the error through the existing fallback() path instead of silently returning empty inventory. Also adds missing loadInventoryFromCli stubs to OpenCodeAdapter and OpenCodeTextGeneration test doubles, and fixes regex nullability issues. --- .../provider/Layers/OpenCodeAdapter.test.ts | 8 + .../provider/Layers/OpenCodeProvider.test.ts | 24 ++- .../src/provider/Layers/OpenCodeProvider.ts | 42 +++-- apps/server/src/provider/opencodeRuntime.ts | 168 ++++++++++++++++++ .../OpenCodeTextGeneration.test.ts | 8 + 5 files changed, 230 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index b227ff1ab66..3cd823281e9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -176,6 +176,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..c1530745154 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -95,6 +95,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadInventoryFromCli: () => + runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; beforeEach(() => { @@ -197,11 +207,21 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("closes the local OpenCode server scope after provider refresh", () => + it.effect("does not spawn a local server for health check (uses CLI instead)", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 0); + }), + ); + + it.effect("surfaces CLI failure as error status for local installs", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..2c63350014d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -409,26 +409,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } const inventoryExit = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ + (isExternalServer + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment: resolvedEnvironment, + }); + return yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ); + }), + ) + : openCodeRuntime.loadInventoryFromCli({ binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(isExternalServer && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), + }) + ).pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index a83c134d5bd..b32cb15a848 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -5,6 +5,7 @@ import { createOpencodeClient, type Agent, type FilePartInput, + type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadInventoryFromCli: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; } function parseServerUrlFromOutput(output: string): string | null { @@ -160,6 +165,107 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } +const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const AGENT_HEADER_RE = /^(\S+)\s+\((\S+)\)\s*$/; + +/** @internal */ +export function parseModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + const lines = stdout.split("\n"); + let currentSlug: string | null = null; + const jsonLines: Array = []; + + const flushModel = () => { + if (currentSlug !== null && jsonLines.length > 0) { + const jsonStr = jsonLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const model = JSON.parse(jsonStr) as Model; + const separator = currentSlug.indexOf("/"); + if (separator > 0) { + const providerID = currentSlug.slice(0, separator); + const modelID = currentSlug.slice(separator + 1); + let provider = providers.get(providerID); + if (!provider) { + provider = { id: providerID, name: providerID, models: {} }; + providers.set(providerID, provider); + } + provider.models[modelID] = model; + } + } catch { + // Skip unparseable model JSON + } + } + } + currentSlug = null; + jsonLines.length = 0; + }; + + for (const line of lines) { + const slugMatch = SLUG_LINE_RE.exec(line); + if (slugMatch) { + flushModel(); + currentSlug = slugMatch[1]!; + } else if (currentSlug !== null) { + jsonLines.push(line); + } + } + flushModel(); + + return { providers, connected: [...providers.keys()] }; +} + +/** @internal */ +export function parseAgentListCliOutput(stdout: string): ReadonlyArray { + const agents: Array = []; + const lines = stdout.split("\n"); + let currentHeader: { name: string; mode: string } | null = null; + const blockLines: Array = []; + + const flushAgent = () => { + if (currentHeader !== null) { + const jsonStr = blockLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const permission = JSON.parse(jsonStr); + agents.push({ + name: currentHeader.name, + mode: currentHeader.mode as Agent["mode"], + permission, + options: {}, + }); + } catch { + // Skip unparseable agent + } + } + } + currentHeader = null; + blockLines.length = 0; + }; + + for (const line of lines) { + const match = AGENT_HEADER_RE.exec(line); + if (match) { + flushAgent(); + currentHeader = { name: match[1]!, mode: match[2]! }; + } else if (currentHeader !== null) { + blockLines.push(line); + } + } + flushAgent(); + + return agents; +} + export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { @@ -542,12 +648,74 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map(([providerList, agents]) => ({ providerList, agents })), ); + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + Effect.gen(function* () { + const [modelsResult, agentsResult] = yield* Effect.all( + [ + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe(Effect.exit), + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["agent", "list"], + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe(Effect.exit), + ], + { concurrency: "unbounded" }, + ); + + if (modelsResult._tag === "Failure") { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `Failed to run 'opencode models': ${openCodeRuntimeErrorDetail(modelsResult.cause)}`, + cause: modelsResult.cause, + }); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: [ + `'opencode models' exited with code ${modelsResult.value.code}.`, + modelsResult.value.stderr.trim() + ? `stderr:\n${modelsResult.value.stderr.trim()}` + : null, + ] + .filter(Boolean) + .join("\n"), + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map((p) => ({ + id: p.id, + name: p.name, + source: "config" as const, + env: [], + options: {}, + models: p.models, + })); + + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + }; + }); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadInventoryFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..1fcf9bc4c73 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { From b9811591a03179e60f6d2b4a1fdc01669b96836c Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sun, 19 Jul 2026 20:00:40 +0530 Subject: [PATCH 2/9] test(server): add unit tests for OpenCode CLI parsers Adds 11 tests for parseModelsCliOutput and parseAgentListCliOutput covering: - Single and multiple providers/models - Empty input handling - Unparseable JSON graceful degradation - CRLF line endings - Complex nested permission blocks - Variants and nested model fields --- .../opencodeRuntime.cliParsers.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 apps/server/src/provider/opencodeRuntime.cliParsers.test.ts diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts new file mode 100644 index 00000000000..ae0e255b9ae --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -0,0 +1,200 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts"; + +describe("parseModelsCliOutput", () => { + it("parses a single model from a single provider", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ + id: "claude-sonnet-4-5", + providerID: "anthropic", + name: "Claude Sonnet 4.5", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.equal(result.connected.length, 1); + NodeAssert.equal(result.connected[0], "anthropic"); + + const provider = result.providers.get("anthropic")!; + NodeAssert.ok(provider); + NodeAssert.equal(provider.id, "anthropic"); + NodeAssert.equal(provider.name, "anthropic"); + NodeAssert.equal(Object.keys(provider.models).length, 1); + + const model = provider.models["claude-sonnet-4-5"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "claude-sonnet-4-5"); + NodeAssert.equal(model.providerID, "anthropic"); + NodeAssert.equal(model.name, "Claude Sonnet 4.5"); + }); + + it("parses multiple models from multiple providers", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + "openai/gpt-4o", + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.equal(result.connected.length, 2); + NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); + NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); + NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); + }); + + it("handles empty input", () => { + const result = parseModelsCliOutput(""); + NodeAssert.equal(result.providers.size, 0); + NodeAssert.equal(result.connected.length, 0); + }); + + it("skips unparseable JSON blocks", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + "this is not valid json {{{", + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + const provider = result.providers.get("anthropic")!; + NodeAssert.equal(Object.keys(provider.models).length, 1); + NodeAssert.ok(provider.models["claude-haiku-4-5"]); + }); + + it("handles Windows-style CRLF line endings", () => { + const stdout = + "anthropic/claude-sonnet-4-5\r\n" + + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + + "\r\n"; + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("handles model JSON with variants and nested fields", () => { + const stdout = [ + "opencode/gpt-5.4", + JSON.stringify({ + id: "gpt-5.4", + providerID: "opencode", + name: "GPT-5.4", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, input: 160000, output: 32000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + variants: { none: {}, low: {}, medium: {}, high: {} }, + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("opencode")!.models["gpt-5.4"]!; + NodeAssert.ok(model); + NodeAssert.ok(model.capabilities); + NodeAssert.equal(model.capabilities!.reasoning, true); + NodeAssert.ok(model.variants); + NodeAssert.equal(model.variants!["medium"] !== undefined, true); + }); +}); + +describe("parseAgentListCliOutput", () => { + it("parses a single agent", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.permission.length, 1); + }); + + it("parses multiple agents", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "plan (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 3); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + NodeAssert.equal(result[2]!.name, "plan"); + NodeAssert.equal(result[2]!.mode, "primary"); + }); + + it("handles empty input", () => { + const result = parseAgentListCliOutput(""); + NodeAssert.equal(result.length, 0); + }); + + it("skips agents with unparseable permission JSON", () => { + const stdout = [ + "build (primary)", + " not valid json {", + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "explore"); + }); + + it("handles real-world permission blocks with nested paths", () => { + const permissions = [ + { permission: "*", action: "allow", pattern: "*" }, + { + permission: "external_directory", + pattern: "C:\\Users\\test\\.local\\*", + action: "allow", + }, + { permission: "read", pattern: "*.env", action: "ask" }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.permission.length, 3); + NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); + NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); + }); +}); From c7de2cd1b0c7a2d1794219daaaa9715eb155cd03 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Sun, 19 Jul 2026 23:54:33 +0530 Subject: [PATCH 3/9] fix(server): retry CLI inventory on transient failure, degrade gracefully Retry 'opencode models --verbose' and 'opencode agent list' once after 1s on failure to handle SQLite 'database is locked' contention with concurrent opencode serve processes. Return empty inventory on persistent failure (warning status, not error) so the health check remains resilient. --- .../provider/Layers/OpenCodeProvider.test.ts | 16 ++-- apps/server/src/provider/opencodeRuntime.ts | 90 ++++++++++--------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index c1530745154..05160517bfe 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -97,13 +97,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), loadInventoryFromCli: () => runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) + ? Effect.succeed({ + providerList: { all: [], default: {}, connected: [] as string[] }, + agents: [], + } as OpenCodeInventory) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; @@ -215,13 +212,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("surfaces CLI failure as error status for local installs", () => + it.effect("degrades gracefully on CLI failure for local installs", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.status, "warning"); NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); }), ); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b32cb15a848..804c0fd6ee4 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -650,53 +650,55 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => Effect.gen(function* () { - const [modelsResult, agentsResult] = yield* Effect.all( - [ - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["models", "--verbose"], - ...(input.environment !== undefined ? { environment: input.environment } : {}), - }).pipe(Effect.exit), - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["agent", "list"], - ...(input.environment !== undefined ? { environment: input.environment } : {}), - }).pipe(Effect.exit), - ], - { concurrency: "unbounded" }, - ); + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...env, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( + Effect.exit, + ); - if (modelsResult._tag === "Failure") { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: `Failed to run 'opencode models': ${openCodeRuntimeErrorDetail(modelsResult.cause)}`, - cause: modelsResult.cause, - }); - } - if (modelsResult.value.code !== 0) { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: [ - `'opencode models' exited with code ${modelsResult.value.code}.`, - modelsResult.value.stderr.trim() - ? `stderr:\n${modelsResult.value.stderr.trim()}` - : null, - ] - .filter(Boolean) - .join("\n"), - }); + // First attempt — run both in parallel + let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { + concurrency: "unbounded", + }); + + // Retry once after 1s on transient failures (e.g. SQLite "database is locked") + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + ], + { concurrency: "unbounded" }, + ); + modelsResult = m2; + agentsResult = a2; } - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - const connected = [...parsed.connected]; - const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map((p) => ({ - id: p.id, - name: p.name, - source: "config" as const, - env: [], - options: {}, - models: p.models, - })); + // Degrade gracefully on failure — return empty inventory (warning status, not error) + let connected: ReadonlyArray = []; + let allProviders: ProviderListResponse["all"] = []; + if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + connected = [...parsed.connected]; + allProviders = [...parsed.providers.values()].map((p) => ({ + id: p.id, + name: p.name, + source: "config" as const, + env: [], + options: {}, + models: p.models, + })); + } let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { From 25546f1560da798dd5b889566f4ffec02ad67d17 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 00:00:51 +0530 Subject: [PATCH 4/9] fix: use mutable array type for connected in CLI inventory path --- apps/server/src/provider/opencodeRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 804c0fd6ee4..b0017941ec6 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -685,7 +685,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } // Degrade gracefully on failure — return empty inventory (warning status, not error) - let connected: ReadonlyArray = []; + let connected: string[] = []; let allProviders: ProviderListResponse["all"] = []; if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { const parsed = parseModelsCliOutput(modelsResult.value.stdout); From 476a8a8353d74234c903d3f589a3c0c02509f396 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 02:11:19 +0530 Subject: [PATCH 5/9] fix(server): set hidden flag on known hidden agents in CLI parser --- apps/server/src/provider/opencodeRuntime.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 8b63e4be486..7a4ea6fa7d1 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -168,6 +168,11 @@ function parseServerUrlFromOutput(output: string): string | null { const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(\S+)\s+\((\S+)\)\s*$/; +// Agents that are always hidden in OpenCode but the CLI "agent list" command +// does not expose the hidden flag. Keep in sync with OpenCode agent +// definitions (packages/opencode/src/agent/agent.ts). +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); + /** @internal */ export function parseModelsCliOutput(stdout: string): { readonly providers: ReadonlyMap< @@ -240,6 +245,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name) || undefined, permission, options: {}, }); From e862daf8c95ebf4479f155bfb289f9e70f40d389 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 02:18:26 +0530 Subject: [PATCH 6/9] chore: clarify comment refers to OpenCode repo path --- apps/server/src/provider/opencodeRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 7a4ea6fa7d1..6969b6a6a98 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -170,7 +170,7 @@ const AGENT_HEADER_RE = /^(\S+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent -// definitions (packages/opencode/src/agent/agent.ts). +// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */ From 3be4c1313d2aabbeb90cb8e7b84d9ab6adae969e Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 02:23:04 +0530 Subject: [PATCH 7/9] fix(server): support agent names with spaces in CLI parser Update AGENT_HEADER_RE to capture everything before the (mode) suffix instead of only non-whitespace tokens. Adds tests for names with spaces and for the known-hidden-agent flag. --- .../opencodeRuntime.cliParsers.test.ts | 29 +++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index ae0e255b9ae..3356d215808 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -197,4 +197,33 @@ describe("parseAgentListCliOutput", () => { NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); }); + + it("handles agent names with spaces", () => { + const stdout = [ + "code reviewer (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "my custom agent (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "code reviewer"); + NodeAssert.equal(result[0]!.mode, "subagent"); + NodeAssert.equal(result[1]!.name, "my custom agent"); + NodeAssert.equal(result[1]!.mode, "primary"); + }); + + it("marks known hidden agents", () => { + const stdout = [ + "compaction (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result[0]!.hidden, true); + NodeAssert.equal(result[1]!.hidden, undefined); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 6969b6a6a98..5efffd4cb91 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -166,7 +166,7 @@ function parseServerUrlFromOutput(output: string): string | null { } const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; -const AGENT_HEADER_RE = /^(\S+)\s+\((\S+)\)\s*$/; +const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent From 01ca564a30e95d53ec9f1661a82ae841f2b351eb Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 02:47:25 +0530 Subject: [PATCH 8/9] fix: use boolean for hidden flag instead of boolean | undefined --- apps/server/src/provider/opencodeRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 5efffd4cb91..b853662b037 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -245,7 +245,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], - hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name) || undefined, + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), permission, options: {}, }); From f311ca6b64e433f6de2cbf1b8841174c0d30fde8 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 20 Jul 2026 03:05:24 +0530 Subject: [PATCH 9/9] fix: update test to expect false instead of undefined for non-hidden agents --- apps/server/src/provider/opencodeRuntime.cliParsers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 3356d215808..6208f04507e 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -224,6 +224,6 @@ describe("parseAgentListCliOutput", () => { const result = parseAgentListCliOutput(stdout); NodeAssert.equal(result[0]!.hidden, true); - NodeAssert.equal(result[1]!.hidden, undefined); + NodeAssert.equal(result[1]!.hidden, false); }); });