Compare commits

..

2 Commits

Author SHA1 Message Date
LukeParkerDev e77baed670 wip(desktop): load the renderer into the early window before the layers
Measured interleaved against the window-only early start (5 runs):
renderer process 600 -> 259, first paint 814 -> 778, shell 1135 -> 1087,
idle 1676 -> 1578, but first main log line 457 -> 605 and service ready
578 -> 856: serving the renderer's chunks and the RenderProcessHost work
share the main thread with the bundle evaluation. Parked pending a
decision.
2026-09-19 10:26:30 +10:00
LukeParkerDev db4da867e1 perf(desktop): show the first window the moment Electron is ready
The entry module awaited the whole main bundle, which held back
Electron's ready event; windows were then created only after every
layer had built, hidden until the renderer had painted and reported its
theme. Users saw nothing for ~900 ms.

The entry is now a small module that configures the app, takes the
single-instance lock and registers the renderer scheme (all of which must
precede ready), then on ready creates and shows the first restored window
- bounds from its state file, background from the persisted theme colour
- and only then imports the rest of the main process, which adopts that
window in restoreWindows() and loads the renderer into it. Starting the
import before ready measured worse: the module graph evaluates on the
thread Chromium needs to finish initialising.

Configuration moves from an Effect in the lifecycle layer to a
synchronous configure.ts; the renderer scheme constants move to
scheme.ts; frame defaults shared by the early window and windowAppearance
live in defaults.ts. The bench reads the early window's shownAt from the
adoption log line.

