Compare commits

..
9 Commits
96 changed files with 655 additions and 630 deletions
+35 -36
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.9",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,11 +201,10 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -215,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -407,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -456,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -493,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -528,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -541,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -555,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -570,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -609,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -639,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -654,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -678,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -699,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -721,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -756,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -776,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -810,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -829,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -875,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -889,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -924,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -959,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -992,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1033,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.9",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.9",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+14 -10
View File
@@ -456,6 +456,10 @@ export type V2EventServerConnected = {
data: {}
}
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
@@ -1656,19 +1660,19 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ModelVariant = {
id: string
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
@@ -1898,7 +1902,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
@@ -2107,13 +2111,13 @@ export type ConfigEntry =
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigProviderSettings
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigProviderSettings
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+3 -7
View File
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Source and execution model
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
TypeScript-only syntax is rejected rather than stripped before execution.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
@@ -41,8 +40,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## Values and literals
@@ -494,9 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
+2 -9
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.9",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
@@ -19,12 +19,6 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
@@ -32,8 +26,7 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:"
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+9 -7
View File
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
imports?: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
@@ -41,12 +41,14 @@ try {
]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
if (pkg.imports) {
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
+1 -14
View File
@@ -1,14 +1,10 @@
import { parse, type Program } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
import { PendingThrow } from "./model.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
return parse(executableCode, {
return parse(code, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -1,25 +0,0 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -1,11 +0,0 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+13 -7
View File
@@ -15,6 +15,12 @@ const error = async (code: string) => {
return result.error
}
describe("source syntax", () => {
test("rejects TypeScript-only syntax", async () => {
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
})
})
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 14)",
"(line 1, col 10)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 2, col 5)",
"(line 1, col 8)",
)
})
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
})
})
@@ -147,7 +153,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
})
test("the limit is 10000 nested calls", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.8",
"version": "2.0.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.8",
"version": "2.0.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.9",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+8 -8
View File
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
import { ProviderShared } from "@opencode/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import type { ID, RuntimeInfo } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export interface SDKEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string) {
function prepareOptions(model: RuntimeInfo, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.canonical ?? model.providerID,
@@ -182,8 +182,8 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
}),
)
function modelFromLanguage(info: Info, language: LanguageModelV3) {
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const providerID = info.canonical ?? info.providerID
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: Info, packageName: string) {
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
+21 -16
View File
@@ -8,6 +8,7 @@ import { AISDK } from "./aisdk.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
import type { RuntimeInfo } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
@@ -117,9 +118,9 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
/** Model policy overrides the provider policy; omitted means summary compaction. */
readonly compaction?: Provider.Compaction
/** Model transport overrides the provider transport; omitted means HTTP. */
/** Provider transport policy; omitted means HTTP. */
readonly transport?: Provider.Transport
}
@@ -149,7 +150,7 @@ export const withVariant = (
variant
? {
...model,
settings: Provider.mergeOverlay(model.settings, variant.settings),
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
headers: Provider.mergeHeaders(model.headers, variant.headers),
body: Provider.mergeOverlay(model.body, variant.body),
}
@@ -159,11 +160,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
}
export const fromCatalogModel = (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
@@ -191,7 +192,7 @@ export const fromCatalogModel = (
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
@@ -248,7 +249,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
})
})
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return {
...model,
@@ -260,7 +261,7 @@ function prepareRuntimeModel(model: Info, credential: Credential.Value | undefin
}
function validateProviderVariables(
model: Info,
model: RuntimeInfo,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
@@ -270,7 +271,7 @@ function validateProviderVariables(
}
function prepareProviderSettings(
model: Info,
model: RuntimeInfo,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
@@ -280,14 +281,14 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
@@ -310,14 +311,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
return { apiKey: credential.access }
}
const unsupported = (model: Info) =>
const unsupported = (model: RuntimeInfo) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
@@ -359,7 +360,11 @@ export const layer = Layer.effect(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const runtimeInfo = yield* withVariant(selected, variant)
const selectedVariant = yield* withVariant(selected, variant)
const runtimeInfo: RuntimeInfo = {
...selectedVariant,
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
}
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
@@ -382,7 +387,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
transport: provider?.settings?.transport,
}
})
return Service.of({
@@ -406,7 +411,7 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: Info) {
function hasConfiguredAuth(model: RuntimeInfo) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
+7 -1
View File
@@ -36,6 +36,9 @@ export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
/** Effective provider and model settings used only while constructing a runtime model. */
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
export type MutableInfo = DeepMutable<Info>
export { Event } from "@opencode/schema/model"
@@ -188,7 +191,10 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
+15 -18
View File
@@ -7,7 +7,6 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
@@ -140,16 +139,18 @@ export const AzurePlugin = define({
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
if (resourceName)
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
})
yield* ctx.model.transform((models) => {
@@ -167,10 +168,6 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
})
}
}
@@ -242,9 +239,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
if (settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
+6 -6
View File
@@ -254,10 +254,13 @@ export const OpenAIPlugin = define({
yield* ctx.provider.transform((providers) => {
const item = providers.get(Provider.ID.openai)
if (!item) return
if (!chatgpt) return
const account = chatgpt.metadata?.accountID
const account = chatgpt?.metadata?.accountID
providers.update(item.provider.id, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
...(chatgpt ? { baseURL: codexBaseURL } : {}),
})
if (!chatgpt) return
provider.headers = Provider.mergeHeaders(provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
@@ -270,9 +273,6 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+6 -7
View File
@@ -95,14 +95,13 @@ export const XAIPlugin = define({
editor.method.update(device(ctx.app))
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
yield* ctx.provider.transform((providers) => {
if (!providers.get(providerID)) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
})
}
})
})
}),
})
+5
View File
@@ -134,11 +134,16 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
}
export function modelSettings(settings: Settings | undefined) {
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
}
export function mergeOverlay(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
+5 -4
View File
@@ -27,14 +27,15 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
Model.Info.make({
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
...Model.Info.make({
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
modelID: Model.ID.make("api-model"),
package: Provider.aisdk(packageName),
settings,
limit: { context: 100, output: 20 },
})
}),
settings,
})
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
+2 -1
View File
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
}),
)
it.effect("resolves provider and model overlay merges", () =>
it.effect("keeps settings scoped while resolving request overlay merges", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -533,6 +533,7 @@ describe("Provider and Model", () => {
})
const model = required(yield* models.get(providerID, modelID))
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
+19 -11
View File
@@ -37,6 +37,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
const providers = yield* Provider.Service
yield* addPlugin([
new Document({
type: "document",
@@ -63,6 +64,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -73,8 +75,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("inherits the provider websocket policy with model overrides", () =>
it.effect("keeps the provider websocket policy out of model settings", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* addPlugin([
new Document({
@@ -83,8 +86,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
models: {
inherited: {
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
},
},
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -92,10 +99,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
])
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
expect(inherited.settings).toEqual({ shared: "provider", model: true })
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -106,7 +112,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -122,7 +128,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
yield* addPlugin([
new Document({
@@ -131,15 +138,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
models: { override: { modelID: builtin.model } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
}),
)
}
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
@@ -472,9 +472,8 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
}
@@ -205,7 +205,8 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.settings?.transport).toBe("websocket")
expect(provider.settings?.transport).toBe("websocket")
expect(model.settings?.transport).toBeUndefined()
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -83,8 +83,10 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const provider = yield* providers.get(providerID)
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.settings?.transport).toBe("websocket")
expect(provider?.settings?.transport).toBe("websocket")
expect(model?.settings?.transport).toBeUndefined()
}),
)
})
+15
View File
@@ -42,4 +42,19 @@ describe("Provider", () => {
reasoningEffort: "high",
})
})
test("inherits shared and loose settings without provider-only policies", () => {
expect(
Provider.modelSettings({
timeout: 60_000,
chunkTimeout: 30_000,
transport: "websocket",
compaction: { type: "native" },
reasoningEffort: "high",
}),
).toEqual({
compaction: { type: "native" },
reasoningEffort: "high",
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+134 -6
View File
@@ -3,7 +3,7 @@
//
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
// [--trace] [--out <dir>] [--home <dir>]
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
//
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
@@ -21,7 +21,15 @@
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
import { spawn } from "node:child_process"
//
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
// without activating it the moment it exists, then records when the window's pixels first differ
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
import { execFileSync, spawn } from "node:child_process"
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { createServer } from "node:net"
import { tmpdir } from "node:os"
@@ -45,6 +53,9 @@ const args = parseArgs({
"profile-renderer": { type: "boolean", default: false },
trace: { type: "boolean", default: false },
"settle-ms": { type: "string", default: "1500" },
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
"window-at": { type: "string" },
},
allowPositionals: true,
})
@@ -112,10 +123,12 @@ const probe = `(() => ({
origin: performance.timeOrigin,
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('[data-timeline-row]').length,
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
prepaint: !!document.getElementById('oc-prepaint'),
visible: document.visibilityState === 'visible',
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('#root [data-timeline-row]').length,
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
url: location.pathname + location.search,
}))()`
// Main-process bootstrap timing, read after the run: when the process was created, when Node
@@ -237,6 +250,8 @@ type Probe = {
origin: number
firstPaint?: number
domInteractive?: number
prepaint: boolean
visible: boolean
shell: boolean
editor: boolean
rows: number
@@ -273,6 +288,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const trace = args.values.trace && run === runs
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
const launchArgs = [
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
`--remote-debugging-port=${cdpPort}`,
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
...(trace
@@ -284,10 +300,12 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
]
: []),
]
const raiser = await windowRaiser()
const spawnAt = Date.now()
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
child.unref()
appPid = child.pid
raiser.raise(child.pid!)
let mainProfile: Promise<unknown> | undefined
if (profile) mainProfile = profileMain(spawnAt)
@@ -309,6 +327,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
const seen: Record<string, number> = {}
let last: Probe | undefined
let prepaintSeen = false
let quietSince: number | undefined
let taskMs = 0
let scriptMs = 0
@@ -318,6 +337,9 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
last = result.result?.result?.value as Probe | undefined
const t = Date.now() - spawnAt
if (last?.prepaint) prepaintSeen = true
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
@@ -356,6 +378,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
cdp.close()
const processes = appPid ? await processTree(appPid) : []
const screenChanges = await raiser.screen()
const boot = profile ? undefined : await mainBootTiming()
await sleep(300)
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
@@ -385,7 +408,16 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
domInteractive:
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
prepaintVisible:
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
? Math.round(origin + last.firstPaint)
: undefined,
...seen,
// First sampled screen change inside the window: the ground truth for "the user sees something".
screenPainted: screenChanges.changed[0],
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
screenSettled: screenChanges.settled,
rendererIdle: rendererIdleMs,
},
final: { url: last?.url, timelineRows: last?.rows },
@@ -451,6 +483,22 @@ function prepareHome() {
})
}
mkdirSync(paths.logs, { recursive: true })
const at = args.values["window-at"]?.split(",").map(Number)
if (at?.length === 2 && existsSync(userData)) {
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
const bounds = displayAt(at[0], at[1])
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
}
}
}
function displayAt(x: number, y: number) {
if (process.platform !== "win32") return undefined
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
}
async function freePort() {
@@ -565,6 +613,86 @@ function mainLog() {
}
// Working set of every process in the launched app's tree once it is idle.
// Windows places a window launched from a background process behind the foreground one, and
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
// what else is on screen. A helper started before the app polls for its main window and pins it
// topmost without activating it, so the user keeps their focus and the bench window is visible.
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
const script = join(outDir, "raise-window.ps1")
writeFileSync(
script,
[
`Add-Type -AssemblyName System.Drawing`,
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
`[Console]::Out.WriteLine("ready")`,
`$target = [int][Console]::In.ReadLine()`,
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
`$h = 0`,
`for ($i = 0; $i -lt 600; $i++) {`,
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
` Start-Sleep -Milliseconds 10`,
`}`,
`if (-not $h -or $h -eq 0) { exit }`,
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
// colour, a splash and the interface apart.
`$r = New-Object Bench.User32+RECT`,
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
` $t = $clock.ElapsedMilliseconds`,
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
` $sum = 0`,
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
` $g.Dispose(); $bmp.Dispose()`,
` Start-Sleep -Milliseconds 30`,
`}`,
].join("\n"),
)
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
const lines: string[] = []
let buffer = ""
await new Promise<void>((resolve) => {
helper.stdout!.on("data", (chunk: Buffer) => {
buffer += chunk.toString()
const parts = buffer.split(/\r?\n/)
buffer = parts.pop() ?? ""
for (const line of parts) {
if (line === "ready") resolve()
else lines.push(line)
}
})
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
helper.on("exit", () => resolve())
})
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
return {
raise: (pid) => helper.stdin!.write(`${pid}\n`),
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
// content differed from the first sample, i.e. when something other than the background colour
// was on screen.
screen: async () => {
await exited
const samples = lines
.filter((line) => line.startsWith("screen "))
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
const first = samples[0]?.[1]
const last = samples.at(-1)?.[1]
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
// The last sample that still differed from the final content, i.e. when the window stopped changing.
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
},
}
}
async function processTree(root: number) {
const script =
process.platform === "win32"
@@ -2,14 +2,7 @@ import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
saveWindowPrepaint,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
@@ -45,11 +38,6 @@ export const windowHandlers = WindowRpcs.toLayer(
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
WindowSavePrepaint: ({ html }, context) =>
Effect.promise(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
return win ? saveWindowPrepaint(win, html) : Promise.resolve()
}),
})
}),
)
+9 -19
View File
@@ -1,10 +1,10 @@
export * as Ipc from "./ipc"
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
@@ -23,7 +23,6 @@ import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { isRendererUrl } from "./windows/protocol"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
@@ -66,27 +65,18 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
}
// Each renderer document asks for its own port once its client is listening; see ipc-client.ts.
const handPort = (event: Electron.IpcMainEvent) => {
const contents = event.sender
if (contents.isDestroyed() || !isRendererUrl(contents.getURL())) return
const channel = new MessageChannelMain()
handoff.bind(contents, channel.port1)
contents.postMessage(IpcTransportPort, null, [channel.port2])
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
ipcMain.on(IpcTransportPortRequest, handPort)
})
yield* Effect.addFinalizer(
() =>
Effect.sync(() => {
app.off("browser-window-created", wire)
ipcMain.off(IpcTransportPortRequest, handPort)
}),
)
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
return {
installMenu: () => createMenu(menu),
}
@@ -99,9 +99,7 @@ export function setZoomFactor(win: BrowserWindow, factor: number) {
export function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
// Setting the factor forces a visual-properties round trip with the renderer, so leave it alone
// when it is already 1: the first window has a document on screen by now.
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, direction) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
+2 -13
View File
@@ -6,11 +6,9 @@ import { windowIDArgument } from "../../shared/window-bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { rendererHost, rendererProtocol } from "./scheme"
import { earlyQuery, serveRenderer } from "./serve"
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number; loaded: boolean }
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
let pending: EarlyWindow | undefined
@@ -58,16 +56,7 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
// In production the window starts its document now - the same index.html without its scripts,
// carrying the shell snapshot from the previous run - so the renderer process and the stylesheet
// are ready, and the user sees their UI, while the bundle and the layers load. The scripts are
// added when restoreWindows() adopts the window. Development keeps loading from the dev server.
const loaded = !process.env.ELECTRON_RENDERER_URL
if (loaded) {
serveRenderer(path.join(root, "../renderer"))
void win.loadURL(`${rendererProtocol}://${rendererHost}/index.html?${earlyQuery}=${encodeURIComponent(id)}`)
}
pending = { id, win, state, shownAt: Date.now(), loaded }
pending = { id, win, state, shownAt: Date.now() }
}
export function takeEarlyWindow() {
+1 -18
View File
@@ -22,8 +22,6 @@ import {
wireZoom,
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { removePrepaint, writePrepaint } from "./prepaint"
import { releaseRenderer } from "./serve"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -31,7 +29,6 @@ import { manageWindowState, readWindowState, resolveWindowState, windowStateFile
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const themeReady = new WeakMap<BrowserWindow, () => void>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const displays = {
all: () => screen.getAllDisplays().map((display) => display.bounds),
primary: () => screen.getPrimaryDisplay().bounds,
@@ -83,12 +80,6 @@ export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
export function saveWindowPrepaint(win: BrowserWindow, html: string) {
const id = windowIDs.get(win)
if (!id) return Promise.resolve()
return writePrepaint(id, html)
}
export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -135,13 +126,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
if (early?.loaded) {
runFork(
Effect.tryPromise(() => releaseRenderer(win, paths.rendererRoot)).pipe(
Effect.catch((error) => scoped("window", Effect.logError("failed to release early renderer", { id, error }))),
),
)
} else loadWindow(win, "index.html")
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -178,7 +163,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const register = (win: BrowserWindow, id: string) => {
registry.register(id, win)
windowIDs.set(win, id)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
win.on("session-end", () => registry.setQuitting())
@@ -188,7 +172,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
Effect.gen(function* () {
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* Effect.promise(() => removePrepaint(id))
}).pipe(
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
),
@@ -1,27 +0,0 @@
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { app } from "electron"
// The shell snapshot a window shows before its renderer has booted. It is a file next to the
// window-state JSON rather than a row in the desktop database because the entry module serves it
// before the storage layers exist; a snapshot from another app version is ignored, since its class
// names may no longer match the bundled stylesheet.
export function prepaintFile(id: string) {
return `prepaint-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.html`
}
export function prepaintMarker(version: string) {
return `<!-- opencode ${version} -->`
}
export async function writePrepaint(id: string, html: string) {
const file = path.join(app.getPath("userData"), prepaintFile(id))
await mkdir(path.dirname(file), { recursive: true })
await writeFile(`${file}.tmp`, `${prepaintMarker(app.getVersion())}\n${html}`)
await rename(`${file}.tmp`, file)
}
export function removePrepaint(id: string) {
return rm(path.join(app.getPath("userData"), prepaintFile(id)), { force: true })
}
+51 -9
View File
@@ -1,19 +1,54 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { Effect } from "effect"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
import { serveRenderer, setRendererProtocolLogger } from "./serve"
// The entry module normally registers the handler before the bundle loads; this only wires its
// logging, and registers it when the entry module did not (development, or a window created later).
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context<never>())
setRendererProtocolLogger((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
serveRenderer(paths.rendererRoot)
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
}
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(paths.rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
return new Response("Not found", { status: 404 })
}
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
@@ -33,4 +68,11 @@ export function isRendererUrl(value?: string, html = false) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
-114
View File
@@ -1,114 +0,0 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { app, net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { prepaintFile, prepaintMarker } from "./prepaint"
import { rendererHost, rendererProtocol } from "./scheme"
// Serves the renderer bundle over oc://renderer. This module has no Effect dependency because the
// entry module registers it right after Electron is ready, before the main bundle has loaded, so
// the first window can start its document while the bundle and the layers evaluate.
export const earlyQuery = "early"
type Log = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
let log: Log = () => {}
export function setRendererProtocolLogger(logger: Log) {
log = logger
}
export function serveRenderer(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
log("warning", "rejected host", { url: request.url })
return new Response("Not found", { status: 404 })
}
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
log("warning", "rejected path", { url: request.url, file })
return new Response("Not found", { status: 404 })
}
const early = url.pathname === "/index.html" ? url.searchParams.get(earlyQuery) : null
if (early !== null) return earlyDocument(file, early)
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
log("error", "fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
})
}
return addDocumentPolicy(response, file)
} catch (error) {
log("error", "fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
})
}
// The early window loads index.html?early=<window id>: the same document without its module
// scripts, plus the shell snapshot the window captured last time, so the user sees their own UI
// while the main process is still loading. releaseRenderer() adds the scripts back once the window
// has been adopted.
async function earlyDocument(file: string, id: string) {
const [html, prepaint] = await Promise.all([
readFile(file, "utf8"),
readFile(path.join(app.getPath("userData"), prepaintFile(id)), "utf8").catch(() => undefined),
])
const body = prepaint?.startsWith(prepaintMarker(app.getVersion()))
? `<div id="oc-prepaint" inert aria-hidden="true" style="position:fixed;inset:0;z-index:100;pointer-events:none;display:flex;flex-direction:column;background-color:var(--background-base)">${prepaint.slice(prepaint.indexOf("\n") + 1)}</div></body>`
: "</body>"
const text = html.replace(moduleScript, "").replace("</body>", body)
return new Response(text, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
[documentPolicyHeader]: jsCallStacksDocumentPolicy,
},
})
}
const moduleScript = /<script type="module"[^>]*><\/script>\s*/g
export async function releaseRenderer(win: BrowserWindow, rendererRoot: string) {
const html = await readFile(path.join(rendererRoot, "index.html"), "utf8")
const srcs = [...html.matchAll(moduleScript)].flatMap((match) => {
const src = /\bsrc="([^"]+)"/.exec(match[0])
return src ? [src[1]] : []
})
if (win.isDestroyed()) return
// async = false keeps the runtime chunk ahead of the entry, as the static tags did. Dropping the
// query afterwards makes a reload load the full document.
await win.webContents.executeJavaScript(
`(() => {
for (const src of ${JSON.stringify(srcs)}) {
const script = document.createElement("script")
script.type = "module"
script.async = false
script.crossOrigin = ""
script.src = src
document.head.appendChild(script)
}
history.replaceState(history.state, "", "/index.html")
})()`,
)
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import { listInstalledWslDistros, probeWslRuntime, runWslSh, wslArgs } from "./runtime"
test("wslArgs bypasses the distro default shell", () => {
expect(wslArgs(["sh", "-lc", 'printf "%s\\n" "$HOME"'], "Debian")).toEqual([
"-d",
"Debian",
"--exec",
"sh",
"-lc",
'printf "%s\\n" "$HOME"',
])
expect(wslArgs(["bash", "-se"], "Debian", "root")).toEqual([
"-d",
"Debian",
"--user",
"root",
"--exec",
"bash",
"-se",
])
})
// Exercise the real wsl.exe argv path: with `--`, WSL routes the command line through the distro's
// default shell, which expands `$cli` to "" before `sh` runs the script (#48640).
const distro =
process.platform === "win32" && (await probeWslRuntime()).available
? (await listInstalledWslDistros().catch(() => [])).find((item) => item.isDefault)?.name
: undefined
test.skipIf(!distro)("inline scripts keep their own variable expansion inside WSL", async () => {
const result = await runWslSh(['cli="from-script"', 'printf "%s\\n" "$cli"'].join("\n"), distro)
expect(result.code).toBe(0)
expect(result.stdout.trim()).toBe("from-script")
})
+3 -1
View File
@@ -38,7 +38,9 @@ const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
// `--` hands the command line to the distro's default shell, which expands `$VAR` and `$(...)`
// inside our inline scripts before `sh -lc` ever runs them. `--exec` runs the command directly.
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--exec", ...args]
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
+1 -2
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
@@ -11,6 +11,5 @@ ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelE
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
requestRpcPort: () => ipcRenderer.send(IpcTransportPortRequest),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
-1
View File
@@ -1,5 +1,4 @@
export type ElectronNative = {
windowID: string
requestRpcPort(): void
getPathForFile(file: File): string
}
@@ -54,8 +54,6 @@ export type ElectronAPI = {
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
themeReady(): Promise<void>
// Persists the sanitized shell markup the next launch shows before the renderer boots.
savePrepaint(html: string): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
-1
View File
@@ -112,7 +112,6 @@ export const api: ElectronAPI = {
getWindowID: () => window.electron.windowID,
themeReady: () => invoke("WindowThemeReady"),
savePrepaint: (html) => invoke("WindowSavePrepaint", { html }),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+2 -25
View File
@@ -19,7 +19,7 @@ import {
} from "@opencode/app/desktop"
import { useTheme } from "@opencode/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, on, Show, Suspense } from "solid-js"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
@@ -27,7 +27,6 @@ import { createDesktopPlatform } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
import { hasPrepaint, removePrepaint, schedulePrepaintCapture } from "./startup/prepaint"
import { LoadingSplash } from "./startup/splash"
import { getLastActiveUrl } from "./window/route-storage"
import { DesktopMemoryRouter } from "./window/router"
@@ -41,12 +40,9 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
const initialUrl = getLastActiveUrl(windowState.id)
const url = new URL(initialUrl, "http://localhost")
const route = currentRoute(url.pathname, url.search)
// With a shell snapshot on screen the splash is not needed; the snapshot is removed when the
// interface would otherwise be revealed.
const prepaint = hasPrepaint()
const [startup, setStartup] = createStore({
ready: false,
visible: !prepaint,
visible: true,
themeReady: false,
onboardingReady: false,
drawingReady: false,
@@ -76,25 +72,6 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
if (!startup.themeReady || firstLaunch.loading) return
void props.api.themeReady()
})
createEffect(() => {
if (!prepaint || firstLaunch() !== true) return
removePrepaint()
setStartup("visible", true)
})
createEffect(() => {
if (!readyToReveal()) return
removePrepaint()
schedulePrepaintCapture(props.api.savePrepaint)
})
createEffect(
on(
() => startup.route,
() => {
if (startup.ready) schedulePrepaintCapture(props.api.savePrepaint, 3000)
},
{ defer: true },
),
)
function ReadyApp() {
const wslServers = useWslServers()
@@ -13,8 +13,6 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
// The renderer asks for its port rather than receiving one on load: the first window's document
// is on screen before its scripts, and before the main process has its IPC layer, exist.
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
@@ -24,7 +22,6 @@ const port = new Promise<MessagePort>((resolve) => {
resolve(value)
}
window.addEventListener("message", onMessage)
window.electron.requestRpcPort()
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
@@ -1,72 +0,0 @@
// The early document carries a static copy of this window's shell from the previous run, served by
// the main process as #oc-prepaint, so the user sees their UI while the renderer boots. It is
// removed when the real interface is ready to be revealed, and re-captured from the live DOM so the
// next launch shows the current state.
const prepaintID = "oc-prepaint"
const maxBytes = 1_000_000
export function hasPrepaint() {
return document.getElementById(prepaintID) !== null
}
export function removePrepaint() {
document.getElementById(prepaintID)?.remove()
}
let timer: number | undefined
// Captures once the renderer is idle; `delay` coalesces bursts of route changes into one capture.
export function schedulePrepaintCapture(save: (html: string) => Promise<void>, delay = 0) {
clearTimeout(timer)
timer = window.setTimeout(() => {
requestIdleCallback(
() => {
const root = document.getElementById("root")
const html = root && capturePrepaint(root)
if (html) void save(html).catch(() => undefined)
},
{ timeout: 2000 },
)
}, delay)
}
// Elements that hold live or transient state and would look wrong, or leak, in a static copy.
const dropped =
"script, style, link, iframe, object, embed, canvas, video, audio, dialog, [popover], [role='dialog'], [role='menu'], [role='listbox'], [role='tooltip'], [data-component='startup-overlay']"
// Attributes that could act, be targeted, or collide with the live document once it mounts.
const stripped = new Set(["id", "href", "tabindex", "contenteditable", "autofocus", "for", "name", "action", "formaction"])
export function capturePrepaint(root: HTMLElement) {
const clone = root.cloneNode(true) as HTMLElement
clone.querySelectorAll(dropped).forEach((element) => element.remove())
const symbols = new Set<string>()
for (const element of clone.querySelectorAll("*")) {
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) element.removeAttribute("value")
if (element.hasAttribute("contenteditable")) element.replaceChildren()
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name
if (name.startsWith("on")) element.removeAttribute(name)
// SVG keeps its ids and hrefs: gradients, clip paths and <use> references are local visuals.
if (element instanceof SVGElement) {
if (name === "href" && attribute.value.startsWith("#")) symbols.add(attribute.value.slice(1))
continue
}
if (stripped.has(name)) element.removeAttribute(name)
if (name === "src" && !/^(\.\/|\/|oc:|data:)/.test(attribute.value)) element.removeAttribute(name)
}
}
const root_ = document.documentElement
const html = `<div class="${document.body.className} flex flex-col h-dvh" lang="${root_.lang}" dir="${root_.dir}">${sprite(symbols)}${clone.innerHTML}</div>`
return html.length <= maxBytes ? html : undefined
}
// Icons are <use href="#symbol"> into a sprite outside #root; copy only the symbols the shell uses.
function sprite(symbols: Set<string>) {
const defs = [...symbols]
.map((id) => document.getElementById(id))
.filter((element) => element instanceof SVGSymbolElement)
.map((element) => element.outerHTML)
if (!defs.length) return ""
return `<svg aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden">${defs.join("")}</svg>`
}
@@ -24,9 +24,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
}),
},
})
export const WindowSavePrepaint = Rpc.make("WindowSavePrepaint", {
payload: { html: Schema.String },
})
export const WindowRpcs = RpcGroup.make(
WindowThemeReady,
WindowGetFocused,
@@ -38,5 +35,4 @@ export const WindowRpcs = RpcGroup.make(
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
WindowSavePrepaint,
)
@@ -1,3 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const IpcTransportPortRequest = "desktop-rpc-port-request"
export const DragCancelEvent = "opencode:drag-cancel"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.8",
"version": "2.0.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.9",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.8",
"version": "2.0.9",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"scripts": {
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+16 -2
View File
@@ -17,6 +17,14 @@ export const Settings = Schema.StructWithRest(
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
export const ModelSettings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Model.Settings" })
export type ModelSettings = typeof ModelSettings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
@@ -25,6 +33,12 @@ export const Overlays = {
body: JsonRecord.pipe(optional),
}
const ModelOverlays = {
settings: ModelSettings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
headers: Overlays.headers,
body: Overlays.body,
@@ -57,11 +71,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...Overlays,
...ModelOverlays,
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
...ModelOverlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
+17 -2
View File
@@ -56,6 +56,21 @@ export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tok
})
export type MaxTokensField = typeof MaxTokensField.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
// Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy.
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Model.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
@@ -97,7 +112,7 @@ export const Cost = Schema.Struct({
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
export const Variant = Schema.Struct({
id: VariantID,
...Provider.Overlays,
...Overlays,
}).annotate({ identifier: "Model.Variant" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -110,7 +125,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
...Provider.Overlays,
...Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
time: Schema.Struct({
@@ -169,6 +169,7 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.ModelSettings,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
@@ -183,6 +184,7 @@ describe("contract hygiene", () => {
Model.Ref,
Model.Capabilities,
Model.Cost,
Model.Settings,
Model.Variant,
Project.Current,
Worktree.Directory,
@@ -239,11 +241,12 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "model.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(source).not.toContain("Schema.mutable")
})
+4 -7
View File
@@ -75,13 +75,10 @@ describe("Model.Info", () => {
})
})
describe("Model.Capabilities", () => {
test("decodes the optional transport setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
describe("Model.Settings", () => {
test("preserves provider-specific model options", () => {
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
providerOption: true,
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.9",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"license": "MIT",
+5 -5
View File
@@ -120,7 +120,7 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
if (status.attention) return theme.hue.interactive[800]
if (status.attention) return theme.hue.accent[200]
if (status.unread === "error") return theme.text.feedback.error.base
return undefined
}
@@ -528,8 +528,8 @@ function VerticalSessionTabs(props: {
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const unreadColor = () => theme.hue.accent[200]
const activeNumber = () => theme.hue.interactive[200]
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
@@ -1291,8 +1291,8 @@ function HorizontalSessionTabs(props: {
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const unreadColor = () => theme.hue.accent[200]
const activeNumber = () => theme.hue.interactive[200]
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
@@ -32,7 +32,7 @@ export default Plugin.define({
return (
<Show when={pending() > 0}>
<box flexShrink={0}>
<Spinner color={theme.hue.accent[800]}>/btw</Spinner>
<Spinner color={theme.hue.interactive[200]}>/btw</Spinner>
</box>
</Show>
)
@@ -181,7 +181,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.hue.accent[800]}
color={theme.hue.interactive[200]}
/>
</box>
)}
@@ -236,7 +236,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.hue.accent[800]}
color={theme.hue.interactive[200]}
/>
<text fg={theme.text.base}> esc stop</text>
</box>
@@ -284,10 +284,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ text: "", color: theme.text.base },
)
if (status.attention === "question")
lines.push({ text: "? Which approach should I take?", color: theme.hue.interactive[800] })
lines.push({ text: "? Which approach should I take?", color: theme.hue.accent[200] })
else if (status.attention === "permission")
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.interactive[800] })
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.accent[800] })
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.accent[200] })
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.interactive[200] })
else if (outcome === "failed")
lines.push({
text: `✗ bun run test failed — 3 tests failing in ${file}`,
+3 -3
View File
@@ -142,9 +142,9 @@ function map(
formfieldFocusedBg: exact(theme.background.formfield.focused),
formfieldFocusedText: exact(theme.text.formfield.focused),
selection: exact(theme.text.formfield.selected),
running: exact(theme.hue.accent[800]),
question: exact(theme.hue.interactive[800]),
permission: exact(theme.hue.interactive[800]),
running: exact(theme.hue.interactive[200]),
question: exact(theme.hue.accent[200]),
permission: exact(theme.hue.accent[200]),
success: exact(theme.text.feedback.success.base),
link: exact(theme.markdown.link),
categorical: dedupeWith(
@@ -139,7 +139,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
const glow = () => {
const colors = app
.captureSpans()
@@ -156,7 +156,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
expect(full).toBeGreaterThan(0)
setActive("first")
await app.renderOnce()
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
@@ -172,7 +172,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.base : theme.hue.interactive[800]).toInts(),
(unread === "error" ? theme.text.feedback.error.base : theme.hue.accent[200]).toInts(),
)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
+3 -3
View File
@@ -90,9 +90,9 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
formfieldFocusedBg: theme.background.formfield.focused,
formfieldFocusedText: theme.text.formfield.focused,
selection: theme.text.formfield.selected,
running: theme.hue.accent[800],
question: theme.hue.interactive[800],
permission: theme.hue.interactive[800],
running: theme.hue.interactive[200],
question: theme.hue.accent[200],
permission: theme.hue.accent[200],
success: theme.text.feedback.success.base,
link: theme.markdown.link,
shade: theme.background.raised.base,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.8",
"version": "2.0.9",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.8",
"version": "2.0.9",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.8",
"version": "2.0.9",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.8",
"version": "2.0.9",
"private": true,
"type": "module",
"scripts": {
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+1 -2
View File
@@ -506,7 +506,6 @@ headers, and model variants.
}
```
`settings.transport: "websocket"` on a provider or model selects its session
WebSocket; a model value overrides the provider value.
Provider `settings.transport: "websocket"` selects its session WebSocket.
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration.
+2 -6
View File
@@ -558,9 +558,6 @@ sessions.
"providers": {
"openai": {
"settings": { "transport": "http" },
"models": {
"gpt-5.5": { "settings": { "transport": "websocket" } },
},
},
},
}
@@ -568,9 +565,8 @@ sessions.
WebSocket behavior follows these rules:
- Built-in providers opt supported models in according to their own policy.
- `settings.transport: "websocket"` enables a provider or model; `"http"` disables it.
- A model value overrides its provider value.
- Built-in providers opt supported routes in according to their own policy.
- Provider `settings.transport: "websocket"` enables it; `"http"` disables it.
- `"websocket"` on a route without a WebSocket channel logs a warning and falls back to HTTP.
- OpenAI provider compaction uses the same connection.
- xAI continues from stored responses only. With its default `store: false`, each step is sent in full over the reused