Compare commits

..
Author SHA1 Message Date
rekram1-node 2fa63c521d feat(core): announce top-level tool availability changes
Track the direct tool names shown to the model as an instruction source so
later requests announce only what was added or removed, phrased like the
Code Mode catalog updates. The request's own tool list is the baseline, so
instruction sources may now omit an initial render and nothing is said until
the set changes.
2026-09-19 20:30:50 -05:00
51 changed files with 1159 additions and 1988 deletions
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { Codec } from "./codec"
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
test("struct types infer optional and required fields", () => {
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
const value: typeof s.Type = { id: "x" }
const tab: string | undefined = value.tab
const n: number | undefined = value.n
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
const out: string | Codec.Invalid = t.decode({ id: "a" })
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
const empty: typeof onlyOptional.Type = {}
const maybe: string | undefined = empty.tab
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
})
@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "./codec"
describe("Codec", () => {
test("primitives reject the wrong shape and finite numbers only", () => {
expect(Codec.string.decode("a")).toBe("a")
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
expect(Codec.number.decode(1.5)).toBe(1.5)
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
expect(Codec.literal("x").decode("x")).toBe("x")
})
test("struct keeps optional fields absent and rejects invalid required ones", () => {
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
expect(codec.decode([])).toBe(Codec.INVALID)
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
const value: typeof codec.Type = { id: "1", title: undefined }
expect(value.id).toBe("1")
})
test("lenient collections recover what they can", () => {
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
expect(items.decode("nope")).toEqual([])
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
const map = Codec.lenientRecord(Codec.boolean)
expect(map.decode({ a: true, b: "x" })).toEqual({})
expect(map.decode({ a: true })).toEqual({ a: true })
})
test("union, transform and fallback compose", () => {
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
const tab = Codec.union([session, draft])
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
expect(upper.decode("ab")).toBe("AB")
expect(upper.encode("AB")).toBe("ab")
const safe = Codec.fallback(Codec.number, () => 7)
expect(safe.decode("x")).toBe(7)
expect(safe.decode(undefined)).toBe(7)
expect(safe.decode(2)).toBe(2)
})
test("brand constructs and decodes as its base", () => {
const Key = Codec.brand<"ServerConnection.Key">()
const key = Key.make("http://a")
expect(Key.decode(key)).toBe(key)
expect(Key.decode(3)).toBe(Codec.INVALID)
})
test("withInitial recovers field by field and merges new defaults", () => {
const layout = Codec.struct({
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
})
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
sidebar: { opened: false, width: 240 },
theme: "dark",
})
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
expect(codec.decode("{not json")).toBe(Codec.INVALID)
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
})
test("migrate reads the old shape first", () => {
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
const read = Codec.transform(previous, {
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
encode: (value) => ({ tab: value.tabs[0] }),
})
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
})
})
@@ -1,325 +0,0 @@
export * as Codec from "./codec"
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
// graph, where Effect's own module initialisation was the single largest startup cost that was not
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
// value that cannot be recovered, and the lenient combinators recover what they can.
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
export type Invalid = typeof INVALID
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
export interface Of<T, E = unknown> {
readonly [tag]: true
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
readonly Type: T
readonly Encoded: E
readonly optional?: boolean
decode(input: unknown): T | Invalid
encode(value: T): E
}
export type Any = Of<any, any>
export type Type<C extends Any> = C["Type"]
export function isCodec(value: unknown): value is Any {
return typeof value === "object" && value !== null && tag in value
}
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
return { [tag]: true, decode, encode } as Of<T, E>
}
export function is<T>(codec: Of<T>, input: unknown): input is T {
return codec.decode(input) !== INVALID
}
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
const value = codec.decode(input)
return value === INVALID ? undefined : value
}
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
const value = codec.decode(input)
if (value === INVALID) throw new Error("Value does not match its codec")
return value
}
const identity = <T>(value: T) => value
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
export const nonNegativeInt: Of<number, number> = make(
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
identity,
)
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
return make((v) => (v === value ? value : INVALID), identity)
}
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
const set = new Set<unknown>(values)
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
}
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
make(value: string): string & { readonly [K in B]: B }
} {
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
make: (value: string) => value as never,
})
}
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
}
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
}
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return { ...undefinedOr(codec), optional: true } as never
}
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return {
...make<T | undefined, E | undefined>(
(v) => {
if (v === undefined) return undefined
const value = codec.decode(v)
return value === INVALID ? undefined : value
},
(v) => (v === undefined ? undefined : codec.encode(v)),
),
optional: true,
} as never
}
type Fields = Record<string, Any>
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
type Simplify<T> = { [K in keyof T]: T[K] } & {}
export type StructType<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
>
export type StructEncoded<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
>
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
readonly fields: F
}
export function struct<const F extends Fields>(fields: F): Struct<F> {
const entries = Object.entries(fields)
return {
...make<StructType<F>, StructEncoded<F>>(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const record = input as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const present = Object.hasOwn(record, key)
if (!present && codec.optional) continue
const value = codec.decode(record[key])
if (value === INVALID) return INVALID
if (value !== undefined || present) out[key] = value
}
return out as StructType<F>
},
(value) => {
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const field = (value as Record<string, unknown>)[key]
if (field === undefined && !Object.hasOwn(value as object, key)) continue
out[key] = codec.encode(field)
}
return out as StructEncoded<F>
},
),
fields,
}
}
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return INVALID
const out: T[] = []
for (const item of input) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out.push(value)
}
return out
},
(value) => value.map((item) => codec.encode(item)),
)
}
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const value = codec.decode(item)
return value === INVALID ? [] : [value]
})
},
(value) => value.map((item) => codec.encode(item)),
)
}
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
const strict = record(codec)
return make(
(input) => {
const value = strict.decode(input)
return value === INVALID ? {} : value
},
strict.encode,
)
}
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
return make(
(input) => {
for (const codec of codecs) {
const value = codec.decode(input)
if (value !== INVALID) return value
}
return INVALID
},
(value) => {
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
return value as C[number]["Encoded"]
},
)
}
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
export function transform<T, E, T2>(
codec: Of<T, E>,
options: { decode: (value: T) => T2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = codec.decode(input)
return value === INVALID ? INVALID : options.decode(value)
},
(value) => codec.encode(options.encode(value)),
)
}
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
return make(
(input) => {
if (input === undefined) return value()
const decoded = codec.decode(input)
return decoded === INVALID ? value() : decoded
},
codec.encode,
)
}
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
return make(
(input) => {
if (typeof input !== "string") return INVALID
try {
return codec.decode(JSON.parse(input))
} catch {
return INVALID
}
},
(value) => JSON.stringify(codec.encode(value)),
)
}
export type Decoder = Pick<Of<unknown>, "decode">
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
return { current, read }
}
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
return !isCodec(definition) && "current" in definition
}
// Stored values recover field by field against the initial value: an object's valid fields are
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
const codec = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition) ? definition.read : unknown
return make(
(input) => {
const stored = read.decode(input)
if (stored === INVALID) return INVALID
return merge(initial, recover(codec, stored, initial))
},
(value) => codec.encode(value),
)
}
function recover(codec: Any, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if ("fields" in codec && isObject(value)) {
const fields = (codec as Struct<Fields>).fields
return Object.fromEntries(
Object.entries(fields).flatMap(([name, field]) => {
const defaults = isObject(initial) ? initial[name] : undefined
const next = recover(field, value[name], defaults)
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
return [[name, next]]
}),
)
}
const decoded = codec.decode(value)
return decoded === INVALID ? initial : decoded
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!isObject(initial) || !isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+11 -44
View File
@@ -6,7 +6,6 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "./codec"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
@@ -473,57 +472,25 @@ export function removePersisted(
}
}
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
| S
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
| Codec.Migrated<Extract<S, Codec.Any>>
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
// stops paying for Effect at startup; both are accepted while the migration is underway.
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
definition: Definition<S>,
initial: S["Type"],
) {
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
const json = Codec.fromJsonString(codec)
return {
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
serialize: (value: S["Type"]) => json.encode(value),
encode: (value: S["Type"]) => codec.encode(value),
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
}
}
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
const initialized = Persistence.withInitial(schema, initial as object)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
return {
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
target: string | PersistTarget,
schema: Definition<S>,
schema: S | Persistence.Migrated<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const codec = serializer<S>(schema, initial)
const { encode, serialize } = codec
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = codec.decode(raw)
if (value !== undefined) return serialize(value)
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
}
const store = createStore<S["Type"]>(codec.initial)
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
name: config.key,
storage,
serialize,
deserialize: codec.deserialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
-11
View File
@@ -1,11 +0,0 @@
import type { Brand } from "effect"
import { Codec } from "@/runtime/persistence/codec"
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
{ make: (value: string) => value as ServerKey },
)
+8 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
@@ -10,21 +11,21 @@ test("settings has its own layout route", () => {
})
describe("layout persistence", () => {
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
@@ -55,8 +56,8 @@ describe("layout persistence", () => {
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(schema.encode(value)).toEqual(value)
expect(decode(schema.encode(value))).toEqual(value)
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
@@ -168,4 +169,3 @@ describe("pruneSessionKeys", () => {
expect(drop).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
+50 -45
View File
@@ -1,58 +1,63 @@
export * as TabStorage from "./schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "@/runtime/server/key"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const Session = Codec.struct({
type: Codec.literal("session"),
export const Session = Persistence.struct({
type: Schema.Literal("session"),
server: ServerKey,
sessionId: Codec.string,
routeSessionId: Codec.lenientOptional(Codec.string),
routeParentId: Codec.lenientOptional(Codec.string),
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
})
export const Draft = Codec.struct({
type: Codec.literal("draft"),
draftID: Codec.string,
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
server: ServerKey,
directory: Codec.string,
worktree: Codec.lenientOptional(Codec.string),
branch: Codec.lenientOptional(Codec.string),
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
})
// A stored route that only repeats the session id carries nothing; drop it and its parent.
const SessionCodec = Codec.transform(Session, {
decode: (tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
}),
encode: (tab) => tab,
})
export const Tab = Codec.union([Session, Draft])
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
export const Recent = Codec.struct({
key: Codec.optional(Codec.string),
})
export const Info = Codec.struct({
title: Codec.optional(Codec.string),
directory: Codec.optional(Codec.string),
})
export const Infos = Codec.record(Info)
export const Panes = Codec.record(
Codec.struct({
terminal: Codec.optional(Codec.boolean),
review: Codec.optional(Codec.boolean),
terminalHeight: Codec.optional(Codec.number),
sessionWidth: Codec.optional(Codec.number),
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
}),
)
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
export const Closed = Codec.lenientArray(ClosedTab)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
+14 -15
View File
@@ -3,12 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -25,7 +26,7 @@ describe("tab migration", () => {
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
@@ -63,13 +64,13 @@ describe("tab migration", () => {
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
@@ -80,16 +81,16 @@ describe("tab migration", () => {
test("validates auxiliary tab state", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
).toEqual({ key: undefined })
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
})
})
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
+4 -2
View File
@@ -45,7 +45,8 @@ export declare namespace Source {
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: Effect.Effect<A | Unavailable | Removed>
readonly render: {
readonly initial: (current: A) => string
/** Omit when the baseline is already visible to the model and only changes carry information. */
readonly initial?: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
@@ -88,7 +89,8 @@ export const empty: List = []
export function make<A>(source: Source.Definition<A>): List {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
const initial = (value: A) =>
source.render.initial === undefined ? undefined : requireText(source.key, "initial", source.render.initial(value))
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
return [
{
File diff suppressed because one or more lines are too long
+2
View File
@@ -16,6 +16,7 @@ import { McpTool } from "../tool/mcp.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { ToolInstructions } from "../tool/instructions.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
@@ -146,6 +147,7 @@ const layer = Layer.effect(
agent: { ...agent, info: agent.info },
instructions: Instructions.combine([
loaded.builtins,
ToolInstructions.make(loaded.tools.definitions.map((definition) => definition.name)),
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
+36
View File
@@ -0,0 +1,36 @@
export * as ToolInstructions from "./instructions.js"
import { Effect, Schema } from "effect"
import { Instructions } from "../instructions/index.js"
const Names = Schema.Array(Schema.String)
type Names = typeof Names.Type
const list = (names: ReadonlyArray<string>) => names.map((name) => `\`${name}\``).join(", ")
export function update(previous: Names, current: Names) {
const added = current.filter((name) => !previous.includes(name))
const removed = previous.filter((name) => !current.includes(name))
return [
"The available tools have changed.",
...(added.length > 0 ? [`New tools are available in addition to those previously provided: ${list(added)}.`] : []),
...(removed.length > 0
? [`The following tools are no longer available and must not be called: ${list(removed)}.`]
: []),
].join("\n\n")
}
const key = Instructions.Key.make("core/tools")
const codec = Schema.toCodecJson(Names)
/**
* Tracks the top-level tool names the model has been shown. The request's own
* tool list is the baseline, so nothing renders until that set changes.
*/
export const make = (names: ReadonlyArray<string>): Instructions.List =>
Instructions.make({
key,
codec,
read: Effect.succeed(Array.from(new Set(names)).sort()),
render: { changed: update },
})
@@ -0,0 +1,58 @@
import { describe, expect } from "bun:test"
import { ToolInstructions } from "@opencode/core/tool/instructions"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
describe("ToolInstructions", () => {
it.effect("renders nothing for the baseline and announces only the delta afterwards", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make(["shell", "read", "edit"]))
expect(initialized.text).toBe("")
expect(initialized.values["core/tools"]).toEqual(["edit", "read", "shell"])
const unchanged = yield* readUpdate(ToolInstructions.make(["edit", "shell", "read", "read"]), initialized)
expect(unchanged.text).toBe("")
const changed = yield* readUpdate(ToolInstructions.make(["edit", "read", "write", "glob"]), initialized)
expect(changed.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `glob`, `write`.",
"The following tools are no longer available and must not be called: `shell`.",
].join("\n\n"),
)
const restored = yield* readUpdate(ToolInstructions.make(["edit", "read", "shell", "write", "glob"]), changed)
expect(restored.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `shell`.",
].join("\n\n"),
)
}),
)
it.effect("announces transitions to and from an empty tool set", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make([]))
expect(initialized.text).toBe("")
const added = yield* readUpdate(ToolInstructions.make(["read"]), initialized)
expect(added.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `read`.",
].join("\n\n"),
)
const emptied = yield* readUpdate(ToolInstructions.make([]), added)
expect(emptied.text).toBe(
[
"The available tools have changed.",
"The following tools are no longer available and must not be called: `read`.",
].join("\n\n"),
)
}),
)
})
-28
View File
@@ -28,29 +28,6 @@ const sentry =
})
: false
// Every module the entry reaches through static imports lands in one chunk. Automatic splitting
// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs
// the renderer a main-thread request round trip through the main process before first paint.
type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null }
const initialGraph = new WeakMap<ChunkingContext, Map<string, boolean>>()
function inInitialGraph(id: string, ctx: ChunkingContext) {
const memo = initialGraph.get(ctx) ?? new Map<string, boolean>()
initialGraph.set(ctx, memo)
const visit = (id: string, path: Set<string>): boolean => {
const known = memo.get(id)
if (known !== undefined) return known
if (path.has(id)) return false
const info = ctx.getModuleInfo(id)
if (!info) return false
path.add(id)
const result = info.isEntry || info.importers.some((importer) => visit(importer, path))
path.delete(id)
memo.set(id, result)
return result
}
return visit(id, new Set())
}
export default defineConfig(({ command }) => ({
main: {
resolve: {
@@ -133,11 +110,6 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: {
main: "src/renderer/index.html",
},
output: {
codeSplitting: {
groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }],
},
},
},
},
},
+4 -17
View File
@@ -191,14 +191,8 @@ const phaseOrder = [
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
["logging → first log line", "loggingReady", "appStarting"],
["first log line → storage open", "appStarting", "storageOpen"],
["storage → initialization done", "storageOpen", "initializationDone"],
["initialization → layers ready", "initializationDone", "layersReady"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
@@ -403,15 +397,8 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
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,
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
initializationDone: main.marks.init && main.marks.init - spawnAt,
layersReady: main.marks.layers && main.marks.layers - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
@@ -606,8 +593,8 @@ 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|layers ready/.test(message))
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
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])
}
}
@@ -1,10 +0,0 @@
param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot\..
# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed.
bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3
if (-not (Test-Path out\main\index.js)) { throw "build failed" }
bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2
if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force }
Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked"
bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45
@@ -1,94 +0,0 @@
// Attribute a renderer .cpuprofile's self time to original source files through the build's
// source maps. Run with: bun scripts/profile-by-source.ts <profile.cpuprofile> [out/renderer/assets]
import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
const profilePath = process.argv[2]!
const assets = process.argv[3] ?? "out/renderer/assets"
const profile = JSON.parse(readFileSync(profilePath, "utf8"))
const maps = new Map<string, TraceMap>()
for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) {
maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8"))))
}
const nodes = new Map<number, any>()
for (const n of profile.nodes) nodes.set(n.id, n)
const self = new Map<string, number>()
const byPkg = new Map<string, number>()
const group = (source: string) => {
const n = source.replace(/\\/g, "/")
const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/)
if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1]
const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/)
return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50)
}
const parent = new Map<number, number>()
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id)
const resolve = (frame: any) => {
const name = frame.functionName
if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name }
const file = frame.url.split("/").pop()
const map = maps.get(file)
if (!map) return { label: `(no map) ${file}`, fn: name }
const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber })
return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name }
}
const labelOf = (frame: any) => resolve(frame).label
// A sample belongs to the render phase once Solid's root is on the stack; everything before that is
// module evaluation, everything after the first render is later work (hydration, effects, timers).
const stackHas = (id: number, test: (label: string, fn: string) => boolean) => {
for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) {
const resolved = resolve(nodes.get(cur).callFrame)
if (test(resolved.label, resolved.fn)) return true
}
return false
}
const phases = { evaluate: new Map<string, number>(), render: new Map<string, number>(), later: new Map<string, number>() }
let phase: keyof typeof phases = "evaluate"
let t = 0
let total = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") {
if (phase === "render" && dt > 5) phase = "later"
continue
}
total += dt
if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot")))
phase = "render"
const label = labelOf(node.callFrame)
const bucket = phases[phase]
bucket.set(label, (bucket.get(label) ?? 0) + dt)
self.set(label, (self.get(label) ?? 0) + dt)
const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/")
byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt)
}
console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`)
void phases
// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands.
const buckets = new Map<number, Map<string, number>>()
t = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") continue
const b = Math.floor(t / 25) * 25
const m = buckets.get(b) ?? new Map()
const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "")
m.set(label, (m.get(label) ?? 0) + dt)
buckets.set(b, m)
}
console.log("\n== timeline (25 ms buckets) ==")
for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) {
const busy = [...m.values()].reduce((a, c) => a + c, 0)
if (busy < 1) continue
const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ")
console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top)
}
console.log("\n== by package (all) ==")
for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k)
-8
View File
@@ -5,7 +5,6 @@ import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { DesktopLogging } from "./native/logging"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
@@ -16,15 +15,8 @@ marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
marks.layers = Date.now()
yield* Effect.logInfo("layers ready", { marks })
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
yield* Effect.sleep("500 millis")
const logging = yield* DesktopLogging.Service
yield* logging.startCrashReporter
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
+1 -10
View File
@@ -2,10 +2,7 @@
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { startSidecarProbe } from "./service/sidecar-probe"
import { registerStorageSnapshotHandler } from "./storage/snapshot"
import { createEarlyWindow } from "./windows/early"
import { rendererAssetsServed } from "./windows/protocol"
import { registerRendererScheme } from "./windows/scheme"
// This module stays small on purpose. Electron holds the ready event until the entry module has
@@ -17,16 +14,10 @@ if (acquireApplicationLock()) {
registerRendererScheme()
// 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(async () => {
void app.whenReady().then(() => {
marks.ready = Date.now()
registerStorageSnapshotHandler()
createEarlyWindow()
marks.window = Date.now()
startSidecarProbe()
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
marks.served = Date.now()
return import("./desktop")
})
}
@@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/scheme"
import { isRendererUrl } from "../windows/protocol"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
+2 -5
View File
@@ -65,15 +65,12 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
const post = () => {
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
}
win.webContents.on("did-finish-load", post)
// The first window starts loading before the layers exist and may already be done.
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { enableCompileCache } from "node:module"
import { homedir, tmpdir } from "node:os"
import path from "node:path"
import { app } from "electron"
@@ -32,8 +31,6 @@ export function configureApplication() {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
}
export function acquireApplicationLock() {
@@ -4,7 +4,6 @@ import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { getStore } from "../storage/store"
import { marks } from "./marks"
import {
loadProxyEnvironment,
preferApplicationEnvironment,
@@ -23,15 +22,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
// System certificates, the proxy and the net log serve later network work; the first window and
// its IPC port do not wait for them.
yield* Effect.forkScoped(
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
)
yield* loadProxyEnvironment
yield* Effect.promise(() => app.whenReady())
yield* logging.startNetwork
yield* prepareDesktop
marks.init = Date.now()
return Service.of({
version: app.getVersion(),
updaterStore: getStore("opencode.updater"),
@@ -4,8 +4,7 @@ import { app } from "electron"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
import { scoped } from "../native/logging"
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.
@@ -38,11 +37,7 @@ export const prepareDesktop = Effect.gen(function* () {
const paths = yield* DesktopPaths.resolve
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
const runFork = Effect.runForkWith(yield* Effect.context())
setProtocolReporter((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
registerRendererProtocol(paths.rendererRoot)
yield* registerRendererProtocol()
setDockIcon(path, paths)
})
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { marks } from "./marks"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
@@ -158,7 +157,6 @@ export const layer = Layer.unwrap(
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
marks.onboarding = Date.now()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+3 -6
View File
@@ -1,7 +1,4 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup
// benchmark can split the time before the renderer gets its IPC port into Electron's own
// initialisation, our entry, the main bundle and each layer.
export const marks: { entry: number } & Partial<
Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number>
> = { entry: Date.now() }
// 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() }
@@ -8,10 +8,6 @@ import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
@@ -21,8 +17,11 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
}),
{ concurrency: "unbounded" },
)
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const complete = hasExistingAppState(entries)
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
return complete
+2 -10
View File
@@ -19,7 +19,6 @@ let netLogPath: string | undefined
export interface Interface {
readonly startNetwork: Effect.Effect<void>
readonly startCrashReporter: Effect.Effect<void>
readonly exportDebug: Effect.Effect<string>
}
@@ -31,9 +30,7 @@ const serviceLayer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* initLogging(fs, path).pipe(Effect.orDie)
// Old run directories go away in the background; listing them is not worth a wait at startup.
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
marks.logging = Date.now()
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
yield* Effect.logInfo("app starting", {
version: VERSION,
packaged: app.isPackaged,
@@ -45,12 +42,6 @@ const serviceLayer = Layer.effect(
startNetwork: startNetLog(path).pipe(
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
),
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
// and its IPC port come first.
startCrashReporter: initCrashReporter(fs, path).pipe(
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
),
exportDebug,
})
}),
@@ -108,6 +99,7 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
})
yield* cleanup(fs, path)
})
}
@@ -3,7 +3,6 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
export * as BackgroundService from "./background-service"
@@ -37,7 +36,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode/client/service"))
const ensure = () =>
const service = yield* Effect.tryPromise(() =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
@@ -47,18 +46,13 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
})
// A compatible service the entry module already found is adopted at once; ensure() still runs
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(ensure))
}),
)
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
version,
probed: !!early,
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
@@ -1,7 +1,6 @@
export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
@@ -94,15 +93,9 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
// async read waits behind everything else the main thread is doing at that moment.
const shipped = yield* Effect.sync(() => {
try {
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
})
const shipped = yield* fs
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
if (shipped) {
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
return shipped
@@ -154,7 +147,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
const path = yield* Path.Path
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = path.join(directory, executableName())
if (existsSync(destination)) {
if (yield* fs.exists(destination)) {
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
return destination
}
@@ -1,32 +0,0 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { app } from "electron"
import type { Endpoint } from "@opencode/client/service"
// The main thread idles between showing the first window and evaluating the main bundle, waiting
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
// background service is already running, so the renderer's first data request is not the first
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
// which set the environment the CLI expects.
let probe: Promise<Endpoint | undefined> | undefined
export function startSidecarProbe() {
if (!app.isPackaged) return
const version = bundledVersion()
if (!version) return
probe = import("@opencode/client/service")
.then(({ Service }) => Service.discover({ version }))
.catch(() => undefined)
}
export function sidecarProbe() {
return probe ?? Promise.resolve(undefined)
}
function bundledVersion() {
try {
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
}
@@ -2,9 +2,7 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { marks } from "../lifecycle/marks"
import { openDatabase } from "./database"
import { setStorageSnapshotProvider } from "./snapshot"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
@@ -42,8 +40,6 @@ export const layer = Layer.effect(
storage.close()
}),
)
setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)])))
marks.storage = Date.now()
return Service.of(storage)
}),
)
@@ -1,56 +0,0 @@
import { existsSync, readdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { app, ipcMain } from "electron"
import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport"
import { isRendererUrl } from "../windows/scheme"
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
// the answer comes from the database file; the layer takes over so later windows see queued writes.
type Provider = (names: ReadonlyArray<string>) => StorageSnapshot
let provider: Provider = readFromDisk
export function setStorageSnapshotProvider(next: Provider) {
provider = next
}
export function registerStorageSnapshotHandler() {
ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => {
if (!isRendererUrl(event.senderFrame?.url)) return {}
if (!Array.isArray(names)) return {}
return provider(names.filter((name): name is string => typeof name === "string"))
})
}
// Nothing has been written in this process yet, so every namespace is at revision 0, as the
// storage layer would report before its first update. Legacy electron-store files still waiting
// to be imported would make the database stale for this launch, so the renderer asks the layer then.
function readFromDisk(names: ReadonlyArray<string>): StorageSnapshot {
const userData = app.getPath("userData")
const file = path.join(userData, "drafts.sqlite")
if (!existsSync(file)) return {}
if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {}
try {
const db = new DatabaseSync(file)
try {
const rows = db.prepare("SELECT key, value FROM state WHERE name = ?")
return Object.fromEntries(
names.map((name) => [
name,
{
items: Object.fromEntries(
(rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]),
),
revision: 0,
},
]),
)
} finally {
db.close()
}
} catch {
return {}
}
}
@@ -1,20 +0,0 @@
import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap"
import { getDefaultServerUrl } from "../service/server-settings"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
// The settings store is already in memory when a window is created, so the renderer gets the
// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding
// decision yet; the renderer asks once the layers have made one.
export function windowBootstrap(id: string): WindowBootstrap {
const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
return {
id,
firstLaunchPending: typeof complete === "boolean" ? !complete : undefined,
defaultServerUrl: getDefaultServerUrl(),
}
}
export function windowArguments(id: string) {
return [windowBootstrapArgument(windowBootstrap(id))]
}
@@ -9,6 +9,10 @@ import { getStore } from "../storage/store"
// full window setup in appearance.ts, so both draw the same frame.
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
// Match the renderer's 36px titlebar plus its former 8px content inset.
export const titlebarHeight = 44
@@ -17,13 +21,10 @@ export function tone() {
}
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
// a window shown before the renderer paints already has the right background. Resolving a palette
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
// a window shown before the renderer paints already has the right background.
export function storedBackgroundColor() {
const stored = getStore().get(BACKGROUND_COLOR_KEY)
if (typeof stored === "string") return stored
const dark = tone() === "dark"
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
return typeof stored === "string" ? stored : oc2Background[tone()]
}
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
+5 -33
View File
@@ -1,25 +1,14 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen, shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { windowArguments } from "./bootstrap"
import { app, BrowserWindow, screen } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { registerRendererProtocol } from "./protocol"
import { 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
// Navigation policy is wired before the layers exist; the adopter swaps in the logged version.
openExternal: (url: string) => void
}
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
let pending: EarlyWindow | undefined
@@ -57,7 +46,7 @@ export function createEarlyWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: windowArguments(id),
additionalArguments: [windowIDArgument(id)],
},
})
manageWindowState(win, file, state, displays)
@@ -67,24 +56,7 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
const record: EarlyWindow = {
id,
win,
state,
shownAt: Date.now(),
openExternal: (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
},
}
pending = record
// The renderer boots while the main bundle and layers load, instead of after them. Everything the
// page needs before its first request is wired here; the IPC port arrives once the layers are up.
registerRendererProtocol(path.join(root, "../renderer"))
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => record.openExternal(url))
wireRendererHeaders(win)
loadWindow(win, "index.html")
pending = { id, win, state, shownAt: Date.now() }
}
export function takeEarlyWindow() {
+16 -16
View File
@@ -7,8 +7,7 @@ import { DesktopPaths } from "../paths"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowDataFile } from "../../shared/ipc-transport"
import { windowArguments } from "./bootstrap"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -22,8 +21,7 @@ import {
wireFullscreen,
wireZoom,
} from "./appearance"
import { registerRendererProtocol, setProtocolReporter } from "./protocol"
import { loadWindow } from "./scheme"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -50,7 +48,6 @@ export {
getBackgroundColor,
getPinchZoomEnabled,
registerRendererProtocol,
setProtocolReporter,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
@@ -118,23 +115,18 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: windowArguments(id),
additionalArguments: [windowIDArgument(id)],
},
})
// The early window was secured and loaded when it was created; only its external-URL policy is
// upgraded to the logged one.
if (early) early.openExternal = (url) => runFork(openExternalURL(url))
if (!early) {
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
manageWindowState(win, stateFile, state, displays)
}
allowRendererPermissions(win)
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
if (!early) loadWindow(win, "index.html")
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -190,4 +182,12 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
return { create, restore }
})
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
function safeWindowID(id: string) {
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
}
+57 -69
View File
@@ -1,85 +1,73 @@
import { net, protocol } from "electron"
import path from "node:path"
import type { BrowserWindow } from "electron"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
// The entry module registers the handler the moment the first window exists, before logging is up,
// so problems go to the console until the logging layer installs a reporter.
let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data)
export function setProtocolReporter(reporter: ProtocolReport) {
report = reporter
}
// Requests in flight and when the last one arrived. The entry module holds the main bundle back
// until the renderer's initial burst of asset requests has been answered, because this handler
// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer
// and its HTML.
let inflight = 0
let served = 0
let lastRequest = 0
export function rendererAssetsServed(options: { quietMs: number; capMs: number }) {
const start = Date.now()
return new Promise<void>((resolve) => {
const check = () => {
const now = Date.now()
if (now - start >= options.capMs) return resolve()
if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve()
setTimeout(check, 5)
}
check()
})
}
export function registerRendererProtocol(rendererRoot: string) {
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) => {
inflight++
lastRequest = Date.now()
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 {
return await serve(request, rendererRoot)
} finally {
inflight--
served++
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
async function serve(request: Request, rendererRoot: string) {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report("warning", "rejected host", { url: request.url })
return new Response("Not found", { status: 404 })
}
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
report("warning", "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 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) {
@@ -1,28 +1,8 @@
import { protocol } from "electron"
import type { BrowserWindow } from "electron"
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
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
}
// 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() {
@@ -1,7 +1,7 @@
import type { BrowserWindow } from "electron"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./scheme"
import { isRendererUrl } from "./protocol"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
+3 -17
View File
@@ -1,12 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import {
DragCancelEvent,
IpcTransportPort,
StorageSnapshotChannel,
storageSnapshotNames,
type StorageSnapshot,
} from "../shared/ipc-transport"
import { windowBootstrapFromArguments } from "../shared/window-bootstrap"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -15,15 +9,7 @@ ipcRenderer.on(IpcTransportPort, (event) => {
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
const bootstrap = windowBootstrapFromArguments(process.argv)
// Asked before the page runs, so the stores the shell reads are hydrated on the first render.
const storageSnapshot: Promise<StorageSnapshot> = ipcRenderer
.invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id))
.catch(() => ({}))
contextBridge.exposeInMainWorld("electron", {
windowID: bootstrap.id,
bootstrap,
storageSnapshot,
windowID: windowIDFromArguments(process.argv),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
-5
View File
@@ -1,9 +1,4 @@
import type { StorageSnapshot } from "../shared/ipc-transport"
import type { WindowBootstrap } from "../shared/window-bootstrap"
export type ElectronNative = {
windowID: string
bootstrap: WindowBootstrap
storageSnapshot: Promise<StorageSnapshot>
getPathForFile(file: File): string
}
@@ -5,7 +5,6 @@ import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -54,7 +53,6 @@ export type ElectronAPI = {
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+1 -13
View File
@@ -22,9 +22,6 @@ const updaterHandler = (state: UpdaterState) => {
updaterCallbacks.forEach((callback) => callback(state))
}
// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here.
const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot)))
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
@@ -102,15 +99,7 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
// The first read of a namespace the preload already fetched is served from that snapshot; later
// reads (a window re-opening a namespace) go to the main process as usual.
storeItems: (name) =>
seeded.then((snapshot) => {
const item = snapshot.get(name)
if (!item) return invoke("StorageItems", { name }).then(mutable)
snapshot.delete(name)
return item
}),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
@@ -122,7 +111,6 @@ export const api: ElectronAPI = {
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => window.electron.windowID,
getWindowBootstrap: () => window.electron.bootstrap,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+5 -13
View File
@@ -48,23 +48,15 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
drawingReady: false,
route,
})
// The window was created with the answers the shell gate needs; only a fresh install, which has no
// onboarding decision yet, asks over IPC and waits for the port.
const bootstrap = props.api.getWindowBootstrap()
const [firstLaunch] = createResource(() =>
bootstrap.firstLaunchPending !== undefined
? Promise.resolve(bootstrap.firstLaunchPending)
: props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(async () => {
if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.()
return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null
})
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
+69 -73
View File
@@ -1,14 +1,10 @@
import type { Effect } from "effect"
import type { RpcMessage } from "effect/unstable/rpc"
import type { DesktopRpcClient } from "../shared/ipc-rpc"
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
// payload in the contract is JSON-native or a Uint8Array, and the main process is trusted, so the
// renderer needs neither the Effect runtime nor the contract's schemas to talk to it. Keeping them
// out of the renderer's initial module graph is worth about a third of its startup script.
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
@@ -17,48 +13,55 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
type Pending = {
readonly resolve: (value: unknown) => void
readonly reject: (error: unknown) => void
readonly chunk?: (values: ReadonlyArray<unknown>) => void
}
const pending = new Map<number, Pending>()
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
let nextId = 0
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
value.start()
resolve(value)
}
window.addEventListener("message", onMessage)
})
// Let queued work (storage flushes) hand its messages to the port before it closes.
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
window.addEventListener(
"pagehide",
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => port.then((p) => p.close())),
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
{ once: true },
)
void request("DesktopEvents", null, (values) => {
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
})
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
@@ -76,49 +79,42 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
}
}
function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject, chunk })
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
void port.then((p) => p.postMessage(message))
})
}
function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
switch (message._tag) {
case "Chunk": {
pending.get(Number(message.requestId))?.chunk?.(message.values)
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
case "Exit": {
const id = Number(message.requestId)
const entry = pending.get(id)
pending.delete(id)
if (!entry) return
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
return entry.reject(failure(message.exit.cause))
}
case "Defect": {
const error = new Error("Desktop IPC defect", { cause: message.defect })
pending.forEach((entry) => entry.reject(error))
pending.clear()
return
}
case "ClientProtocolError": {
console.error("[desktop-ipc] protocol error", message.error)
return
}
}
}
// The RPC failure a caller sees is the encoded error the handler failed with, as before; defects
// and interrupts surface as errors.
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
const failed = cause.find((item) => item._tag === "Fail")
if (failed) return failed.error
const died = cause.find((item) => item._tag === "Die")
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
return new Error("Desktop IPC request interrupted")
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
send: (_clientId, request) =>
Effect.sync(() => {
value.postMessage(request)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
}
@@ -1,22 +1,9 @@
import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop"
import { storedLocaleValue } from "./locale-value"
// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the
// last answer in localStorage lets the shell mount without waiting for the port; the store is still
// asked every launch and the copy refreshed, and the language provider hydrates from the store
// itself, so a stale copy costs one visible switch, not a wrong language.
const cacheKey = "opencode.desktop.language"
export async function preloadStoredLocale(platform: Platform) {
const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then(
(raw) => {
localStorage.setItem(cacheKey, raw ?? "")
return raw
},
() => undefined,
)
const cached = localStorage.getItem(cacheKey)
const locale = storedLocale(cached ?? (await fresh))
const raw = await platform.storage?.("opencode.global.dat").getItem("language")
const locale = storedLocale(raw)
if (!locale) return
if (locale !== "en") await loadLocaleDict(locale)
return locale
@@ -1,16 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
export const StorageSnapshotChannel = "desktop-storage-snapshot"
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
// runs so the first render already has them; mirrors windowStorage() in
// packages/app/src/runtime/persistence/storage.ts.
export function storageSnapshotNames(windowID: string) {
return ["opencode.global.dat", "default.dat", windowDataFile(windowID)]
}
export function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}
@@ -1,17 +1,13 @@
import { describe, expect, test } from "bun:test"
import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips through argv", () => {
const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" }
expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap)
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
})
test("keeps unknown values absent", () => {
expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" })
})
test("throws when the argument is missing", () => {
expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found")
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
})
})
@@ -1,19 +1,11 @@
// What the main process already knows when it creates a window, handed to the renderer through the
// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC".
export type WindowBootstrap = {
id: string
firstLaunchPending?: boolean
defaultServerUrl?: string | null
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
}
const prefix = "--opencode-window="
export function windowBootstrapArgument(bootstrap: WindowBootstrap) {
return prefix + encodeURIComponent(JSON.stringify(bootstrap))
}
export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap {
const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!value) throw new Error("Window bootstrap argument not found")
return JSON.parse(decodeURIComponent(value))
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
}