Packaged build, warm service, interleaved with the v2 baseline, median
of 5: window visible 893 -> 251 ms, first paint 1065 -> 812 ms; the
first log line and the shell land ~65-100 ms later because the bundle now
starts after the window exists.
2026-09-19 10:07:19 +10:00
105 changed files with 470 additions and 989 deletions
+3
View File
@@ -87,6 +87,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -99,6 +100,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -205,6 +207,7 @@
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-W2QBi7/jtbvLp3Gj+nR49+wqVgCB0qfnQ30C0PQOjO4=",
"aarch64-linux": "sha256-R4YKo8JUvKd77QYehTNpj/65oodeLBQ8mdxLzBLrFdw=",
"aarch64-darwin": "sha256-7v2ABLG6zsIMSzsYu06BWEqbLL4HOeKyZFBx/gx87SI=",
"x86_64-darwin": "sha256-emKRoQILMzB5ko45dtMPuZGV2pAyszcwzKUh9ey9JZM="
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
}
}
+2
View File
@@ -46,6 +46,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -87,6 +88,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
@@ -36,24 +36,6 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
const image: Prompt = [
{ type: "text", content: "look", start: 0, end: 4 },
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
]
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
expect(entries).toHaveLength(2)
const untouched = removeHistoryEntry(entries, text("never sent"))
expect(untouched).toBe(entries)
const withoutComments = removeHistoryEntry(entries, image)
expect(withoutComments).toBe(entries)
const removed = removeHistoryEntry(entries, image, [comment("c1")])
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
@@ -48,18 +48,6 @@ export function prependHistoryEntry(
return [entry, ...entries].slice(0, max)
}
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
export function removeHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
) {
const entry = { prompt, comments } satisfies PromptHistoryEntry
const next = entries.filter((item) => !isPromptEqual(item, entry))
return next.length === entries.length ? entries : next
}
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
return (
commentA.path === commentB.path &&
@@ -4,7 +4,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
prependHistoryEntry,
removeHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
@@ -14,7 +13,6 @@ import { PromptHistoryState } from "../schema"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = typeof PromptHistoryState.Type
@@ -34,13 +32,6 @@ function createComposerHistoryStore(
if (next === current.entries) return
setCurrent("entries", next)
},
remove(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = removeHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
@@ -65,12 +56,5 @@ export function createComposerHistory() {
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.remove(saved, mode, metadata))
},
}
}
-1
View File
@@ -264,7 +264,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
+7 -8
View File
@@ -53,7 +53,6 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
) {
return createComposerSubmit({
adapter,
@@ -61,9 +60,7 @@ function submitInput(
commands,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
removeFromHistory: (prompt) =>
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
addToHistory() {},
resetHistory() {},
setMode() {},
closePopover() {},
@@ -536,8 +533,12 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const history: string[] = []
const submission = submitInput(adapter, notify, "normal", () => [], history)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -548,8 +549,6 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
})
test("forwards structured mentions to custom commands", async () => {
-3
View File
@@ -32,7 +32,6 @@ type ComposerSubmitInput = {
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
@@ -251,8 +250,6 @@ function restoreSubmission(
) {
const restored = submission.restore()
if (!restored) return false
// The prompt is back in the composer; its history entry would only keep attachments referenced.
input.removeFromHistory(value.prompt, value.mode, comments)
restored.target.set(restored.prompt, promptLength(restored.prompt))
restored.target.mode.set(value.mode)
restored.target.context.replaceComments(
+13 -12
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Button } from "@opencode/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
@@ -326,19 +327,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
// Calendar day in the local time zone, comparable as a number.
function localDay(date: Date) {
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = new Date()
const today = localDay(now)
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
const todaySessions = records.filter((record) => day(record) === today)
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
+16 -12
View File
@@ -1,4 +1,5 @@
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode/ui/context"
import { useProviders } from "@/providers/catalog/providers"
@@ -8,8 +9,6 @@ export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
const RECENT_LIMIT = 5
// luxon's diffNow().as("months") used an average month; keep the same window.
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
@@ -30,23 +29,27 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
),
)
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
const release = createMemo(
() =>
new Map(
available().map(
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
),
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => {
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
return Math.abs(Date.now() - released) < sixMonths
}),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -98,8 +101,9 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
// Models without a parseable release date stay visible.
return !Number.isFinite(release().get(key) ?? NaN)
const date = release().get(key)
if (!date?.isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
@@ -1,12 +1,6 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
const dateTime = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
@@ -20,7 +14,7 @@ export function createSessionContextFormatter(locale: string) {
},
time(value: number | undefined) {
if (!value) return "—"
return dateTime.format(value)
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
+1 -3
View File
@@ -131,9 +131,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}).pipe(
Effect.repeat({
until: Option.isSome,
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
// like the Promise variant rather than by attempt count.
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
}),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
+14 -18
View File
@@ -456,10 +456,6 @@ 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
@@ -1660,19 +1656,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
@@ -1902,7 +1898,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ModelSettings
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
@@ -2111,13 +2107,13 @@ export type ConfigEntry =
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigModelSettings
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigModelSettings
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
@@ -2228,7 +2224,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
state?: SessionMessageProviderState
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -3180,7 +3176,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3497,7 +3493,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3814,7 +3810,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
+3 -4
View File
@@ -1,5 +1,6 @@
export type EnsureTiming = {
readonly pollInterval: number
readonly attempts: number
readonly requestTimeout: number
readonly spawnDelay: number
readonly maxSpawnDelay: number
@@ -10,11 +11,9 @@ export type EnsureTiming = {
const timings = new WeakMap<object, EnsureTiming>()
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
// time a client waits for it. Probes are sequential, so the wait between them only matters while
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
export const defaultEnsureTiming: EnsureTiming = {
pollInterval: 25,
pollInterval: 100,
attempts: 1_200,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
+3 -3
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.state = undefined
existing.providerState = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.state = event.data.providerState
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.state = event.data.providerState
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
attempts: 120,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
+7 -3
View File
@@ -15,7 +15,8 @@ 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.
TypeScript-only syntax is rejected rather than stripped before execution.
- [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.
- [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
@@ -40,6 +41,8 @@ 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
@@ -491,8 +494,9 @@ 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. 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 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] 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.
+8 -1
View File
@@ -19,6 +19,12 @@
"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",
@@ -26,7 +32,8 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:"
"effect": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+7 -9
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,14 +41,12 @@ try {
]
}),
)
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)])),
]),
)
}
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`
+14 -1
View File
@@ -1,10 +1,14 @@
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"
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
return parse(code, {
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, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -0,0 +1,25 @@
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 }
}
@@ -0,0 +1,11 @@
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 })
+7 -13
View File
@@ -15,12 +15,6 @@ 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(
@@ -107,7 +101,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 19)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -115,13 +109,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 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
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({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 10)",
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 1, col 8)",
"(line 2, col 5)",
)
})
@@ -134,7 +128,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 4)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
@@ -153,7 +147,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 14)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
+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, RuntimeInfo } from "./model.js"
import type { ID, Info } 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: RuntimeInfo
readonly model: Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: RuntimeInfo
readonly model: Info
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: RuntimeInfo, pkg: string) {
function prepareOptions(model: Info, 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: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => 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: RuntimeInfo, language: LanguageModelV3) {
function modelFromLanguage(info: Info, 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: RuntimeInfo, packageName: string) {
function mapBodyToProviderOptions(model: Info, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
+16 -21
View File
@@ -8,7 +8,6 @@ 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"
@@ -118,9 +117,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 summary compaction. */
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Provider.Compaction
/** Provider transport policy; omitted means HTTP. */
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Provider.Transport
}
@@ -150,7 +149,7 @@ export const withVariant = (
variant
? {
...model,
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
settings: Provider.mergeOverlay(model.settings, variant.settings),
headers: Provider.mergeHeaders(model.headers, variant.headers),
body: Provider.mergeOverlay(model.body, variant.body),
}
@@ -160,11 +159,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
}
export const fromCatalogModel = (
model: RuntimeInfo,
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
@@ -192,7 +191,7 @@ export const fromCatalogModel = (
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: RuntimeInfo,
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
@@ -249,7 +248,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
})
})
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return {
...model,
@@ -261,7 +260,7 @@ function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value |
}
function validateProviderVariables(
model: RuntimeInfo,
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
@@ -271,7 +270,7 @@ function validateProviderVariables(
}
function prepareProviderSettings(
model: RuntimeInfo,
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
@@ -281,14 +280,14 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(model: Info, 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: RuntimeInfo, baseURL: string) {
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
@@ -311,14 +310,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
return { apiKey: credential.access }
}
const unsupported = (model: RuntimeInfo) =>
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
@@ -360,11 +359,7 @@ export const layer = Layer.effect(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const selectedVariant = yield* withVariant(selected, variant)
const runtimeInfo: RuntimeInfo = {
...selectedVariant,
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
}
const runtimeInfo = yield* withVariant(selected, variant)
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
@@ -387,7 +382,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: provider?.settings?.transport,
transport: runtimeInfo.settings?.transport,
}
})
return Service.of({
@@ -411,7 +406,7 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: RuntimeInfo) {
function hasConfiguredAuth(model: Info) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
+1 -7
View File
@@ -36,9 +36,6 @@ 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"
@@ -191,10 +188,7 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
-1
View File
@@ -453,7 +453,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+18 -15
View File
@@ -7,6 +7,7 @@ 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"
@@ -139,18 +140,16 @@ export const AzurePlugin = define({
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
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) }
: {}),
}
})
if (resourceName)
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
})
yield* ctx.model.transform((models) => {
@@ -168,6 +167,10 @@ 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",
})
})
}
}
@@ -239,9 +242,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
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)
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,13 +254,10 @@ export const OpenAIPlugin = define({
yield* ctx.provider.transform((providers) => {
const item = providers.get(Provider.ID.openai)
if (!item) return
const account = chatgpt?.metadata?.accountID
if (!chatgpt) return
const account = chatgpt.metadata?.accountID
providers.update(item.provider.id, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
...(chatgpt ? { baseURL: codexBaseURL } : {}),
})
if (!chatgpt) return
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
provider.headers = Provider.mergeHeaders(provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
@@ -273,6 +270,9 @@ 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 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
@@ -132,13 +131,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.as(
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
? snapshot.config
: undefined,
),
),
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
+7 -6
View File
@@ -95,13 +95,14 @@ 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.provider.transform((providers) => {
if (!providers.get(providerID)) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
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",
})
})
})
}
})
}),
})
+12 -5
View File
@@ -1,6 +1,7 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { ToolEditor } from "@opencode/plugin/effect/tool"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
@@ -20,19 +21,25 @@ const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: (ctx) =>
ctx.tool.hook("execute.before", (event) =>
Effect.gen(function* () {
effect: Effect.fn(function* (ctx) {
let get: ToolEditor["get"] = () => undefined
yield* ctx.tool.transform((draft) => {
// The draft sees later tool transforms too; reload replaces this lookup.
get = draft.get
})
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
const tool = get(event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
),
)
}),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
-5
View File
@@ -134,16 +134,11 @@ 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,
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+3 -3
View File
@@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.state = undefined
draft.providerState = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.state = castDraft(event.data.providerState)
draft.providerState = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.state = castDraft(event.data.providerState)
draft.providerState = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+1 -3
View File
@@ -107,9 +107,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
-2
View File
@@ -39,7 +39,6 @@ type Data = {
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -221,7 +220,6 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
list: () => Effect.sync(() => Array.from(state.get().tools.values())),
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
+4 -5
View File
@@ -27,15 +27,14 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
...Model.Info.make({
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
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",
+1 -2
View File
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
}),
)
it.effect("keeps settings scoped while resolving request overlay merges", () =>
it.effect("resolves provider and model overlay merges", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -533,7 +533,6 @@ 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 })
+11 -19
View File
@@ -37,7 +37,6 @@ 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",
@@ -64,7 +63,6 @@ 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)
@@ -75,9 +73,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("keeps the provider websocket policy out of model settings", () =>
it.effect("inherits the provider websocket policy with model overrides", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* addPlugin([
new Document({
@@ -86,12 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
models: {
inherited: {
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
},
},
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -99,9 +92,10 @@ 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((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
expect(inherited.settings).toEqual({ shared: "provider", model: true })
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -112,7 +106,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(`configured provider transport overrides ${builtin.id} defaults`, () =>
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -128,8 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -138,16 +131,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model } },
models: { override: { modelID: builtin.model, settings: { transport: "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()
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
}),
)
}
-1
View File
@@ -72,7 +72,6 @@ export const registerToolPlugin = <R>(
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
-1
View File
@@ -133,7 +133,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
-1
View File
@@ -73,7 +73,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
),
)
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
@@ -472,8 +472,9 @@ describe("AzurePlugin", () => {
yield* addPlugin()
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 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]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
}
@@ -205,8 +205,7 @@ 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(provider.settings?.transport).toBe("websocket")
expect(model.settings?.transport).toBeUndefined()
expect(model.settings?.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -550,15 +550,14 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes hosted search with Console config, retains it on failure, and skips unchanged snapshots", () =>
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, failing: false, requests: 0 }
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
if (state.failing) return new Response("Unavailable", { status: 502 })
return Response.json({
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
@@ -608,18 +607,10 @@ describe("OpencodePlugin", () => {
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.failing = true
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.failing = false
state.advertised = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(5)
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
expect(yield* websearch.providers()).toEqual([])
expect(yield* websearch.default()).toBeUndefined()
@@ -83,10 +83,8 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const provider = yield* providers.get(providerID)
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(provider?.settings?.transport).toBe("websocket")
expect(model?.settings?.transport).toBeUndefined()
expect(model?.settings?.transport).toBe("websocket")
}),
)
})
@@ -22,19 +22,30 @@ function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
list: () => Effect.succeed([tool]),
transform: (callback) =>
Effect.sync(() => {
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
callback({
list: () => [tool],
get: (id) => (id === tool.id ? tool : undefined),
add: () => {},
namespace: () => {},
update: () => {},
remove: () => {},
})
return { dispose: Effect.void }
}),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
-15
View File
@@ -42,19 +42,4 @@ 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",
})
})
})
+2 -2
View File
@@ -690,7 +690,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "stop",
rawFinish: "stop_sequence",
state: { response: "ended" },
providerState: { response: "ended" },
cost: Money.USD.make(1),
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
@@ -700,7 +700,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "blocked",
state: { response: "failed" },
providerState: { response: "failed" },
error: { type: "provider.invalid-request", message: "Failed" },
snapshot: { end: "snap_failed", files: ["src/failed.ts"] },
time: { completed: created },
+2 -2
View File
@@ -4953,7 +4953,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
state: { responseId: "response-1", serviceTier: "priority" },
providerState: { responseId: "response-1", serviceTier: "priority" },
content: [Expected.text("Complete")],
},
])
@@ -4984,7 +4984,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
state: {
providerState: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
-15
View File
@@ -855,21 +855,6 @@ describe("Tool", () => {
])
}),
)
it.effect("lists registered tools by effective name", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" })
expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([
["echo", "echo"],
["acme_count", "count"],
])
yield* service.transform((editor) => editor.remove("echo"))
expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"])
}),
)
;[
{ name: "string", content: "hooked", text: "hooked" },
{ name: "empty string", content: "", text: "" },
@@ -91,11 +91,6 @@ for (const channel of channels) {
"js-yaml/dist/js-yaml.min.js",
"js-yaml/dist/js-yaml.mjs.map",
"js-yaml/bin/js-yaml.js",
"unrelated/dist/index.js.map",
"unrelated/dist/index.cjs.map",
"unrelated/dist/index.d.ts",
"unrelated/dist/index.d.cts",
"unrelated/dist/index.d.ts.map",
]) {
expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false)
}
@@ -117,10 +112,7 @@ for (const channel of channels) {
"js-yaml/lib/loader.js",
"js-yaml/dist/js-yaml.mjs",
"debug/src/index.js",
"unrelated/dist/index.js",
"unrelated/dist/index.cjs",
"unrelated/dist/data.json",
"unrelated/src/index.ts",
"unrelated/dist/index.js.map",
...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap(
(name) => [`${name}/package.json`, `${name}/LICENSE`],
),
+3 -6
View File
@@ -77,14 +77,11 @@ const getBase = (appId: string): Configuration => ({
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
// Nothing executes type declarations or source maps, and every entry costs startup time: the
// main process parses the whole asar header before it runs any JavaScript.
"!**/node_modules/**/*.d.{ts,cts,mts}",
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
"!**/node_modules/**/*.{js,cjs,mjs}.map",
// These packages execute compiled JavaScript, not their sources.
// These packages execute compiled JavaScript, not their sources or source maps.
"!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map",
"!**/node_modules/ajv/lib{,/**/*}",
"!**/node_modules/ajv-formats/src{,/**/*}",
"!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map",
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
"!**/node_modules/js-yaml/bin{,/**/*}",
+3 -15
View File
@@ -175,12 +175,8 @@ const summaries = Object.fromEntries(
const phaseOrder = [
["electron native init", "processCreated", "nodeStart"],
["node bootstrap", "nodeStart", "nodeBootstrapped"],
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["layers → renderer process", "appStarting", "rendererProcess"],
["electron js init + main bundle", "nodeBootstrapped", "appStarting"],
["main layers → window created", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
["shell → idle", "shellVisible", "rendererIdle"],
@@ -372,9 +368,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
processCreated: boot && Math.round(boot.created - spawnAt),
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
@@ -532,8 +525,6 @@ function mainLog() {
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
const timeline: [number, string, string][] = []
let windowShownAt: number | undefined
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
const marks: Record<string, number> = {}
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
const text = readFileSync(join(dir!, name), "utf8")
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
@@ -545,8 +536,6 @@ function mainLog() {
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting/.test(message))
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
@@ -559,8 +548,7 @@ function mainLog() {
versionDone: at(/v2 CLI command completed/),
serviceStarting: at(/v2 CLI background service starting/),
serviceReady: at(/background service ready/),
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
marks,
windowVisible: windowShownAt ?? at(/main window visible/),
}
}
-6
View File
@@ -3,21 +3,15 @@ import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
import { marks } from "./lifecycle/marks"
// Everything above has been loaded and evaluated by now; the layers start below.
marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
-4
View File
@@ -1,5 +1,3 @@
// Imported first so its evaluation stamps the moment Electron handed control to this module.
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { createEarlyWindow } from "./windows/early"
@@ -15,9 +13,7 @@ if (acquireApplicationLock()) {
// Window first, then the bundle: starting the import before ready delays ready itself, because the
// module graph evaluates on the same thread Chromium needs to finish initialising.
void app.whenReady().then(() => {
marks.ready = Date.now()
createEarlyWindow()
marks.window = Date.now()
return import("./desktop")
})
}
+6 -2
View File
@@ -65,12 +65,16 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
win.webContents.on("did-finish-load", () => {
const connect = () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
win.webContents.on("did-finish-load", connect)
// The first window starts loading before this layer exists; a renderer that has already finished
// loading is waiting for its port right now.
if (!win.webContents.isLoading() && win.webContents.getURL()) connect()
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -1,18 +1,12 @@
import http from "node:http"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon } from "../windows"
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
export const installContextMenu = Effect.gen(function* () {
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
})
export const prepareApplicationEnvironment = Effect.gen(function* () {
yield* loadSystemCertificates
yield* loadProxyEnvironment
@@ -1,4 +0,0 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports them with "app starting" so the startup benchmark can split the time before the
// first log line into Electron's own initialisation, our entry, and the main bundle.
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
@@ -5,7 +5,6 @@ import { app, crashReporter, netLog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
import { marks } from "../lifecycle/marks"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
@@ -35,7 +34,6 @@ const serviceLayer = Layer.effect(
version: VERSION,
packaged: app.isPackaged,
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
marks,
})
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
return Service.of({
@@ -1,57 +1,15 @@
import { expect, test } from "bun:test"
import { Deferred, Effect, Fiber } from "effect"
import { Effect } from "effect"
import { BackgroundServiceState } from "./background-service-state"
test("new consumers receive the latest reconnected service", async () => {
const initial = { url: "http://127.0.0.1:4100", password: "first" }
const replacement = { url: "http://127.0.0.1:4200", password: "second" }
const service = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* BackgroundServiceState.make({
initial: Effect.succeed(initial),
reconnect: Effect.succeed(replacement),
})
expect(yield* state.connection).toEqual(initial)
expect(yield* state.reconnect).toEqual(replacement)
return yield* state.connection
}),
),
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
)
expect(service).toEqual(replacement)
})
test("the state is ready before the initial connect finishes and consumers wait for it", async () => {
const initial = { url: "http://127.0.0.1:4100", password: "first" }
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const state = yield* BackgroundServiceState.make({
initial: Deferred.await(gate).pipe(Effect.as(initial)),
reconnect: Effect.succeed(initial),
})
const consumer = yield* Effect.forkChild(state.connection)
yield* Effect.sleep("10 millis")
expect(consumer.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(consumer)).toEqual(initial)
}),
),
)
})
test("a failed initial connect fails consumers instead of hanging them", async () => {
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* BackgroundServiceState.make({
initial: Effect.fail(new Error("service unavailable")),
reconnect: Effect.succeed({ url: "http://127.0.0.1:4100", password: "later" }),
})
return yield* state.connection.pipe(Effect.exit)
}),
),
)
expect(result._tag).toBe("Failure")
expect(await Effect.runPromise(service.connection)).toEqual(initial)
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
})
@@ -1,30 +1,16 @@
export * as BackgroundServiceState from "./background-service-state"
import { Deferred, Effect, Exit, Ref } from "effect"
import { Effect, Exit, Ref } from "effect"
import type { SidecarCredentials } from "./sidecar-credentials"
// The initial connect runs in the background: windows are created as soon as the main process
// is up, and the renderer loads its bundle while the CLI service is being resolved. Consumers
// await the first outcome through `connection`, so nothing observes the service before it exists.
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
}) {
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown> | undefined>(undefined)
const first = yield* Deferred.make<void>()
yield* options.initial.pipe(
Effect.exit,
Effect.flatMap((exit) => Ref.set(current, exit)),
Effect.ensuring(Deferred.succeed(first, undefined)),
Effect.forkScoped,
)
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
return {
connection: Deferred.await(first).pipe(
Effect.flatMap(() => Ref.get(current)),
Effect.flatMap((exit) => exit ?? Exit.die(new Error("background service connect did not resolve"))),
Effect.orDie,
),
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
}
})
@@ -17,29 +17,17 @@ describe("draft store", () => {
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
})
test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => {
test("stores blobs by content hash and collects unreferenced ones on open", () => {
const database = openDatabase(":memory:")
let clock = 0
const first = createDraftStore(database.db, { delay: 1_000, now: () => clock })
const first = createDraftStore(database.db, { delay: 1_000 })
const used = first.putBlob(new Uint8Array([1, 2, 3]))
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
first.flush()
first.close()
// The startup collection runs off the window's critical path and keeps the usual grace, so a
// blob from a session that ended within the grace period is still there...
const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6]))
second.close()
// ...and gone once the grace has passed.
clock = blobGrace + 1
const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(third.getBlob(unused)).toBeNull()
third.close()
const second = createDraftStore(database.db, { delay: 1_000 })
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(second.getBlob(unused)).toBeNull()
})
test("collects a retired chunk only once it is unreferenced and past the grace period", () => {
+4 -10
View File
@@ -27,14 +27,11 @@ const referenced = (value: unknown) => sql`
export function createDraftStore(
db: Database,
input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
) {
const now = input.now ?? Date.now
// Orphans left by an earlier session are collected once the window is up rather than before it:
// the scan walks every stored document, and the usual grace keeps anything a renderer has
// uploaded in the meantime.
const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000)
startup.unref()
// Nothing outside this process can hold a blob id at startup, so no grace applies.
collectBlobs(db, Infinity)
let collected = now()
let orphans = false
const byKey = eq(document.key, sql.placeholder("key"))
@@ -107,10 +104,7 @@ export function createDraftStore(
return data ? (data as Uint8Array<ArrayBuffer>) : null
},
flush: writer.flush,
close() {
clearTimeout(startup)
writer.close()
},
close: writer.close,
}
}
@@ -49,10 +49,8 @@ export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
}
export function setBackgroundColor(color: string) {
// The renderer reports its theme background on every boot; electron-store rewrites and fsyncs the
// settings file on each set, so only persist a change.
if (getBackgroundColor() !== color) getStore().set(BACKGROUND_COLOR_KEY, color)
backgroundColor = color
getStore().set(BACKGROUND_COLOR_KEY, color)
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
+17 -4
View File
@@ -1,11 +1,14 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen } from "electron"
import { app, BrowserWindow, screen, shell } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { resolveExternalURL } from "../files/external-url"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { handleRendererProtocol, loadWindow } from "./scheme"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
@@ -19,9 +22,11 @@ const displays = {
}
// Creates and shows the first restored window the moment Electron is ready, before the rest of the
// main process has loaded. The frame options mirror windowAppearance(); the persisted background
// colour stands in for the theme until the renderer applies it, so the window is on screen while the
// bundle, the layers and the renderer boot. restoreWindows() adopts it through takeEarlyWindow().
// main process has loaded, and starts loading the renderer into it so the renderer process boots in
// parallel with the main bundle and its layers. The frame options mirror windowAppearance(); the
// persisted background colour stands in for the theme until the renderer applies it. The renderer's
// IPC connection waits for a port the IPC layer hands over once it exists. restoreWindows() adopts the
// window through takeEarlyWindow().
export function createEarlyWindow() {
const ids = getStore().get(WINDOW_IDS_KEY)
const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID()
@@ -50,6 +55,14 @@ export function createEarlyWindow() {
},
})
manageWindowState(win, file, state, displays)
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
})
wireRendererHeaders(win)
handleRendererProtocol(path.join(root, "../renderer"))
loadWindow(win, "index.html")
// Closing the only window before the rest of the app has adopted it is a quit.
win.once("closed", () => {
if (pending?.win !== win) return
+8 -5
View File
@@ -119,14 +119,17 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
},
})
allowRendererPermissions(win)
// The early window arrives with its state, security wiring and renderer load already done.
if (!early) {
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
manageWindowState(win, stateFile, state, displays)
}
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
if (!early) loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
+10 -70
View File
@@ -1,78 +1,18 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { Effect } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
import { handleRendererProtocol, setRendererProtocolReporter } from "./scheme"
export { isRendererUrl, loadWindow } from "./scheme"
// The entry module usually installs the handler before this runs; either way, route its diagnostics
// through the application log from here on.
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>())
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 })
}
setRendererProtocolReporter({
warn: (message, data) => runFork(scoped("protocol", Effect.logWarning(message, data))),
error: (message, data) => runFork(scoped("protocol", Effect.logError(message, data))),
})
handleRendererProtocol(paths.rendererRoot)
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
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 })
}
+77 -3
View File
@@ -1,8 +1,30 @@
import { protocol } from "electron"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
// The renderer scheme, its file handler and the window loader, kept free of Effect and logging so
// the entry module can register the scheme before ready and load the first window before the rest
// of the main process exists. Diagnostics go through a reporter the logging layer installs later.
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
type Reporter = {
warn: (message: string, data: Record<string, unknown>) => void
error: (message: string, data: Record<string, unknown>) => void
}
let report: Reporter = {
warn: (message, data) => console.warn(message, data),
error: (message, data) => console.error(message, data),
}
export function setRendererProtocolReporter(reporter: Reporter) {
report = reporter
}
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
// before it loads anything else.
export function registerRendererScheme() {
@@ -14,9 +36,61 @@ export function registerRendererScheme() {
standard: true,
supportFetchAPI: true,
stream: true,
// Let Chromium keep V8 bytecode for the renderer bundle between launches.
codeCache: true,
},
},
])
}
export function handleRendererProtocol(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report.warn("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)) {
report.warn("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) {
report.error("fetch failed", { url: request.url, file, status: response.status, statusText: response.statusText })
}
return addDocumentPolicy(response, file)
} catch (error) {
report.error("fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
})
}
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
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 })
}
@@ -1,7 +1,7 @@
import type { BrowserWindow } from "electron"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./protocol"
import { isRendererUrl } from "./scheme"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
+2 -8
View File
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode/app/wsl/types"
import { Effect, FileSystem, Path } from "effect"
import { nativeT } from "../native/translations"
@@ -116,14 +117,7 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
})
}
async function runInteractiveCommand(
command: string,
args: string[],
opts: RunWslOptions = {},
defaultTimeoutMs: number,
) {
// The native addon is only needed for interactive installs; loading it here keeps it out of startup.
const pty = await import("@lydell/node-pty")
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
+2 -8
View File
@@ -159,14 +159,8 @@ export const Operations = [
),
operation(
"tabs.open",
"Open a browser tab, show it in the Review pane, and select it. Defaults to about:blank. Website traffic uses the connected server's network; localhost reaches that server.",
{
url: optional(short),
focus: optional(Schema.Boolean).annotate({
description:
"Default true: open the Review pane and select the new tab so the user sees it. Pass false only when the user asked for the tab to stay in the background.",
}),
},
"Open a browser tab. Defaults to about:blank and focused. Website traffic uses the connected server's network; localhost reaches that server.",
{ url: optional(short), focus: optional(Schema.Boolean) },
Tab,
),
operation(
-2
View File
@@ -54,7 +54,5 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
export interface ToolDomain {
readonly transform: Transform<ToolEditor>
readonly reload: () => Effect.Effect<void>
/** Currently registered tools, after every transform, keyed by effective name. */
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
-4
View File
@@ -462,10 +462,6 @@ export function fromPromise(plugin: Plugin) {
},
tool: {
reload: () => run(host.tool.reload()),
list: () =>
run(host.tool.list()).then((tools) =>
tools.map((tool) => ({ ...tool, execute: promiseExecutor(tool.execute) })),
),
transform: (callback) =>
register(
host.tool.transform((editor) =>
-2
View File
@@ -65,7 +65,5 @@ interface ToolHooks {
export interface ToolDomain {
readonly transform: Transform<ToolEditor>
readonly reload: () => Promise<void>
/** Currently registered tools, after every transform, keyed by effective name. */
readonly list: () => Promise<readonly (Info & { readonly id: string })[]>
readonly hook: Hooks<ToolHooks>
}
+4 -39
View File
@@ -13251,27 +13251,6 @@
"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": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"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": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
-12
View File
@@ -340,17 +340,6 @@ export namespace Frontend {
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
export type Color = Schema.Schema.Type<typeof Color>
export const CapturedImage = Schema.Struct({
x: Schema.Number,
y: Schema.Number,
width: Schema.Number,
height: Schema.Number,
pixelWidth: Schema.Number,
pixelHeight: Schema.Number,
rgba: Schema.String.check(Schema.isBase64()),
})
export interface CapturedImage extends Schema.Schema.Type<typeof CapturedImage> {}
export const CapturedFrame = Schema.Struct({
cols: Schema.Number,
rows: Schema.Number,
@@ -368,7 +357,6 @@ export namespace Frontend {
),
}),
),
images: Schema.optionalKey(Schema.Array(CapturedImage)),
})
export interface CapturedFrame extends Schema.Schema.Type<typeof CapturedFrame> {}
+2 -16
View File
@@ -17,14 +17,6 @@ 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 = {
@@ -33,12 +25,6 @@ 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,
@@ -71,11 +57,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...ModelOverlays,
...Overlays,
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...ModelOverlays,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
+2 -17
View File
@@ -56,21 +56,6 @@ 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),
@@ -112,7 +97,7 @@ export const Cost = Schema.Struct({
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
export const Variant = Schema.Struct({
id: VariantID,
...Overlays,
...Provider.Overlays,
}).annotate({ identifier: "Model.Variant" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -125,7 +110,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
...Overlays,
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
time: Schema.Struct({
+2 -10
View File
@@ -1,6 +1,6 @@
export * as SessionMessage from "./session-message.js"
import { Predicate, Schema } from "effect"
import { Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
@@ -222,7 +222,7 @@ export const Assistant = Schema.Struct({
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
state: ProviderState.pipe(optional),
providerState: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -318,11 +318,3 @@ export type Info =
| Compaction
| Idle
export type Type = Info["type"]
/** Reads assistant rows stored before `providerState` was renamed to `state`. */
export function persisted(input: unknown) {
if (!Predicate.isObject(input) || input.type !== "assistant" || "state" in input || !("providerState" in input))
return input
const { providerState, ...rest } = input
return { ...rest, state: providerState }
}
@@ -169,7 +169,6 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.ModelSettings,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
@@ -184,7 +183,6 @@ describe("contract hygiene", () => {
Model.Ref,
Model.Capabilities,
Model.Cost,
Model.Settings,
Model.Variant,
Project.Current,
Worktree.Directory,
@@ -241,12 +239,11 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.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")
})
+7 -4
View File
@@ -75,10 +75,13 @@ describe("Model.Info", () => {
})
})
describe("Model.Settings", () => {
test("preserves provider-specific model options", () => {
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
providerOption: true,
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",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+1 -10
View File
@@ -23,23 +23,14 @@ test("assistant terminal diagnostics remain optional and round trip", () => {
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
state: { promptFeedback: { blockReason: "SAFETY" } },
providerState: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
state: { promptFeedback: { blockReason: "SAFETY" } },
})
const legacy = SessionMessage.persisted({
...assistant,
providerState: { promptFeedback: { blockReason: "SAFETY" } },
})
expect(decode(legacy).state).toEqual({ promptFeedback: { blockReason: "SAFETY" } })
expect(encode(decode(legacy))).toEqual({
...assistant,
state: { promptFeedback: { blockReason: "SAFETY" } },
})
})
test("failed steps only override the assistant finish for content filters", () => {
+2 -48
View File
@@ -1,4 +1,4 @@
import { ImageRenderable, type CliRenderer, type Renderable } from "@opentui/core"
import type { CliRenderer, Renderable } from "@opentui/core"
import {
createMockKeys,
createMockMouse,
@@ -7,7 +7,7 @@ import {
type MockInput,
type MockMouse,
} from "@opentui/core/testing"
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer"
import { SimulationSemantics } from "./semantics"
@@ -165,55 +165,9 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness
width: span.width,
})),
})),
images: captureImages(harness.renderer),
} satisfies SimulationProtocol.Frontend.CapturedFrame
})
function captureImages(renderer: CliRenderer): SimulationProtocol.Frontend.CapturedImage[] {
return all(renderer.root).flatMap((renderable) => {
if (!(renderable instanceof ImageRenderable) || !renderable.visible || renderable.isDestroyed || !renderable.image)
return []
const fitted = renderable.fit === "cover"
? { width: renderable.width, height: renderable.height }
: renderable.getFittedSize(renderable.width, renderable.height)
if (fitted.width <= 0 || fitted.height <= 0) return []
const x = renderable.screenX + Math.floor((renderable.width - fitted.width) / 2)
const y = renderable.screenY + Math.floor((renderable.height - fitted.height) / 2)
let source = renderable.image
let extracted: typeof source | undefined
if (renderable.fit === "cover") {
const targetAspect = renderable.width / (renderable.height * renderable.cellAspectRatio)
const sourceAspect = source.width / source.height
const width = sourceAspect > targetAspect ? Math.max(1, Math.round(source.height * targetAspect)) : source.width
const height = sourceAspect > targetAspect ? source.height : Math.max(1, Math.round(source.width / targetAspect))
extracted = source.extract({
left: Math.floor((source.width - width) / 2),
top: Math.floor((source.height - height) / 2),
width,
height,
})
source = extracted
}
try {
const raw = source.raw("rgba8")
const rgba = new Uint8Array(raw.width * raw.height * 4)
for (let row = 0; row < raw.height; row++)
rgba.set(raw.data.subarray(row * raw.stride, row * raw.stride + raw.width * 4), row * raw.width * 4)
return [{
x,
y,
width: fitted.width,
height: fitted.height,
pixelWidth: raw.width,
pixelHeight: raw.height,
rgba: Encoding.encodeBase64(rgba),
}]
} finally {
extracted?.dispose()
}
})
}
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
switch (action.type) {
case "ui.type":
+2 -38
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { BoxRenderable, ImageRenderable, NativeImage, TextRenderable } from "@opentui/core"
import { BoxRenderable, TextRenderable } from "@opentui/core"
import { Effect } from "effect"
import { capture, createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer"
import { SimulationSemantics } from "../src/frontend/semantics"
@@ -74,42 +74,6 @@ test("headless input mirrors the configured kitty keyboard protocol", async () =
)
})
test("captures image pixels and their terminal placement", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({ width: 20, height: 10 })
const image = NativeImage.fromRgba(
Uint8Array.from([255, 0, 0, 255, 0, 0, 255, 255]),
2,
1,
)
renderer.root.add(new ImageRenderable(renderer, {
source: image,
position: "absolute",
left: 2,
top: 1,
width: 2,
height: 1,
fit: "fill",
}))
const frame = yield* capture(createHarness(renderer))
expect(frame.images).toEqual([{
x: 2,
y: 1,
width: 2,
height: 1,
pixelWidth: 2,
pixelHeight: 1,
rgba: "/wAA/wAA//8=",
}])
}),
),
)
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
+7 -11
View File
@@ -1,4 +1,4 @@
import { createEffect, mergeProps, on, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
import { createEffect, mergeProps, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createStore } from "solid-js/store"
@@ -236,22 +236,18 @@ export function ScrollView(props: ScrollViewProps) {
local.viewportRef(viewportRef)
}
// The observer reports every target once when it starts observing (including a thumb mount that
// appears later), after layout. Measuring here as well would force a synchronous layout per
// ScrollView while the page is still rendering.
createResizeObserver(
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
updateThumb,
)
updateThumb()
})
// Inputs that change the thumb without resizing anything: re-measure on change, but leave the
// first measurement to the observer.
createEffect(
on([() => local.verticalScrollAdjustment, vertical, horizontal, thumbMount], () => updateThumb(), {
defer: true,
}),
)
createEffect(() => {
thumbMount()
updateThumb()
})
createEffect(() => {
if (!horizontal() || !viewportRef) return
@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { boundToastDescription, toastDescriptionLimit } from "./description"
describe("toast description bound", () => {
test("keeps ordinary descriptions and the options object", () => {
const options = { title: "Failed", description: "Attachment exceeds the limit" }
expect(boundToastDescription(options)).toBe(options)
expect(boundToastDescription({ description: undefined })).toEqual({ description: undefined })
})
test("cuts a description that embeds a whole data URL", () => {
const description = `Attachment exceeds the limit: data:text/plain;base64,${"Q".repeat(50 * 1024 * 1024)}`
const bounded = boundToastDescription({ title: "Failed to send prompt", description })
expect(bounded.description!.length).toBe(toastDescriptionLimit + 1)
expect(bounded.description!.endsWith("…")).toBe(true)
expect(bounded.description!.startsWith("Attachment exceeds the limit: data:text/plain;base64,")).toBe(true)
expect(bounded.title).toBe("Failed to send prompt")
})
})
@@ -1,9 +0,0 @@
// A toast shows a few lines; the rest of a long description would only be retained (in the dedupe
// key, the active-toast registry and the toaster's store) for as long as the toast lives. Server
// errors have carried a whole data URL here.
export const toastDescriptionLimit = 2000
export function boundToastDescription<T extends { description?: string }>(options: T): T {
if (!options.description || options.description.length <= toastDescriptionLimit) return options
return { ...options, description: `${options.description.slice(0, toastDescriptionLimit)}…` }
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { Toaster, toast, type ToasterProps } from "solid-sonner"
import { boundToastDescription } from "./description"
import { isRTL } from "@kobalte/core/i18n"
import type { ComponentProps, JSX } from "solid-js"
import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js"
@@ -206,7 +205,7 @@ const activeToastByKey = new Map<string, ActiveToast>()
const activeToastById = new Map<number, ActiveToast>()
export function showToast(options: ToastOptions | string) {
const opts = boundToastDescription(typeof options === "string" ? { description: options } : options)
const opts: ToastOptions = typeof options === "string" ? { description: options } : options
const key = JSON.stringify({
title: opts.title,
description: opts.description,
-1
View File
@@ -296,7 +296,6 @@ export default defineConfig({
],
components: {
Hero: "./src/components/Hero.astro",
Banner: "./src/components/Banner.astro",
Head: "./src/components/Head.astro",
Header: "./src/components/Header.astro",
Footer: "./src/components/Footer.astro",
-61
View File
@@ -1,61 +0,0 @@
<a class="v2-banner" href="https://opencode.ai/v2">
<span class="badge">New</span>
<span class="text">OpenCode v2 is now available</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</a>
<style>
.v2-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 24px;
background: var(--color-background-interactive);
color: var(--color-text-strong) !important;
border-bottom: 1px solid var(--color-border-weak);
text-decoration: none;
font-size: 14px;
line-height: 1;
transition: background 0.15s ease;
}
.v2-banner:hover {
background: var(--color-background-interactive-weaker);
}
.badge {
padding: 4px 8px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
border-radius: 2px;
}
.text {
font-weight: 500;
}
.arrow {
color: var(--color-text);
transition: transform 0.15s ease;
}
.v2-banner:hover .arrow {
transform: translateX(4px);
}
@media (max-width: 30rem) {
.v2-banner {
padding: 10px 16px;
gap: 8px;
}
.badge {
display: none;
}
}
</style>
+4 -39
View File
@@ -13251,27 +13251,6 @@
"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": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"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": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
+4 -39
View File
@@ -13251,27 +13251,6 @@
"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": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"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": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
-2
View File
@@ -1,7 +1,6 @@
import path from "node:path"
const root = path.resolve(import.meta.dir, "../dist/client")
const externalRoutes = new Set(["/docs"])
const files = await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: root, absolute: true }))
const failures = (
await Promise.all(
@@ -12,7 +11,6 @@ const failures = (
Array.from(html.matchAll(/href="([^"]+)"/g), async (match) => {
const url = new URL(match[1], page)
if (url.origin !== "https://opencode.local") return
if (externalRoutes.has(url.pathname)) return
const targetPath = path.join(root, decodeURIComponent(url.pathname))
const target = (

Some files were not shown because too many files have changed in this diff Show More