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/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/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 96b9e8ffd57..a3aeab9e230 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -496,6 +496,10 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } + const currentAgent = local.agent.current() + if (!currentAgent) { + return + } const sessionID = props.sessionID ? props.sessionID : await (async () => { @@ -531,7 +535,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, @@ -552,7 +556,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, @@ -569,7 +573,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, @@ -935,7 +941,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({ diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 18f2d67e7ac..f5bf47e9664 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -171,8 +171,21 @@ 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() - return result as T + const content = await Bun.file(target).text() + if (!content.trim()) { + throw new NotFoundError({ message: `Empty file: ${target}` }) + } + 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}` }) + } }) } @@ -181,10 +194,23 @@ 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 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}` }) + } }) } 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