From 2b848effb00376b6c17902ee5263732da9f5b01a Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 11 Jan 2026 17:33:12 -0700 Subject: [PATCH 1/4] fix(sdk): handle empty JSON response bodies gracefully Fixes 'Unexpected end of JSON input' error when server returns empty body. Root cause: OPTIONS handler at /zen/v1/models returned status 200 with null body, but SDK only handled empty responses for status 204 or Content-Length: 0. Changes: - Server: OPTIONS handler now returns 204 (No Content) instead of 200 - SDK (v1 & v2): JSON parsing now reads text first and handles empty bodies by returning {} instead of failing on response.json() --- .../console/app/src/routes/zen/v1/models.ts | 2 +- packages/sdk/js/src/gen/client/client.gen.ts | 21 +++++++++++++++++-- .../sdk/js/src/v2/gen/client/client.gen.ts | 21 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index ee2b3ab5416..ee89fd9afa1 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -7,7 +7,7 @@ import { ZenData } from "@opencode-ai/console-core/model.js" export async function OPTIONS(input: APIEvent) { return new Response(null, { - status: 200, + status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", diff --git a/packages/sdk/js/src/gen/client/client.gen.ts b/packages/sdk/js/src/gen/client/client.gen.ts index 34a8d0beceb..e73889f9e2e 100644 --- a/packages/sdk/js/src/gen/client/client.gen.ts +++ b/packages/sdk/js/src/gen/client/client.gen.ts @@ -114,10 +114,27 @@ export const createClient = (config: Config = {}): Client => { case "arrayBuffer": case "blob": case "formData": - case "json": - case "text": data = await response[parseAs]() break + case "json": { + const text = await response.text() + if (!text) { + data = {} + } else { + try { + data = JSON.parse(text) + } catch (parseError) { + const errorMessage = parseError instanceof Error ? parseError.message : String(parseError) + throw new Error( + `Failed to parse JSON response: ${errorMessage}. Status: ${response.status}, URL: ${response.url}` + ) + } + } + break + } + case "text": + data = await response.text() + break case "stream": return opts.responseStyle === "data" ? response.body diff --git a/packages/sdk/js/src/v2/gen/client/client.gen.ts b/packages/sdk/js/src/v2/gen/client/client.gen.ts index 47f1403429d..82317718d19 100644 --- a/packages/sdk/js/src/v2/gen/client/client.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/client.gen.ts @@ -162,10 +162,27 @@ export const createClient = (config: Config = {}): Client => { case "arrayBuffer": case "blob": case "formData": - case "json": - case "text": data = await response[parseAs]() break + case "json": { + const text = await response.text() + if (!text) { + data = {} + } else { + try { + data = JSON.parse(text) + } catch (parseError) { + const errorMessage = parseError instanceof Error ? parseError.message : String(parseError) + throw new Error( + `Failed to parse JSON response: ${errorMessage}. Status: ${response.status}, URL: ${response.url}` + ) + } + } + break + } + case "text": + data = await response.text() + break case "stream": return opts.responseStyle === "data" ? response.body From e59a83682e649563421bfa40ebccdfea08ffd427 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 11 Jan 2026 18:52:58 -0700 Subject: [PATCH 2/4] fix(tui): handle undefined agent during initial data load after login After Anthropic OAuth login, sync.data.agent is empty while data loads. This caused crashes when accessing local.agent.current().name before agents were populated. Changes: - Make agentStore.current nullable with optional chaining on init - Add fallback to first agent in current() when not found - Add early return guards in submit(), cycle(), cycleFavorite(), set() - Add null checks in highlight(), spinnerDef() memos - Use optional chaining in JSX for agent name display --- .../cli/cmd/tui/component/dialog-agent.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 18 ++++++++++------ .../src/cli/cmd/tui/context/local.tsx | 21 +++++++++++++------ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 365a22445b4..facceb25364 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -20,7 +20,7 @@ export function DialogAgent() { return ( { local.agent.set(option.value) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ad55c526850..36625ae309d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -498,6 +498,10 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } + const currentAgent = local.agent.current() + if (!currentAgent) { + return + } const sessionID = props.sessionID ? props.sessionID : await (async () => { @@ -533,7 +537,7 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { sdk.client.session.shell({ sessionID, - agent: local.agent.current().name, + agent: currentAgent.name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, @@ -554,7 +558,7 @@ export function Prompt(props: PromptProps) { sessionID, command: command.slice(1), arguments: args.join(" "), - agent: local.agent.current().name, + agent: currentAgent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, @@ -570,7 +574,7 @@ export function Prompt(props: PromptProps) { sessionID, ...selectedModel, messageID, - agent: local.agent.current().name, + agent: currentAgent.name, model: selectedModel, variant, parts: [ @@ -690,7 +694,8 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary - return local.agent.color(local.agent.current().name) + const agent = local.agent.current() + return agent ? local.agent.color(agent.name) : theme.primary }) const showVariant = createMemo(() => { @@ -701,7 +706,8 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const color = local.agent.color(local.agent.current().name) + const agent = local.agent.current() + const color = agent ? local.agent.color(agent.name) : theme.primary return { frames: createFrames({ color, @@ -931,7 +937,7 @@ export function Prompt(props: PromptProps) { /> - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current()?.name ?? "Agent")}{" "} diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..7d7b8b5deee 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -36,9 +36,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = iife(() => { const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [agentStore, setAgentStore] = createStore<{ - current: string + current: string | undefined }>({ - current: agents()[0].name, + current: agents()[0]?.name, }) const { theme } = useTheme() const colors = createMemo(() => [ @@ -54,7 +54,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((x) => x.name === agentStore.current)! + if (!agentStore.current) return agents()[0] + return agents().find((x) => x.name === agentStore.current) ?? agents()[0] }, set(name: string) { if (!agents().some((x) => x.name === name)) @@ -179,6 +180,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const currentModel = createMemo(() => { const a = agent.current() + if (!a) return fallbackModel() return ( getFirstValidModel( () => modelStore.model[a.name], @@ -219,6 +221,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ cycle(direction: 1 | -1) { const current = currentModel() if (!current) return + const currentAgent = agent.current() + if (!currentAgent) return const recent = modelStore.recent const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) if (index === -1) return @@ -227,7 +231,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - setModelStore("model", agent.current().name, { ...val }) + setModelStore("model", currentAgent.name, { ...val }) }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -239,6 +243,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return } + const currentAgent = agent.current() + if (!currentAgent) return const current = currentModel() let index = -1 if (current) { @@ -253,7 +259,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const next = favorites[index] if (!next) return - setModelStore("model", agent.current().name, { ...next }) + setModelStore("model", currentAgent.name, { ...next }) const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() setModelStore( @@ -264,6 +270,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { batch(() => { + const currentAgent = agent.current() + if (!currentAgent) return if (!isModelValid(model)) { toast.show({ message: `Model ${model.providerID}/${model.modelID} is not valid`, @@ -272,7 +280,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return } - setModelStore("model", agent.current().name, model) + setModelStore("model", currentAgent.name, model) if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() @@ -368,6 +376,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { const value = agent.current() + if (!value) return if (value.model) { if (isModelValid(value.model)) model.set({ From 23848ed326d6fa2b0f3de04de7a09b462b386491 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 14 Jan 2026 16:44:54 -0700 Subject: [PATCH 3/4] fix(storage): handle empty JSON files gracefully - Check for empty files before parsing to avoid 'Unexpected end of JSON input' error - Throw NotFoundError instead of JSON parse error for empty files - Apply fix to both read() and update() functions --- packages/opencode/src/storage/storage.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 8b4042ea13f..611b6928971 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -170,7 +170,11 @@ export namespace Storage { const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.read(target) - const result = await Bun.file(target).json() + const content = await Bun.file(target).text() + if (!content.trim()) { + throw new NotFoundError({ message: `Empty file: ${target}` }) + } + const result = JSON.parse(content) return result as T }) } @@ -180,10 +184,14 @@ export namespace Storage { const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.write(target) - const content = await Bun.file(target).json() - fn(content) - await Bun.write(target, JSON.stringify(content, null, 2)) - return content as T + const content = await Bun.file(target).text() + if (!content.trim()) { + throw new NotFoundError({ message: `Empty file: ${target}` }) + } + const parsed = JSON.parse(content) + fn(parsed) + await Bun.write(target, JSON.stringify(parsed, null, 2)) + return parsed as T }) } From 061652ed31dbaf9b1410ce76c793d495f2d3cc5a Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 14 Jan 2026 17:49:32 -0700 Subject: [PATCH 4/4] fix: prevent crashes from corrupted JSON files in storage - Add null byte/control character detection in storage read/update - Wrap JSON.parse in try-catch with descriptive error messages - Make stats command resilient to corrupted files by catching errors - Fixes 'JSON Parse error: Unterminated string' crashes in stats command --- packages/opencode/src/cli/cmd/stats.ts | 4 ++-- packages/opencode/src/storage/storage.ts | 30 +++++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index d78c4f0abd1..9d0141c432f 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -86,13 +86,13 @@ async function getAllSessions(): Promise { const sessions: Session.Info[] = [] const projectKeys = await Storage.list(["project"]) - const projects = await Promise.all(projectKeys.map((key) => Storage.read(key))) + const projects = await Promise.all(projectKeys.map((key) => Storage.read(key).catch(() => undefined))) for (const project of projects) { if (!project) continue const sessionKeys = await Storage.list(["session", project.id]) - const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key))) + const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key).catch(() => undefined))) for (const session of projectSessions) { if (session) { diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 611b6928971..4de65462304 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -174,8 +174,17 @@ export namespace Storage { if (!content.trim()) { throw new NotFoundError({ message: `Empty file: ${target}` }) } - const result = JSON.parse(content) - return result as T + const hasControlCharacters = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content) + if (hasControlCharacters) { + throw new NotFoundError({ message: `Corrupted file detected: ${target}` }) + } + try { + const result = JSON.parse(content) + return result as T + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + throw new NotFoundError({ message: `Failed to parse JSON from ${target}: ${message}` }) + } }) } @@ -188,10 +197,19 @@ export namespace Storage { if (!content.trim()) { throw new NotFoundError({ message: `Empty file: ${target}` }) } - const parsed = JSON.parse(content) - fn(parsed) - await Bun.write(target, JSON.stringify(parsed, null, 2)) - return parsed as T + const hasControlCharacters = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content) + if (hasControlCharacters) { + throw new NotFoundError({ message: `Corrupted file detected: ${target}` }) + } + try { + const parsed = JSON.parse(content) + fn(parsed) + await Bun.write(target, JSON.stringify(parsed, null, 2)) + return parsed as T + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + throw new NotFoundError({ message: `Failed to parse JSON from ${target}: ${message}` }) + } }) }