From 074ef032eef2cb6a9a9b8dde5626ad5c0080d808 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 15 Apr 2026 21:04:37 -0400 Subject: [PATCH 01/75] feat(core): add fence to make all methods strongly consistent when syncing (#22679) --- packages/opencode/src/bus/global.ts | 16 +-- packages/opencode/src/control-plane/util.ts | 37 +++++++ .../opencode/src/control-plane/workspace.ts | 103 ++++++++++++++++-- packages/opencode/src/flag/flag.ts | 4 +- packages/opencode/src/server/fence.ts | 81 ++++++++++++++ .../src/server/instance/middleware.ts | 9 +- packages/opencode/src/server/proxy.ts | 47 ++++++-- packages/opencode/src/server/server.ts | 18 +++ .../test/plugin/workspace-adaptor.test.ts | 13 ++- 9 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/control-plane/util.ts create mode 100644 packages/opencode/src/server/fence.ts diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index e751b59faf..b5392a81b9 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,12 +1,12 @@ import { EventEmitter } from "events" +export type GlobalEvent = { + directory?: string + project?: string + workspace?: string + payload: any +} + export const GlobalBus = new EventEmitter<{ - event: [ - { - directory?: string - project?: string - workspace?: string - payload: any - }, - ] + event: [GlobalEvent] }>() diff --git a/packages/opencode/src/control-plane/util.ts b/packages/opencode/src/control-plane/util.ts new file mode 100644 index 0000000000..023c2ae150 --- /dev/null +++ b/packages/opencode/src/control-plane/util.ts @@ -0,0 +1,37 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" + +export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) { + if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted")) + + return new Promise((resolve, reject) => { + const abort = () => { + cleanup() + reject(input.signal?.reason ?? new Error("Request aborted")) + } + + const handler = (event: GlobalEvent) => { + try { + if (!input.fn(event)) return + cleanup() + resolve() + } catch (error) { + cleanup() + reject(error) + } + } + + const cleanup = () => { + clearTimeout(timeout) + GlobalBus.off("event", handler) + input.signal?.removeEventListener("abort", abort) + } + + const timeout = setTimeout(() => { + cleanup() + reject(new Error("Timed out waiting for global event")) + }, input.timeout) + + GlobalBus.on("event", handler) + input.signal?.addEventListener("abort", abort, { once: true }) + }) +} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index b9ac0a6b43..67583107fc 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,7 +1,7 @@ import z from "zod" import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" -import { Database, asc, eq } from "@/storage/db" +import { Database, asc, eq, inArray } from "@/storage/db" import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -22,6 +22,8 @@ import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { AppRuntime } from "@/effect/app-runtime" +import { EventSequenceTable } from "@/sync/event.sql" +import { waitEvent } from "./util" export namespace Workspace { export const Info = WorkspaceInfo.meta({ @@ -114,6 +116,17 @@ export namespace Workspace { startSync(info) + await waitEvent({ + timeout: TIMEOUT, + fn(event) { + if (event.workspace === info.id && event.payload.type === Event.Status.type) { + const { status } = event.payload.properties + return status === "error" || status === "connected" + } + return false + }, + }) + return info }) @@ -285,10 +298,15 @@ export namespace Workspace { return spaces } - export const get = fn(WorkspaceID.zod, async (id) => { + function lookup(id: WorkspaceID) { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return - const space = fromRow(row) + return fromRow(row) + } + + export const get = fn(WorkspaceID.zod, async (id) => { + const space = lookup(id) + if (!space) return startSync(space) return space }) @@ -320,12 +338,18 @@ export namespace Workspace { const connections = new Map() const aborts = new Map() + const TIMEOUT = 5000 function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) { const prev = connections.get(id) if (prev?.status === status && prev?.error === error) return const next = { workspaceID: id, status, error } connections.set(id, next) + + if (status === "error") { + aborts.delete(id) + } + GlobalBus.emit("event", { directory: "global", workspace: id, @@ -340,6 +364,52 @@ export namespace Workspace { return [...connections.values()] } + function synced(state: Record) { + const ids = Object.keys(state) + if (ids.length === 0) return true + + const done = Object.fromEntries( + Database.use((db) => + db + .select({ + id: EventSequenceTable.aggregate_id, + seq: EventSequenceTable.seq, + }) + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, ids)) + .all(), + ).map((row) => [row.id, row.seq]), + ) as Record + + return ids.every((id) => { + return (done[id] ?? -1) >= state[id] + }) + } + + export async function isSyncing(workspaceID: WorkspaceID) { + return aborts.has(workspaceID) + } + + export async function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { + if (synced(state)) return + + try { + await waitEvent({ + timeout: TIMEOUT, + signal, + fn(event) { + if (event.workspace !== workspaceID && event.payload.type !== "sync") { + return false + } + return synced(state) + }, + }) + } catch (error) { + if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") + throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) + } + } + const log = Log.create({ service: "workspace-sync" }) function route(url: string | URL, path: string) { @@ -353,6 +423,7 @@ export namespace Workspace { async function syncWorkspace(space: Info, signal: AbortSignal) { while (!signal.aborted) { log.info("connecting to global sync", { workspace: space.name }) + setStatus(space.id, "connecting") const adaptor = await getAdaptor(space.projectID, space.type) const target = await adaptor.target(space) @@ -364,7 +435,7 @@ export namespace Workspace { headers: target.headers, signal, }).catch((err: unknown) => { - setStatus(space.id, "error") + setStatus(space.id, "error", err instanceof Error ? err.message : String(err)) log.info("failed to connect to global sync", { workspace: space.name, @@ -374,8 +445,9 @@ export namespace Workspace { }) if (!res || !res.ok || !res.body) { - log.info("failed to connect to global sync", { workspace: space.name }) - setStatus(space.id, "error") + const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}` + log.info("failed to connect to global sync", { workspace: space.name, error }) + setStatus(space.id, "error", error) await sleep(1000) continue } @@ -414,22 +486,29 @@ export namespace Workspace { } } - function startSync(space: Info) { + async function startSync(space: Info) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - if (space.type === "worktree") { - void Filesystem.exists(space.directory!).then((exists) => { + const adaptor = await getAdaptor(space.projectID, space.type) + const target = await adaptor.target(space) + + if (target.type === "local") { + void Filesystem.exists(target.directory).then((exists) => { setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") }) return } - if (aborts.has(space.id)) return - const abort = new AbortController() - aborts.set(space.id, abort) + if (aborts.has(space.id)) return true + setStatus(space.id, "disconnected") + const abort = new AbortController() + aborts.set(space.id, abort) + void syncWorkspace(space, abort.signal).catch((error) => { + aborts.delete(space.id) + setStatus(space.id, "error", String(error)) log.warn("workspace listener failed", { workspaceID: space.id, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f091fa02a9..a63f8d1c66 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -74,7 +74,6 @@ export namespace Flag { Config.withDefault(false), ) export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") - export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] @@ -84,6 +83,9 @@ export namespace Flag { export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS") export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS") + export const OPENCODE_WORKSPACE_ID = process.env["OPENCODE_WORKSPACE_ID"] + export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") + function number(key: string) { const value = process.env[key] if (!value) return undefined diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts new file mode 100644 index 0000000000..bb41bd7a43 --- /dev/null +++ b/packages/opencode/src/server/fence.ts @@ -0,0 +1,81 @@ +import type { MiddlewareHandler } from "hono" +import { Database, inArray } from "@/storage/db" +import { EventSequenceTable } from "@/sync/event.sql" +import { Workspace } from "@/control-plane/workspace" +import type { WorkspaceID } from "@/control-plane/schema" +import { Log } from "@/util/log" + +const HEADER = "x-opencode-sync" +type State = Record +const log = Log.create({ service: "fence" }) + +export function load(ids?: string[]) { + const rows = Database.use((db) => { + if (!ids?.length) { + return db.select().from(EventSequenceTable).all() + } + + return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + }) + + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State +} + +export function diff(prev: State, next: State) { + const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) + return Object.fromEntries( + [...ids] + .map((id) => [id, next[id] ?? -1] as const) + .filter(([id, seq]) => { + return (prev[id] ?? -1) !== seq + }), + ) as State +} + +export function parse(headers: Headers) { + const raw = headers.get(HEADER) + if (!raw) return + + let data + + try { + data = JSON.parse(raw) + } catch (err) { + return + } + + if (!data || typeof data !== "object") return + + return Object.fromEntries( + Object.entries(data).filter(([id, seq]) => { + return typeof id === "string" && Number.isInteger(seq) + }), + ) as State +} + +export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + log.info("waiting for state", { + workspaceID, + state, + }) + await Workspace.waitForSync(workspaceID, state, signal) + log.info("state fully synced", { + workspaceID, + state, + }) +} + +export const FenceMiddleware: MiddlewareHandler = async (c, next) => { + if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() + + const prev = load() + await next() + const current = diff(prev, load()) + + if (Object.keys(current).length > 0) { + log.info("header", { + diff: current, + }) + c.res.headers.set(HEADER, JSON.stringify(current)) + } +} diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 549fb38d5d..0e29daa9ee 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -6,6 +6,7 @@ import { Workspace } from "@/control-plane/workspace" import { ServerProxy } from "../proxy" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" +import { Flag } from "@/flag/flag" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -68,10 +69,10 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const sessionWorkspaceID = await getSessionWorkspace(url) const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - if (!workspaceID || url.pathname.startsWith("/console") || OPENCODE_WORKSPACE) { - if (OPENCODE_WORKSPACE) { + if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { + if (Flag.OPENCODE_WORKSPACE_ID) { return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(OPENCODE_WORKSPACE), + workspaceID: WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID), async fn() { return Instance.provide({ directory, @@ -148,6 +149,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware headers.delete("x-opencode-workspace") const req = new Request(c.req.raw, { headers }) - return ServerProxy.http(proxyURL, target.headers, req) + return ServerProxy.http(proxyURL, target.headers, req, workspace.id) } } diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 0c0deba20c..5effa5d05f 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,6 +1,9 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" import { Log } from "@/util/log" +import * as Fence from "./fence" +import type { WorkspaceID } from "@/control-plane/schema" +import { Workspace } from "@/control-plane/workspace" const hop = new Set([ "connection", @@ -101,12 +104,27 @@ const app = (upgrade: UpgradeWebSocket) => export namespace ServerProxy { const log = Log.Default.clone().tag("service", "server-proxy") - export function http(url: string | URL, extra: HeadersInit | undefined, req: Request) { + export async function http( + url: string | URL, + extra: HeadersInit | undefined, + req: Request, + workspaceID: WorkspaceID, + ) { console.log("proxy http request", { method: req.method, request: req.url, url: String(url), }) + + if (!Workspace.isSyncing(workspaceID)) { + return new Response(`broken sync connection for workspace: ${workspaceID}`, { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + return fetch( new Request(url, { method: req.method, @@ -116,21 +134,26 @@ export namespace ServerProxy { signal: req.signal, }), ).then((res) => { + const sync = Fence.parse(res.headers) const next = new Headers(res.headers) next.delete("content-encoding") next.delete("content-length") - console.log("proxy http response", { - method: req.method, - request: req.url, - url: String(url), - status: res.status, - statusText: res.statusText, - }) - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: next, + const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve() + + return done.then(async () => { + console.log("proxy http response", { + method: req.method, + request: req.url, + url: String(url), + status: res.status, + statusText: res.statusText, + }) + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: next, + }) }) }) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 02ec7356ec..c6c37ee438 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -4,9 +4,11 @@ import { adapter } from "#hono" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" +import { FenceMiddleware } from "./fence" import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" import { Log } from "@/util/log" +import { Flag } from "@/flag/flag" import { ControlPlaneRoutes } from "./control" import { UIRoutes } from "./ui" @@ -30,6 +32,22 @@ export namespace Server { function create(opts: { cors?: string[] }) { const app = new Hono() const runtime = adapter.create(app) + + if (Flag.OPENCODE_WORKSPACE_ID) { + return { + app: app + .onError(ErrorMiddleware) + .use(AuthMiddleware) + .use(LoggerMiddleware) + .use(CompressionMiddleware) + .use(CorsMiddleware(opts)) + .use(FenceMiddleware) + .route("/", ControlPlaneRoutes()) + .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + runtime, + } + } + return { app: app .onError(ErrorMiddleware) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 669a822a2f..ff8df7490d 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -7,10 +7,16 @@ import { tmpdir } from "../fixture/fixture" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" +const { Flag } = await import("../../src/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") const { Instance } = await import("../../src/project/instance") +const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +// @ts-expect-error tests override the flag directly +Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + afterEach(async () => { await Instance.disposeAll() }) @@ -18,9 +24,12 @@ afterEach(async () => { afterAll(() => { if (disableDefault === undefined) { delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS - return + } else { + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault } - process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault + + // @ts-expect-error restore original test flag value + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental }) describe("plugin.workspace", () => { From 307251bf3cc80131b4df4877d5c0cefc127828dd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:09:06 -0500 Subject: [PATCH 02/75] fix: bash memory usage (#22660) --- packages/opencode/src/tool/bash.ts | 121 +++++++++++++++++- packages/opencode/src/tool/truncate.ts | 15 ++- .../test/session/prompt-effect.test.ts | 4 +- packages/opencode/test/tool/bash.test.ts | 8 +- 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 7a124dadae..0ab1301305 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,5 +1,6 @@ import z from "zod" import os from "os" +import { createWriteStream } from "node:fs" import { Tool } from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" @@ -76,6 +77,11 @@ type Scan = { always: Set } +type Chunk = { + text: string + size: number +} + export const log = Log.create({ service: "bash-tool" }) const resolveWasm = (asset: string) => { @@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) { function preview(text: string) { if (text.length <= MAX_METADATA_LENGTH) return text - return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." + return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) +} + +function tail(text: string, maxLines: number, maxBytes: number) { + const lines = text.split("\n") + if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { + return { + text, + cut: false, + } + } + + const out: string[] = [] + let bytes = 0 + for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + if (out.length === 0) { + const buf = Buffer.from(lines[i], "utf-8") + let start = buf.length - maxBytes + if (start < 0) start = 0 + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + out.unshift(buf.subarray(start).toString("utf-8")) + } + break + } + out.unshift(lines[i]) + bytes += size + } + return { + text: out.join("\n"), + cut: true, + } } const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { @@ -295,6 +333,7 @@ export const BashTool = Tool.define( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner const fs = yield* AppFileSystem.Service + const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { @@ -381,7 +420,16 @@ export const BashTool = Tool.define( }, ctx: Tool.Context, ) { - let output = "" + const bytes = Truncate.MAX_BYTES + const lines = Truncate.MAX_LINES + const keep = bytes * 2 + let full = "" + let last = "" + const list: Chunk[] = [] + let used = 0 + let file = "" + let sink: ReturnType | undefined + let cut = false let expired = false let aborted = false @@ -398,10 +446,47 @@ export const BashTool = Tool.define( yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { - output += chunk + const size = Buffer.byteLength(chunk, "utf-8") + list.push({ text: chunk, size }) + used += size + while (used > keep && list.length > 1) { + const item = list.shift() + if (!item) break + used -= item.size + cut = true + } + + last = preview(last + chunk) + + if (file) { + sink?.write(chunk) + } else { + full += chunk + if (Buffer.byteLength(full, "utf-8") > bytes) { + return trunc.write(full).pipe( + Effect.andThen((next) => + Effect.sync(() => { + file = next + cut = true + sink = createWriteStream(next, { flags: "a" }) + full = "" + }), + ), + Effect.andThen( + ctx.metadata({ + metadata: { + output: last, + description: input.description, + }, + }), + ), + ) + } + } + return ctx.metadata({ metadata: { - output: preview(output), + output: last, description: input.description, }, }) @@ -443,16 +528,42 @@ export const BashTool = Tool.define( ) } if (aborted) meta.push("User aborted the command") + const raw = list.map((item) => item.text).join("") + const end = tail(raw, lines, bytes) + if (end.cut) cut = true + if (!file && end.cut) { + file = yield* trunc.write(raw) + } + + let output = end.text + if (!output) output = "(no output)" + + if (cut && file) { + output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output + } + if (meta.length > 0) { output += "\n\n\n" + meta.join("\n") + "\n" } + if (sink) { + const stream = sink + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + stream.on("error", () => resolve()) + }), + ) + } return { title: input.description, metadata: { - output: preview(output), + output: last || preview(output), exit: code, description: input.description, + truncated: cut, + ...(cut && file ? { outputPath: file } : {}), }, output, } diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index a7bd8a4b16..d607e22f28 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -33,6 +33,7 @@ export namespace Truncate { export interface Interface { readonly cleanup: () => Effect.Effect + readonly write: (text: string) => Effect.Effect /** * Returns output unchanged when it fits within the limits, otherwise writes the full text * to the truncation directory and returns a preview plus a hint to inspect the saved file. @@ -61,6 +62,13 @@ export namespace Truncate { } }) + const write = Effect.fn("Truncate.write")(function* (text: string) { + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + return file + }) + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES @@ -102,10 +110,7 @@ export namespace Truncate { const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) + const file = yield* write(text) const hint = hasTaskTool(agent) ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` @@ -131,7 +136,7 @@ export namespace Truncate { Effect.forkScoped, ) - return Service.of({ cleanup, output }) + return Service.of({ cleanup, write, output }) }), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 94561206e2..31727e3df9 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1362,8 +1362,8 @@ unix( expect(tool.state.metadata.truncated).toBe(true) expect(typeof tool.state.metadata.outputPath).toBe("string") - expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.") - expect(tool.state.output).toContain("Full output saved to:") + expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) expect(tool.state.output).not.toContain("Tool execution aborted") }), { git: true, config: providerCfg }, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 3b03da57ee..19135ba98b 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => { ), ) mustTruncate(result) - expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) }, }) }) @@ -1138,8 +1138,8 @@ describe("tool.bash truncation", () => { ), ) mustTruncate(result) - expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) }, }) }) From 6d42f976447f1b150dd6582e38dd29e0f7100c1b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:14:39 -0400 Subject: [PATCH 03/75] fix: revert "core: move plugin initialisation to config layer override" (#22686) --- packages/opencode/src/effect/app-runtime.ts | 20 +------------------- packages/opencode/src/project/bootstrap.ts | 1 + 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 668c89b60b..257922dafe 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,31 +47,13 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" -import * as Effect from "effect/Effect" - -// Adjusts the default Config layer to ensure that plugins are always initialised before -// any other layers read the current config -const ConfigWithPluginPriority = Layer.effect( - Config.Service, - Effect.gen(function* () { - const config = yield* Config.Service - const plugin = yield* Plugin.Service - - return { - ...config, - get: () => Effect.andThen(plugin.init(), config.get), - getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal), - getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState), - } - }), -).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer))) export const AppLayer = Layer.mergeAll( AppFileSystem.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, - ConfigWithPluginPriority, + Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, FileTime.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 0babdfe13b..a1f2a8cb02 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect" export const InstanceBootstrap = Effect.gen(function* () { Log.Default.info("bootstrapping", { directory: Instance.directory }) + yield* Plugin.Service.use((svc) => svc.init()) yield* Effect.all( [ LSP.Service, From 02f2cf439e58d3f3db2e7927ecad9c0d4e1bca16 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:18:36 -0400 Subject: [PATCH 04/75] =?UTF-8?q?feat:=20namespace=20=E2=86=92=20flat=20ex?= =?UTF-8?q?port=20migration=20(Bus=20proof-of-concept)=20(#22685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/script/unwrap-namespace.ts | 190 ++++++++ .../specs/effect/namespace-treeshake.md | 444 ++++++++++++++++++ packages/opencode/src/bus/bus.ts | 192 ++++++++ packages/opencode/src/bus/index.ts | 195 +------- 4 files changed, 827 insertions(+), 194 deletions(-) create mode 100644 packages/opencode/script/unwrap-namespace.ts create mode 100644 packages/opencode/specs/effect/namespace-treeshake.md create mode 100644 packages/opencode/src/bus/bus.ts diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts new file mode 100644 index 0000000000..65ce498be8 --- /dev/null +++ b/packages/opencode/script/unwrap-namespace.ts @@ -0,0 +1,190 @@ +#!/usr/bin/env bun +/** + * Unwrap a TypeScript `export namespace` into flat exports + barrel. + * + * Usage: + * bun script/unwrap-namespace.ts src/bus/index.ts + * bun script/unwrap-namespace.ts src/bus/index.ts --dry-run + * + * What it does: + * 1. Reads the file and finds the `export namespace Foo { ... }` block + * (uses ast-grep for accurate AST-based boundary detection) + * 2. Removes the namespace wrapper and dedents the body + * 3. If the file is index.ts, renames it to .ts + * 4. Creates/updates index.ts with `export * as Foo from "./"` + * 5. Prints the import rewrite commands to run across the codebase + * + * Does NOT auto-rewrite imports — prints the commands so you can review them. + * + * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`) + */ + +import path from "path" +import fs from "fs" + +const args = process.argv.slice(2) +const dryRun = args.includes("--dry-run") +const filePath = args.find((a) => !a.startsWith("--")) + +if (!filePath) { + console.error("Usage: bun script/unwrap-namespace.ts [--dry-run]") + process.exit(1) +} + +const absPath = path.resolve(filePath) +if (!fs.existsSync(absPath)) { + console.error(`File not found: ${absPath}`) + process.exit(1) +} + +const src = fs.readFileSync(absPath, "utf-8") +const lines = src.split("\n") + +// Use ast-grep to find the namespace boundaries accurately. +// This avoids false matches from braces in strings, templates, comments, etc. +const astResult = Bun.spawnSync( + ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath], + { stdout: "pipe", stderr: "pipe" }, +) + +if (astResult.exitCode !== 0) { + console.error("ast-grep failed:", astResult.stderr.toString()) + process.exit(1) +} + +const matches = JSON.parse(astResult.stdout.toString()) as Array<{ + text: string + range: { start: { line: number; column: number }; end: { line: number; column: number } } + metaVariables: { single: Record; multi: Record> } +}> + +if (matches.length === 0) { + console.error("No `export namespace Foo { ... }` found in file") + process.exit(1) +} + +if (matches.length > 1) { + console.error(`Found ${matches.length} namespaces — this script handles one at a time`) + console.error("Namespaces found:") + for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`) + process.exit(1) +} + +const match = matches[0] +const nsName = match.metaVariables.single.NAME.text +const nsLine = match.range.start.line // 0-indexed +const closeLine = match.range.end.line // 0-indexed, the line with closing `}` + +console.log(`Found: export namespace ${nsName} { ... }`) +console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`) + +// Build the new file content: +// 1. Everything before the namespace declaration (imports, etc.) +// 2. The namespace body, dedented by one level (2 spaces) +// 3. Everything after the closing brace (rare, but possible) +const before = lines.slice(0, nsLine) +const body = lines.slice(nsLine + 1, closeLine) +const after = lines.slice(closeLine + 1) + +// Dedent: remove exactly 2 leading spaces from each line +const dedented = body.map((line) => { + if (line === "") return "" + if (line.startsWith(" ")) return line.slice(2) + return line // don't touch lines that aren't indented (shouldn't happen) +}) + +const newContent = [...before, ...dedented, ...after].join("\n") + +// Figure out file naming +const dir = path.dirname(absPath) +const basename = path.basename(absPath, ".ts") +const isIndex = basename === "index" + +// The implementation file name (lowercase namespace name if currently index.ts) +const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename +const implFile = path.join(dir, `${implName}.ts`) +const indexFile = path.join(dir, "index.ts") + +// The barrel line +const barrelLine = `export * as ${nsName} from "./${implName}"\n` + +console.log("") +if (isIndex) { + console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`) +} else { + console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`) +} +console.log("") + +if (dryRun) { + console.log("--- DRY RUN ---") + console.log("") + console.log(`=== ${implName}.ts (first 30 lines) ===`) + newContent + .split("\n") + .slice(0, 30) + .forEach((l, i) => console.log(` ${i + 1}: ${l}`)) + console.log(" ...") + console.log("") + console.log(`=== index.ts ===`) + console.log(` ${barrelLine.trim()}`) +} else { + // Write the implementation file + if (isIndex) { + // Rename: write new content to implFile, then overwrite index.ts with barrel + fs.writeFileSync(implFile, newContent) + fs.writeFileSync(indexFile, barrelLine) + console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`) + console.log(`Wrote index.ts (barrel)`) + } else { + // Rewrite in place, create index.ts + fs.writeFileSync(absPath, newContent) + if (fs.existsSync(indexFile)) { + // Append to existing barrel + const existing = fs.readFileSync(indexFile, "utf-8") + if (!existing.includes(`export * as ${nsName}`)) { + fs.appendFileSync(indexFile, barrelLine) + console.log(`Appended to existing index.ts`) + } else { + console.log(`index.ts already has ${nsName} export`) + } + } else { + fs.writeFileSync(indexFile, barrelLine) + console.log(`Wrote index.ts (barrel)`) + } + console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) + } +} + +// Print the import rewrite guidance +const relDir = path.relative(path.resolve("src"), dir) + +console.log("") +console.log("=== Import rewrites ===") +console.log("") + +if (!isIndex) { + // Non-index files: imports like "../provider/provider" need to become "../provider" + const oldTail = `${relDir}/${basename}` + + console.log(`# Find all imports to rewrite:`) + console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`) + console.log("") + + // Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences) + console.log("# Auto-rewrite (review diff afterward):") + console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`) + console.log("") + console.log("# What changes:") + console.log(`# import { ${nsName} } from ".../${oldTail}"`) + console.log(`# import { ${nsName} } from ".../${relDir}"`) +} else { + console.log("# File was index.ts — import paths already resolve correctly.") + console.log("# No import rewrites needed!") +} + +console.log("") +console.log("=== Verify ===") +console.log("") +console.log("bun typecheck # from packages/opencode") +console.log("bun run test # run tests") diff --git a/packages/opencode/specs/effect/namespace-treeshake.md b/packages/opencode/specs/effect/namespace-treeshake.md new file mode 100644 index 0000000000..8a9cf94fd4 --- /dev/null +++ b/packages/opencode/specs/effect/namespace-treeshake.md @@ -0,0 +1,444 @@ +# Namespace → flat export migration + +Migrate `export namespace` to the `export * as` / flat-export pattern used by +effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect +conventions, LLM-friendliness for future migrations. + +## What changes and what doesn't + +The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`, +`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved. + +What changes is **how** the namespace is constructed — the TypeScript +`export namespace` keyword is replaced by `export * as` in a barrel file. This +is a mechanical change: unwrap the namespace body into flat exports, add a +one-line barrel. Consumers that import `{ Provider }` don't notice. + +Import paths actually get **nicer**. Today most consumers import from the +explicit file (`"../provider/provider"`). After the migration, each module has a +barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`: + +```ts +// BEFORE — points at the file directly +import { Provider } from "../provider/provider" + +// AFTER — resolves to provider/index.ts, same Provider namespace +import { Provider } from "../provider" +``` + +## Why this matters right now + +The CLI binary startup time (TOI) is too slow. Profiling shows we're loading +massive dependency graphs that are never actually used at runtime — because +bundlers cannot tree-shake TypeScript `export namespace` bodies. + +### The problem in one sentence + +`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but +importing `{ Provider }` from `provider.ts` forces the bundler to include **all +20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`, +`google-auth-library`, and every other top-level import in that 1709-line file. + +### Why `export namespace` defeats tree-shaking + +TypeScript compiles `export namespace Foo { ... }` to an IIFE: + +```js +// TypeScript output +export var Provider; +(function (Provider) { + Provider.ModelNotFoundError = NamedError.create(...) + // ... 1600 more lines of assignments ... +})(Provider || (Provider = {})) +``` + +This is **opaque to static analysis**. The bundler sees one big function call +whose return value populates an object. It cannot determine which properties are +used downstream, so it keeps everything. Every `import` statement at the top of +`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into +memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`. + +### What `export * as` does differently + +`export * as Provider from "./provider"` compiles to a static re-export. The +bundler knows the exact shape of `Provider` at compile time — it's the named +export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used +but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't +reference `createAnthropic` or any AI SDK import, and drop them. The namespace +object still exists at runtime — same API — but the bundler can see inside it. + +### Concrete impact + +The worst import chain in the codebase: + +``` +src/index.ts (entry point) + └── FormatError from src/cli/error.ts + ├── { Provider } from provider/provider.ts (1709 lines) + │ ├── 20+ @ai-sdk/* packages + │ ├── @aws-sdk/credential-providers + │ ├── google-auth-library + │ ├── gitlab-ai-provider, venice-ai-sdk-provider + │ └── fuzzysort, remeda, etc. + ├── { Config } from config/config.ts (1663 lines) + │ ├── jsonc-parser + │ ├── LSPServer (all server definitions) + │ └── Plugin, Auth, Env, Account, etc. + └── { MCP } from mcp/index.ts (930 lines) + ├── @modelcontextprotocol/sdk (3 transports) + └── open (browser launcher) +``` + +All of this gets pulled in to check `.isInstance()` on 6 error classes — code +that needs maybe 200 bytes total. This inflates the binary, increases startup +memory, and slows down initial module evaluation. + +### Why this also hurts memory + +Every module-level import is eagerly evaluated. Even with Bun's fast module +loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and +Google's auth library allocates objects, closures, and prototype chains that +persist for the lifetime of the process. Most CLI commands never use a provider +at all. + +## What effect-smol does + +effect-smol achieves tree-shakeable namespaced APIs via three structural choices. + +### 1. Each module is a separate file with flat named exports + +```ts +// Effect.ts — no namespace wrapper, just flat exports +export const gen: { ... } = internal.gen +export const fail: (error: E) => Effect = internal.fail +export const succeed: (value: A) => Effect = internal.succeed +// ... 230+ individual named exports +``` + +### 2. Barrel file uses `export * as` (not `export namespace`) + +```ts +// index.ts +export * as Effect from "./Effect.ts" +export * as Schema from "./Schema.ts" +export * as Stream from "./Stream.ts" +// ~134 modules +``` + +This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the +bundler knows the **exact shape** at compile time — it's the static export list +of that file. It can trace property accesses (`Effect.gen` → keep `gen`, +drop `timeout` if unused). With `export namespace`, the IIFE is opaque and +nothing can be dropped. + +### 3. `sideEffects: []` and deep imports + +```jsonc +// package.json +{ "sideEffects": [] } +``` + +Plus `"./*": "./src/*.ts"` in the exports map, enabling +`import * as Effect from "effect/Effect"` to bypass the barrel entirely. + +### 4. Errors as flat exports, not class declarations + +```ts +// Cause.ts +export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId +export interface NoSuchElementError extends YieldableError { ... } +export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError +export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError +``` + +Each error is 4 independent exports: TypeId, interface, constructor (as const), +type guard. All individually shakeable. + +## The plan + +The core migration is **Phase 1** — convert `export namespace` to +`export * as`. Once that's done, the bundler can tree-shake individual exports +within each module. You do NOT need to break things into subfiles for +tree-shaking to work — the bundler traces which exports you actually access on +the namespace object and drops the rest, including their transitive imports. + +Splitting errors/schemas into separate files (Phase 0) is optional — it's a +lower-risk warmup step that can be done before or after the main conversion, and +it provides extra resilience against bundler edge cases. But the big win comes +from Phase 1. + +### Phase 0 (optional): Pre-split errors into subfiles + +This is a low-risk warmup that provides immediate benefit even before the full +`export * as` conversion. It's optional because Phase 1 alone is sufficient for +tree-shaking. But it's a good starting point if you want incremental progress: + +**For each namespace that defines errors** (15 files, ~30 error classes total): + +1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error + definitions as top-level named exports: + + ```ts + // provider/errors.ts + import z from "zod" + import { NamedError } from "@opencode-ai/shared/util/error" + import { ProviderID, ModelID } from "./schema" + + export const ModelNotFoundError = NamedError.create( + "ProviderModelNotFoundError", + z.object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, + suggestions: z.array(z.string()).optional(), + }), + ) + + export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod })) + ``` + +2. In the namespace file, re-export from the errors file to maintain backward + compatibility: + + ```ts + // provider/provider.ts — inside the namespace + export { ModelNotFoundError, InitError } from "./errors" + ``` + +3. Update `cli/error.ts` (and any other light consumers) to import directly: + + ```ts + // BEFORE + import { Provider } from "../provider/provider" + Provider.ModelNotFoundError.isInstance(input) + + // AFTER + import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors" + ProviderModelNotFoundError.isInstance(input) + ``` + +**Files to split (Phase 0):** + +| Current file | New errors file | Errors to extract | +| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError | +| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed | +| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts | +| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError | +| `mcp/index.ts` | `mcp/errors.ts` | Failed | +| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError | +| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError | +| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError | +| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError | +| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError | +| `storage/storage.ts` | `storage/errors.ts` | NotFoundError | +| `npm/index.ts` | `npm/errors.ts` | InstallFailedError | +| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError | +| `lsp/client.ts` | `lsp/errors.ts` | InitializeError | + +### Phase 1: The real migration — `export namespace` → `export * as` + +This is the phase that actually fixes tree-shaking. For each module: + +1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper, + keep all the members as top-level `export const` / `export function` / etc. +2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` → + `bus/bus.ts`), so the barrel can take `index.ts`. +3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"` + +The file structure change for a module that's currently a single file: + +``` +# BEFORE +provider/ + provider.ts ← 1709-line file with `export namespace Provider { ... }` + +# AFTER +provider/ + index.ts ← NEW: `export * as Provider from "./provider"` + provider.ts ← SAME file, same name, just unwrap the namespace +``` + +And the code change is purely removing the wrapper: + +```ts +// BEFORE: provider/provider.ts +export namespace Provider { + export class Service extends Context.Service<...>()("@opencode/Provider") {} + export const layer = Layer.effect(Service, ...) + export const ModelNotFoundError = NamedError.create(...) + export function parseModel(model: string) { ... } +} + +// AFTER: provider/provider.ts — identical exports, no namespace keyword +export class Service extends Context.Service<...>()("@opencode/Provider") {} +export const layer = Layer.effect(Service, ...) +export const ModelNotFoundError = NamedError.create(...) +export function parseModel(model: string) { ... } +``` + +```ts +// NEW: provider/index.ts +export * as Provider from "./provider" +``` + +Consumer code barely changes — import path gets shorter: + +```ts +// BEFORE +import { Provider } from "../provider/provider" + +// AFTER — resolves to provider/index.ts, same Provider object +import { Provider } from "../provider" +``` + +All access like `Provider.ModelNotFoundError`, `Provider.Service`, +`Provider.layer` works exactly as before. The difference is invisible to +consumers but lets the bundler see inside the namespace. + +**Once this is done, you don't need to break anything into subfiles for +tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only +depends on `NamedError` + `zod` + the schema file, and drops +`Provider.layer` + all 20 AI SDK imports when they're unused. This works because +`export * as` gives the bundler a static export list it can do inner-graph +analysis on — it knows which exports reference which imports. + +**Order of conversion** (by risk / size, do small modules first): + +1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each) +2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines) +3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project` +4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP` + +### Phase 2: Build configuration + +After the module structure supports tree-shaking: + +1. Add `"sideEffects": []` to `packages/opencode/package.json` (or + `"sideEffects": false`) — this is safe because our services use explicit + layer composition, not import-time side effects. +2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is + insufficient, evaluate whether the compiled binary path needs an esbuild + pre-pass. +3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls + — these are factory functions that return classes, and bundlers may not know + they're side-effect-free without the annotation. + +## Automation + +The transformation is scripted. From `packages/opencode`: + +```bash +bun script/unwrap-namespace.ts [--dry-run] +``` + +The script uses ast-grep for accurate AST-based namespace boundary detection +(no false matches from braces in strings/templates/comments), then: + +1. Removes the `export namespace Foo {` line and its closing `}` +2. Dedents the body by one indent level (2 spaces) +3. If the file is `index.ts`, renames it to `.ts` and creates a new + `index.ts` barrel +4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts` +5. Prints the exact commands to find and rewrite import paths + +### Walkthrough: converting a module + +Using `Provider` as an example: + +```bash +# 1. Preview what will change +bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run + +# 2. Apply the transformation +bun script/unwrap-namespace.ts src/provider/provider.ts + +# 3. Rewrite import paths (script prints the exact command) +rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g' + +# 4. Verify +bun typecheck +bun run test +``` + +**What changes on disk:** + +``` +# BEFORE +provider/ + provider.ts ← 1709 lines, `export namespace Provider { ... }` + +# AFTER +provider/ + index.ts ← NEW: `export * as Provider from "./provider"` + provider.ts ← same file, namespace unwrapped to flat exports +``` + +**What changes in consumer code:** + +```ts +// BEFORE +import { Provider } from "../provider/provider" + +// AFTER — shorter path, same Provider object +import { Provider } from "../provider" +``` + +All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.) +stays identical. + +### Two cases the script handles + +**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`) + +- Rewrites the file in place (unwrap + dedent) +- Creates `provider/index.ts` as the barrel +- Import paths change: `"../provider/provider"` → `"../provider"` + +**Case B: file IS `index.ts`** (e.g. `bus/index.ts`) + +- Renames `index.ts` → `bus.ts` (kebab-case of namespace name) +- Creates new `index.ts` as the barrel +- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts` + +## Do I need to split errors/schemas into subfiles? + +**No.** Once you do the `export * as` conversion, the bundler can tree-shake +individual exports within the file. If `cli/error.ts` only accesses +`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError` +doesn't reference `createAnthropic` and drops the AI SDK imports. + +Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code +organization** — smaller files are easier to read and review. But it's not +required for tree-shaking. The `export * as` conversion alone is sufficient. + +The one case where subfile splitting provides extra tree-shake value is if an +imported package has module-level side effects that the bundler can't prove are +unused. In practice this is rare — most npm packages are side-effect-free — and +adding `"sideEffects": []` to package.json handles the common cases. + +## Scope + +| Metric | Count | +| ----------------------------------------------- | --------------- | +| Files with `export namespace` | 106 | +| Total namespace declarations | 118 (12 nested) | +| Files with `NamedError.create` inside namespace | 15 | +| Total error classes to extract | ~30 | +| Files using `export * as` today | 0 | + +Phase 1 (the `export * as` conversion) is the main change. It's mechanical and +LLM-friendly but touches every import site, so it should be done module by +module with type-checking between each step. Each module is an independent PR. + +## Rules for new code + +Going forward: + +- **No new `export namespace`**. Use a file with flat named exports and + `export * as` in the barrel. +- Keep the service, layer, errors, schemas, and runtime wiring together in one + file if you want — that's fine now. The `export * as` barrel makes everything + individually shakeable regardless of file structure. +- If a file grows large enough that it's hard to navigate, split by concern + (errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the + bundler handles that. diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts new file mode 100644 index 0000000000..c5e31e6c20 --- /dev/null +++ b/packages/opencode/src/bus/bus.ts @@ -0,0 +1,192 @@ +import z from "zod" +import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { Log } from "../util/log" +import { BusEvent } from "./bus-event" +import { GlobalBus } from "./global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" + +const log = Log.create({ service: "bus" }) + +export const InstanceDisposed = BusEvent.define( + "server.instance.disposed", + z.object({ + directory: z.string(), + }), +) + +type Payload = { + type: D["type"] + properties: z.infer +} + +type State = { + wildcard: PubSub.PubSub + typed: Map> +} + +export interface Interface { + readonly publish: ( + def: D, + properties: z.output, + ) => Effect.Effect + readonly subscribe: (def: D) => Stream.Stream> + readonly subscribeAll: () => Stream.Stream + readonly subscribeCallback: ( + def: D, + callback: (event: Payload) => unknown, + ) => Effect.Effect<() => void> + readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> +} + +export class Service extends Context.Service()("@opencode/Bus") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Bus.state")(function* (ctx) { + const wildcard = yield* PubSub.unbounded() + const typed = new Map>() + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + // Publish InstanceDisposed before shutting down so subscribers see it + yield* PubSub.publish(wildcard, { + type: InstanceDisposed.type, + properties: { directory: ctx.directory }, + }) + yield* PubSub.shutdown(wildcard) + for (const ps of typed.values()) { + yield* PubSub.shutdown(ps) + } + }), + ) + + return { wildcard, typed } + }), + ) + + function getOrCreate(state: State, def: D) { + return Effect.gen(function* () { + let ps = state.typed.get(def.type) + if (!ps) { + ps = yield* PubSub.unbounded() + state.typed.set(def.type, ps) + } + return ps as unknown as PubSub.PubSub> + }) + } + + function publish(def: D, properties: z.output) { + return Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const payload: Payload = { type: def.type, properties } + log.info("publishing", { type: def.type }) + + const ps = s.typed.get(def.type) + if (ps) yield* PubSub.publish(ps, payload) + yield* PubSub.publish(s.wildcard, payload) + + const dir = yield* InstanceState.directory + const context = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + + GlobalBus.emit("event", { + directory: dir, + project: context.project.id, + workspace, + payload, + }) + }) + } + + function subscribe(def: D): Stream.Stream> { + log.info("subscribing", { type: def.type }) + return Stream.unwrap( + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const ps = yield* getOrCreate(s, def) + return Stream.fromPubSub(ps) + }), + ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) + } + + function subscribeAll(): Stream.Stream { + log.info("subscribing", { type: "*" }) + return Stream.unwrap( + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + return Stream.fromPubSub(s.wildcard) + }), + ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" })))) + } + + function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { + return Effect.gen(function* () { + log.info("subscribing", { type }) + const bridge = yield* EffectBridge.make() + const scope = yield* Scope.make() + const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) + + yield* Scope.provide(scope)( + Stream.fromSubscription(subscription).pipe( + Stream.runForEach((msg) => + Effect.tryPromise({ + try: () => Promise.resolve().then(() => callback(msg)), + catch: (cause) => { + log.error("subscriber failed", { type, cause }) + }, + }).pipe(Effect.ignore), + ), + Effect.forkScoped, + ), + ) + + return () => { + log.info("unsubscribing", { type }) + bridge.fork(Scope.close(scope, Exit.void)) + } + }) + } + + const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( + def: D, + callback: (event: Payload) => unknown, + ) { + const s = yield* InstanceState.get(state) + const ps = yield* getOrCreate(s, def) + return yield* on(ps, def.type, callback) + }) + + const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { + const s = yield* InstanceState.get(state) + return yield* on(s.wildcard, "*", callback) + }) + + return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) + }), +) + +export const defaultLayer = layer + +const { runPromise, runSync } = makeRuntime(Service, layer) + +// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, +// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. +export async function publish(def: D, properties: z.output) { + return runPromise((svc) => svc.publish(def, properties)) +} + +export function subscribe( + def: D, + callback: (event: { type: D["type"]; properties: z.infer }) => unknown, +) { + return runSync((svc) => svc.subscribeCallback(def, callback)) +} + +export function subscribeAll(callback: (event: any) => unknown) { + return runSync((svc) => svc.subscribeAllCallback(callback)) +} diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 3a1eea5c73..3c21d7c7d1 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,194 +1 @@ -import z from "zod" -import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { Log } from "../util/log" -import { BusEvent } from "./bus-event" -import { GlobalBus } from "./global" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" - -export namespace Bus { - const log = Log.create({ service: "bus" }) - - export const InstanceDisposed = BusEvent.define( - "server.instance.disposed", - z.object({ - directory: z.string(), - }), - ) - - type Payload = { - type: D["type"] - properties: z.infer - } - - type State = { - wildcard: PubSub.PubSub - typed: Map> - } - - export interface Interface { - readonly publish: ( - def: D, - properties: z.output, - ) => Effect.Effect - readonly subscribe: (def: D) => Stream.Stream> - readonly subscribeAll: () => Stream.Stream - readonly subscribeCallback: ( - def: D, - callback: (event: Payload) => unknown, - ) => Effect.Effect<() => void> - readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> - } - - export class Service extends Context.Service()("@opencode/Bus") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Bus.state")(function* (ctx) { - const wildcard = yield* PubSub.unbounded() - const typed = new Map>() - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - // Publish InstanceDisposed before shutting down so subscribers see it - yield* PubSub.publish(wildcard, { - type: InstanceDisposed.type, - properties: { directory: ctx.directory }, - }) - yield* PubSub.shutdown(wildcard) - for (const ps of typed.values()) { - yield* PubSub.shutdown(ps) - } - }), - ) - - return { wildcard, typed } - }), - ) - - function getOrCreate(state: State, def: D) { - return Effect.gen(function* () { - let ps = state.typed.get(def.type) - if (!ps) { - ps = yield* PubSub.unbounded() - state.typed.set(def.type, ps) - } - return ps as unknown as PubSub.PubSub> - }) - } - - function publish(def: D, properties: z.output) { - return Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } - log.info("publishing", { type: def.type }) - - const ps = s.typed.get(def.type) - if (ps) yield* PubSub.publish(ps, payload) - yield* PubSub.publish(s.wildcard, payload) - - const dir = yield* InstanceState.directory - const context = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - - GlobalBus.emit("event", { - directory: dir, - project: context.project.id, - workspace, - payload, - }) - }) - } - - function subscribe(def: D): Stream.Stream> { - log.info("subscribing", { type: def.type }) - return Stream.unwrap( - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return Stream.fromPubSub(ps) - }), - ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) - } - - function subscribeAll(): Stream.Stream { - log.info("subscribing", { type: "*" }) - return Stream.unwrap( - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - return Stream.fromPubSub(s.wildcard) - }), - ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" })))) - } - - function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { - return Effect.gen(function* () { - log.info("subscribing", { type }) - const bridge = yield* EffectBridge.make() - const scope = yield* Scope.make() - const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) - - yield* Scope.provide(scope)( - Stream.fromSubscription(subscription).pipe( - Stream.runForEach((msg) => - Effect.tryPromise({ - try: () => Promise.resolve().then(() => callback(msg)), - catch: (cause) => { - log.error("subscriber failed", { type, cause }) - }, - }).pipe(Effect.ignore), - ), - Effect.forkScoped, - ), - ) - - return () => { - log.info("unsubscribing", { type }) - bridge.fork(Scope.close(scope, Exit.void)) - } - }) - } - - const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( - def: D, - callback: (event: Payload) => unknown, - ) { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return yield* on(ps, def.type, callback) - }) - - const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { - const s = yield* InstanceState.get(state) - return yield* on(s.wildcard, "*", callback) - }) - - return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) - }), - ) - - export const defaultLayer = layer - - const { runPromise, runSync } = makeRuntime(Service, layer) - - // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, - // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. - export async function publish(def: D, properties: z.output) { - return runPromise((svc) => svc.publish(def, properties)) - } - - export function subscribe( - def: D, - callback: (event: { type: D["type"]; properties: z.infer }) => unknown, - ) { - return runSync((svc) => svc.subscribeCallback(def, callback)) - } - - export function subscribeAll(callback: (event: any) => unknown) { - return runSync((svc) => svc.subscribeAllCallback(callback)) - } -} +export * as Bus from "./bus" From 0fb0135e514216732b982a9b302ee5e10f3c8e51 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:22:18 -0400 Subject: [PATCH 05/75] refactor: remove makeRuntime facades from File and Ripgrep (#22513) --- packages/opencode/src/cli/cmd/debug/file.ts | 33 +-- .../opencode/src/cli/cmd/debug/ripgrep.ts | 29 ++- packages/opencode/src/file/index.ts | 24 +- packages/opencode/src/file/ripgrep.ts | 16 +- packages/opencode/test/file/ripgrep.test.ts | 241 +++++++----------- 5 files changed, 130 insertions(+), 213 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index d5e24a0cfa..8e4eaa4e4d 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,10 +1,9 @@ import { EOL } from "os" -import { Effect } from "effect" import { AppRuntime } from "@/effect/app-runtime" import { File } from "../../../file" +import { Ripgrep } from "@/file/ripgrep" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" -import { Ripgrep } from "@/file/ripgrep" const FileSearchCommand = cmd({ command: "search ", @@ -17,11 +16,7 @@ const FileSearchCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.search({ query: args.query })) - }), - ) + const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) process.stdout.write(results.join(EOL) + EOL) }) }, @@ -38,11 +33,7 @@ const FileReadCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.read(args.path)) - }), - ) + const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) process.stdout.write(JSON.stringify(content, null, 2) + EOL) }) }, @@ -54,11 +45,7 @@ const FileStatusCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.status()) - }), - ) + const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) process.stdout.write(JSON.stringify(status, null, 2) + EOL) }) }, @@ -75,11 +62,7 @@ const FileListCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.list(args.path)) - }), - ) + const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) process.stdout.write(JSON.stringify(files, null, 2) + EOL) }) }, @@ -95,8 +78,10 @@ const FileTreeCommand = cmd({ default: process.cwd(), }), async handler(args) { - const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 }) - console.log(JSON.stringify(files, null, 2)) + await bootstrap(process.cwd(), async () => { + const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) + }) }, }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 8c994d6e52..9b7e826915 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,4 +1,5 @@ import { EOL } from "os" +import { Effect, Stream } from "effect" import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" import { Instance } from "../../../project/instance" @@ -21,7 +22,10 @@ const TreeCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) + const tree = await AppRuntime.runPromise( + Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), + ) + process.stdout.write(tree + EOL) }) }, }) @@ -45,14 +49,21 @@ const FilesCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files: string[] = [] - for await (const file of await Ripgrep.files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - })) { - files.push(file) - if (args.limit && files.length >= args.limit) break - } + const files = await AppRuntime.runPromise( + Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg + .files({ + cwd: Instance.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + ) + }), + ) process.stdout.write(files.join(EOL) + EOL) }) }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 113dc59096..909f1e61d2 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" + import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" import { Effect, Layer, Context } from "effect" @@ -653,26 +653,4 @@ export namespace File { Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function init() { - return runPromise((svc) => svc.init()) - } - - export async function status() { - return runPromise((svc) => svc.status()) - } - - export async function read(file: string): Promise { - return runPromise((svc) => svc.read(file)) - } - - export async function list(dir?: string) { - return runPromise((svc) => svc.list(dir)) - } - - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index abf7438dcc..fee9cf4430 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import z from "zod" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { ripgrep } from "ripgrep" -import { makeRuntime } from "@/effect/run-service" + import { Filesystem } from "@/util/filesystem" import { Log } from "@/util/log" @@ -572,18 +572,4 @@ export namespace Ripgrep { ) export const defaultLayer = layer - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function files(input: FilesInput) { - return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input))) - } - - export function tree(input: TreeInput) { - return runPromise((svc) => svc.tree(input)) - } - - export function search(input: SearchInput) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index c3575fdf85..a76c7ebe26 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -6,20 +6,8 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" -async function seed(dir: string, count: number, size = 16) { - const txt = "a".repeat(size) - await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`))) -} - -function env(name: string, value: string | undefined) { - const prev = process.env[name] - if (value === undefined) delete process.env[name] - else process.env[name] = value - return () => { - if (prev === undefined) delete process.env[name] - else process.env[name] = prev - } -} +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) describe("file.ripgrep", () => { test("defaults to include hidden", async () => { @@ -31,7 +19,14 @@ describe("file.ripgrep", () => { }, }) - const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path })) + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) expect(files.includes("visible.txt")).toBe(true) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) }) @@ -45,7 +40,14 @@ describe("file.ripgrep", () => { }, }) - const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false })) + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, hidden: false }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) expect(files.includes("visible.txt")).toBe(true) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) }) @@ -57,7 +59,7 @@ describe("file.ripgrep", () => { }, }) - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) expect(result.partial).toBe(false) expect(result.items).toEqual([]) }) @@ -70,7 +72,7 @@ describe("file.ripgrep", () => { }, }) - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) @@ -78,99 +80,7 @@ describe("file.ripgrep", () => { expect(result.items[0]?.lines.text).toContain("needle") }) - test("files returns empty when glob matches no files in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) - await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") - }, - }) - - const ctl = new AbortController() - const files = await Array.fromAsync( - await Ripgrep.files({ - cwd: tmp.path, - glob: ["packages/*"], - signal: ctl.signal, - }), - ) - - expect(files).toEqual([]) - }) - - test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc")) - try { - const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) - expect(result.items).toHaveLength(1) - } finally { - restore() - } - }) - - test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") - }, - }) - - const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc")) - try { - const ctl = new AbortController() - const result = await Ripgrep.search({ - cwd: tmp.path, - pattern: "needle", - signal: ctl.signal, - }) - expect(result.items).toHaveLength(1) - } finally { - restore() - } - }) - - test("aborts files scan in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await seed(dir, 4000) - }, - }) - - const ctl = new AbortController() - const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal }) - const pending = Array.fromAsync(iter) - setTimeout(() => ctl.abort(), 0) - - const err = await pending.catch((err) => err) - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe("AbortError") - }, 15_000) - - test("aborts search in worker mode", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await seed(dir, 512, 64 * 1024) - }, - }) - - const ctl = new AbortController() - const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal }) - setTimeout(() => ctl.abort(), 0) - - const err = await pending.catch((err) => err) - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe("AbortError") - }, 15_000) -}) - -describe("Ripgrep.Service", () => { - test("search returns matched rows", async () => { + test("search returns matched rows with glob filter", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") @@ -178,11 +88,9 @@ describe("Ripgrep.Service", () => { }, }) - const result = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] }) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const result = await run( + Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })), + ) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toContain("match.ts") @@ -198,16 +106,31 @@ describe("Ripgrep.Service", () => { }) const file = path.join(tmp.path, "match.ts") - const result = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }))) expect(result.partial).toBe(false) expect(result.items).toHaveLength(1) expect(result.items[0]?.path.text).toBe(file) }) + test("files returns empty when glob matches no files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true }) + await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}") + }, + }) + + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) + expect(files).toEqual([]) + }) + test("files returns stream of filenames", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -216,14 +139,14 @@ describe("Ripgrep.Service", () => { }, }) - const files = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.files({ cwd: tmp.path }).pipe( - Stream.runCollect, - Effect.map((chunk) => [...chunk].sort()), - ) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path }).pipe( + Stream.runCollect, + Effect.map((c) => [...c].sort()), + ), + ), + ) expect(files).toEqual(["a.txt", "b.txt"]) }) @@ -235,23 +158,57 @@ describe("Ripgrep.Service", () => { }, }) - const files = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) - + const files = await run( + Ripgrep.Service.use((rg) => + rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( + Stream.runCollect, + Effect.map((c) => [...c]), + ), + ), + ) expect(files).toEqual(["keep.ts"]) }) test("files dies on nonexistent directory", async () => { - const exit = await Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect) - }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) - + const exit = await Ripgrep.Service.use((rg) => + rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect), + ).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) expect(exit._tag).toBe("Failure") }) + + test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") + }, + }) + + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") + try { + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + expect(result.items).toHaveLength(1) + } finally { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + } + }) + + test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n") + }, + }) + + const prev = process.env["RIPGREP_CONFIG_PATH"] + process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc") + try { + const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" }))) + expect(result.items).toHaveLength(1) + } finally { + if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"] + else process.env["RIPGREP_CONFIG_PATH"] = prev + } + }) }) From bbdbc107ae1f935f9694fc36b79c833643ee87a4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:26:24 -0400 Subject: [PATCH 06/75] feat: unwrap Config namespace to flat exports + barrel (#22689) --- packages/opencode/script/schema.ts | 2 +- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/config.ts | 2 +- packages/opencode/src/cli/cmd/mcp.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- .../src/cli/cmd/tui/plugin/runtime.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/error.ts | 2 +- packages/opencode/src/cli/network.ts | 2 +- packages/opencode/src/cli/upgrade.ts | 2 +- packages/opencode/src/command/index.ts | 2 +- packages/opencode/src/config/config.ts | 2125 ++++++++--------- packages/opencode/src/config/index.ts | 1 + packages/opencode/src/config/tui-schema.ts | 2 +- packages/opencode/src/config/tui.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/lsp/index.ts | 2 +- packages/opencode/src/mcp/index.ts | 2 +- packages/opencode/src/node.ts | 2 +- packages/opencode/src/permission/index.ts | 2 +- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/plugin/loader.ts | 2 +- packages/opencode/src/provider/provider.ts | 2 +- .../opencode/src/server/instance/config.ts | 2 +- .../src/server/instance/experimental.ts | 2 +- .../opencode/src/server/instance/global.ts | 2 +- packages/opencode/src/server/instance/mcp.ts | 2 +- .../opencode/src/server/instance/provider.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/overflow.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/share/session.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/skill/index.ts | 2 +- packages/opencode/src/snapshot/index.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/task.ts | 2 +- .../opencode/test/config/agent-color.test.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 +- packages/opencode/test/config/tui.test.ts | 2 +- packages/opencode/test/file/watcher.test.ts | 2 +- packages/opencode/test/fixture/fixture.ts | 2 +- .../opencode/test/permission-task.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 2 +- .../test/session/processor-effect.test.ts | 2 +- .../test/session/prompt-effect.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- .../opencode/test/share/share-next.test.ts | 2 +- packages/opencode/test/tool/task.test.ts | 2 +- 54 files changed, 1105 insertions(+), 1125 deletions(-) create mode 100644 packages/opencode/src/config/index.ts diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 61d11ea7c9..4ea68d9bbb 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { z } from "zod" -import { Config } from "../src/config/config" +import { Config } from "../src/config" import { TuiConfig } from "../src/config/tui" function generate(schema: z.ZodType) { diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5cbf4ed1f9..c065c64ffc 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -43,7 +43,7 @@ import { Agent as AgentModule } from "../agent/agent" import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" -import { Config } from "@/config/config" +import { Config } from "@/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ba38c8efe3..5887ee28e3 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,4 @@ -import { Config } from "../config/config" +import { Config } from "../config" import z from "zod" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index 59e29c4a38..b1f1c25e9c 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import { Config } from "../../../config/config" +import { Config } from "../../../config" import { AppRuntime } from "@/effect/app-runtime" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 3afedb356d..b9e4b04219 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -7,7 +7,7 @@ import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" -import { Config } from "../../config/config" +import { Config } from "../../config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" import path from "path" diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 6ab927e253..5b7f5a1a0d 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -7,7 +7,7 @@ import { ModelsDev } from "../../provider/models" import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" -import { Config } from "../../config/config" +import { Config } from "../../config" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 7f12106b2c..bd7eac7713 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -13,7 +13,7 @@ import { import path from "path" import { fileURLToPath } from "url" -import { Config } from "@/config/config" +import { Config } from "@/config" import { TuiConfig } from "@/config/tui" import { Log } from "@/util/log" import { errorData, errorMessage } from "@/util/error" diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 4e1bdabcdd..da9e3985b5 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -5,7 +5,7 @@ import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" -import { Config } from "@/config/config" +import { Config } from "@/config" import { GlobalBus } from "@/bus/global" import { Flag } from "@/flag/flag" import { writeHeapSnapshot } from "node:v8" diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index cd67635e9b..1277f5046c 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,7 +1,7 @@ import { AccountServiceError, AccountTransportError } from "@/account" import { ConfigMarkdown } from "@/config/markdown" import { errorFormat } from "@/util/error" -import { Config } from "../config/config" +import { Config } from "../config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" import { UI } from "./ui" diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index cea49affa5..6321c056d0 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,5 +1,5 @@ import type { Argv, InferredOptionTypes } from "yargs" -import { Config } from "../config/config" +import { Config } from "../config" import { AppRuntime } from "@/effect/app-runtime" const options = { diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index f67b662455..2628f9673f 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -1,5 +1,5 @@ import { Bus } from "@/bus" -import { Config } from "@/config/config" +import { Config } from "@/config" import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@/flag/flag" import { Installation } from "@/installation" diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 91a9e1b405..28fb37f272 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,7 +5,7 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context } from "effect" import z from "zod" -import { Config } from "../config/config" +import { Config } from "../config" import { MCP } from "../mcp" import { Skill } from "../skill" import { Log } from "../util/log" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6aee4e1dc8..f35e8c83df 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -39,1133 +39,1113 @@ import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from import { Npm } from "../npm" import { InstanceRef } from "@/effect/instance-ref" -export namespace Config { - const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) - const PluginOptions = z.record(z.string(), z.unknown()) - export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])]) +const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) +const PluginOptions = z.record(z.string(), z.unknown()) +export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])]) - export type PluginOptions = z.infer - export type PluginSpec = z.infer - export type PluginScope = "global" | "local" - export type PluginOrigin = { - spec: PluginSpec - source: string - scope: PluginScope +export type PluginOptions = z.infer +export type PluginSpec = z.infer +export type PluginScope = "global" | "local" +export type PluginOrigin = { + spec: PluginSpec + source: string + scope: PluginScope +} + +const log = Log.create({ service: "config" }) + +// Managed settings directory for enterprise deployments (highest priority, admin-controlled) +// These settings override all user and project settings +function systemManagedConfigDir(): string { + switch (process.platform) { + case "darwin": + return "/Library/Application Support/opencode" + case "win32": + return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") + default: + return "/etc/opencode" } +} - const log = Log.create({ service: "config" }) +export function managedConfigDir() { + return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir() +} - // Managed settings directory for enterprise deployments (highest priority, admin-controlled) - // These settings override all user and project settings - function systemManagedConfigDir(): string { - switch (process.platform) { - case "darwin": - return "/Library/Application Support/opencode" - case "win32": - return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") - default: - return "/etc/opencode" +const managedDir = managedConfigDir() + +const MANAGED_PLIST_DOMAIN = "ai.opencode.managed" + +// Keys injected by macOS/MDM into the managed plist that are not OpenCode config +const PLIST_META = new Set([ + "PayloadDisplayName", + "PayloadIdentifier", + "PayloadType", + "PayloadUUID", + "PayloadVersion", + "_manualProfile", +]) + +/** + * Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config. + * Strips MDM metadata keys before parsing through the config schema. + * Pure function — no OS interaction, safe to unit test directly. + */ +export function parseManagedPlist(json: string, source: string): Info { + const raw = JSON.parse(json) + for (const key of Object.keys(raw)) { + if (PLIST_META.has(key)) delete raw[key] + } + return parseConfig(JSON.stringify(raw), source) +} + +/** + * Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc). + * MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root. + * User-scoped plists are checked first, then machine-scoped. + */ +async function readManagedPreferences(): Promise { + if (process.platform !== "darwin") return {} + + const domain = MANAGED_PLIST_DOMAIN + const user = os.userInfo().username + const paths = [ + path.join("/Library/Managed Preferences", user, `${domain}.plist`), + path.join("/Library/Managed Preferences", `${domain}.plist`), + ] + + for (const plist of paths) { + if (!existsSync(plist)) continue + log.info("reading macOS managed preferences", { path: plist }) + const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true }) + if (result.code !== 0) { + log.warn("failed to convert managed preferences plist", { path: plist }) + continue } + return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`) } + return {} +} - export function managedConfigDir() { - return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir() +// Custom merge function that concatenates array fields instead of replacing them +function mergeConfigConcatArrays(target: Info, source: Info): Info { + const merged = mergeDeep(target, source) + if (target.instructions && source.instructions) { + merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + return merged +} - const managedDir = managedConfigDir() +export type InstallInput = { + waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise +} - const MANAGED_PLIST_DOMAIN = "ai.opencode.managed" +type Package = { + dependencies?: Record +} - // Keys injected by macOS/MDM into the managed plist that are not OpenCode config - const PLIST_META = new Set([ - "PayloadDisplayName", - "PayloadIdentifier", - "PayloadType", - "PayloadUUID", - "PayloadVersion", - "_manualProfile", - ]) - - /** - * Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config. - * Strips MDM metadata keys before parsing through the config schema. - * Pure function — no OS interaction, safe to unit test directly. - */ - export function parseManagedPlist(json: string, source: string): Info { - const raw = JSON.parse(json) - for (const key of Object.keys(raw)) { - if (PLIST_META.has(key)) delete raw[key] - } - return parseConfig(JSON.stringify(raw), source) +function rel(item: string, patterns: string[]) { + const normalizedItem = item.replaceAll("\\", "/") + for (const pattern of patterns) { + const index = normalizedItem.indexOf(pattern) + if (index === -1) continue + return normalizedItem.slice(index + pattern.length) } +} - /** - * Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc). - * MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root. - * User-scoped plists are checked first, then machine-scoped. - */ - async function readManagedPreferences(): Promise { - if (process.platform !== "darwin") return {} +function trim(file: string) { + const ext = path.extname(file) + return ext.length ? file.slice(0, -ext.length) : file +} - const domain = MANAGED_PLIST_DOMAIN - const user = os.userInfo().username - const paths = [ - path.join("/Library/Managed Preferences", user, `${domain}.plist`), - path.join("/Library/Managed Preferences", `${domain}.plist`), - ] - - for (const plist of paths) { - if (!existsSync(plist)) continue - log.info("reading macOS managed preferences", { path: plist }) - const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true }) - if (result.code !== 0) { - log.warn("failed to convert managed preferences plist", { path: plist }) - continue - } - return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`) - } - return {} - } - - // Custom merge function that concatenates array fields instead of replacing them - function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeDeep(target, source) - if (target.instructions && source.instructions) { - merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) - } - return merged - } - - export type InstallInput = { - waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise - } - - type Package = { - dependencies?: Record - } - - function rel(item: string, patterns: string[]) { - const normalizedItem = item.replaceAll("\\", "/") - for (const pattern of patterns) { - const index = normalizedItem.indexOf(pattern) - if (index === -1) continue - return normalizedItem.slice(index + pattern.length) - } - } - - function trim(file: string) { - const ext = path.extname(file) - return ext.length ? file.slice(0, -ext.length) : file - } - - async function loadCommand(dir: string) { - const result: Record = {} - for (const item of await Glob.scan("{command,commands}/**/*.md", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse command ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load command", { command: item, err }) - return undefined - }) - if (!md) continue - - const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) - const name = trim(file) - - const config = { - name, - ...md.data, - template: md.content.trim(), - } - const parsed = Command.safeParse(config) - if (parsed.success) { - result[config.name] = parsed.data - continue - } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) - } - return result - } - - async function loadAgent(dir: string) { - const result: Record = {} - - for (const item of await Glob.scan("{agent,agents}/**/*.md", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse agent ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) - return undefined - }) - if (!md) continue - - const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const file = rel(item, patterns) ?? path.basename(item) - const agentName = trim(file) - - const config = { - name: agentName, - ...md.data, - prompt: md.content.trim(), - } - const parsed = Agent.safeParse(config) - if (parsed.success) { - result[config.name] = parsed.data - continue - } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) - } - return result - } - - async function loadMode(dir: string) { - const result: Record = {} - for (const item of await Glob.scan("{mode,modes}/*.md", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse mode ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) - return undefined - }) - if (!md) continue - - const config = { - name: path.basename(item, ".md"), - ...md.data, - prompt: md.content.trim(), - } - const parsed = Agent.safeParse(config) - if (parsed.success) { - result[config.name] = { - ...parsed.data, - mode: "primary" as const, - } - continue - } - } - return result - } - - async function loadPlugin(dir: string) { - const plugins: PluginSpec[] = [] - - for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - plugins.push(pathToFileURL(item).href) - } - return plugins - } - - export function pluginSpecifier(plugin: PluginSpec): string { - return Array.isArray(plugin) ? plugin[0] : plugin - } - - export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined { - return Array.isArray(plugin) ? plugin[1] : undefined - } - - export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise { - const spec = pluginSpecifier(plugin) - if (!isPathPluginSpec(spec)) return plugin - - const base = path.dirname(configFilepath) - const file = (() => { - if (spec.startsWith("file://")) return spec - if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href - return pathToFileURL(path.resolve(base, spec)).href - })() - - const resolved = await resolvePathPluginTarget(file).catch(() => file) - - if (Array.isArray(plugin)) return [resolved, plugin[1]] - return resolved - } - - export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] { - const seen = new Set() - const list: PluginOrigin[] = [] - - for (const plugin of plugins.toReversed()) { - const spec = pluginSpecifier(plugin.spec) - const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg - if (seen.has(name)) continue - seen.add(name) - list.push(plugin) - } - - return list.toReversed() - } - - export const McpLocal = z - .object({ - type: z.literal("local").describe("Type of MCP server connection"), - command: z.string().array().describe("Command and arguments to run the MCP server"), - environment: z - .record(z.string(), z.string()) - .optional() - .describe("Environment variables to set when running the MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpLocalConfig", +async function loadCommand(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{command,commands}/**/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse command ${item}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load command", { command: item, err }) + return undefined }) + if (!md) continue - export const McpOAuth = z - .object({ - clientId: z - .string() - .optional() - .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), - clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), - scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), - }) - .strict() - .meta({ - ref: "McpOAuthConfig", - }) - export type McpOAuth = z.infer + const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] + const file = rel(item, patterns) ?? path.basename(item) + const name = trim(file) - export const McpRemote = z - .object({ - type: z.literal("remote").describe("Type of MCP server connection"), - url: z.string().describe("URL of the remote MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), - oauth: z - .union([McpOAuth, z.literal(false)]) - .optional() - .describe( - "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", - ), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpRemoteConfig", - }) + const config = { + name, + ...md.data, + template: md.content.trim(), + } + const parsed = Command.safeParse(config) + if (parsed.success) { + result[config.name] = parsed.data + continue + } + throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + } + return result +} - export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) - export type Mcp = z.infer +async function loadAgent(dir: string) { + const result: Record = {} - export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ - ref: "PermissionActionConfig", + for (const item of await Glob.scan("{agent,agents}/**/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse agent ${item}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load agent", { agent: item, err }) + return undefined + }) + if (!md) continue + + const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] + const file = rel(item, patterns) ?? path.basename(item) + const agentName = trim(file) + + const config = { + name: agentName, + ...md.data, + prompt: md.content.trim(), + } + const parsed = Agent.safeParse(config) + if (parsed.success) { + result[config.name] = parsed.data + continue + } + throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + } + return result +} + +async function loadMode(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{mode,modes}/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse mode ${item}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load mode", { mode: item, err }) + return undefined + }) + if (!md) continue + + const config = { + name: path.basename(item, ".md"), + ...md.data, + prompt: md.content.trim(), + } + const parsed = Agent.safeParse(config) + if (parsed.success) { + result[config.name] = { + ...parsed.data, + mode: "primary" as const, + } + continue + } + } + return result +} + +async function loadPlugin(dir: string) { + const plugins: PluginSpec[] = [] + + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) + } + return plugins +} + +export function pluginSpecifier(plugin: PluginSpec): string { + return Array.isArray(plugin) ? plugin[0] : plugin +} + +export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined +} + +export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved +} + +export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] { + const seen = new Set() + const list: PluginOrigin[] = [] + + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) + } + + return list.toReversed() +} + +export const McpLocal = z + .object({ + type: z.literal("local").describe("Type of MCP server connection"), + command: z.string().array().describe("Command and arguments to run the MCP server"), + environment: z + .record(z.string(), z.string()) + .optional() + .describe("Environment variables to set when running the MCP server"), + enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), }) - export type PermissionAction = z.infer - - export const PermissionObject = z.record(z.string(), PermissionAction).meta({ - ref: "PermissionObjectConfig", + .strict() + .meta({ + ref: "McpLocalConfig", }) - export type PermissionObject = z.infer - export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({ - ref: "PermissionRuleConfig", +export const McpOAuth = z + .object({ + clientId: z + .string() + .optional() + .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), + clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), + scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) - export type PermissionRule = z.infer + .strict() + .meta({ + ref: "McpOAuthConfig", + }) +export type McpOAuth = z.infer - // Capture original key order before zod reorders, then rebuild in original order - const permissionPreprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __originalKeys: Object.keys(val), ...val } - } - return val +export const McpRemote = z + .object({ + type: z.literal("remote").describe("Type of MCP server connection"), + url: z.string().describe("URL of the remote MCP server"), + enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), + headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), + oauth: z + .union([McpOAuth, z.literal(false)]) + .optional() + .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + }) + .strict() + .meta({ + ref: "McpRemoteConfig", + }) + +export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) +export type Mcp = z.infer + +export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ + ref: "PermissionActionConfig", +}) +export type PermissionAction = z.infer + +export const PermissionObject = z.record(z.string(), PermissionAction).meta({ + ref: "PermissionObjectConfig", +}) +export type PermissionObject = z.infer + +export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({ + ref: "PermissionRuleConfig", +}) +export type PermissionRule = z.infer + +// Capture original key order before zod reorders, then rebuild in original order +const permissionPreprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + return { __originalKeys: Object.keys(val), ...val } } + return val +} - const permissionTransform = (x: unknown): Record => { - if (typeof x === "string") return { "*": x as PermissionAction } - const obj = x as { __originalKeys?: string[] } & Record - const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record - const result: Record = {} - for (const key of __originalKeys) { - if (key in rest) result[key] = rest[key] as PermissionRule - } - return result +const permissionTransform = (x: unknown): Record => { + if (typeof x === "string") return { "*": x as PermissionAction } + const obj = x as { __originalKeys?: string[] } & Record + const { __originalKeys, ...rest } = obj + if (!__originalKeys) return rest as Record + const result: Record = {} + for (const key of __originalKeys) { + if (key in rest) result[key] = rest[key] as PermissionRule } + return result +} - export const Permission = z - .preprocess( - permissionPreprocess, - z - .object({ - __originalKeys: z.string().array().optional(), - read: PermissionRule.optional(), - edit: PermissionRule.optional(), - glob: PermissionRule.optional(), - grep: PermissionRule.optional(), - list: PermissionRule.optional(), - bash: PermissionRule.optional(), - task: PermissionRule.optional(), - external_directory: PermissionRule.optional(), - todowrite: PermissionAction.optional(), - question: PermissionAction.optional(), - webfetch: PermissionAction.optional(), - websearch: PermissionAction.optional(), - codesearch: PermissionAction.optional(), - lsp: PermissionRule.optional(), - doom_loop: PermissionAction.optional(), - skill: PermissionRule.optional(), - }) - .catchall(PermissionRule) - .or(PermissionAction), - ) - .transform(permissionTransform) - .meta({ - ref: "PermissionConfig", - }) - export type Permission = z.infer +export const Permission = z + .preprocess( + permissionPreprocess, + z + .object({ + __originalKeys: z.string().array().optional(), + read: PermissionRule.optional(), + edit: PermissionRule.optional(), + glob: PermissionRule.optional(), + grep: PermissionRule.optional(), + list: PermissionRule.optional(), + bash: PermissionRule.optional(), + task: PermissionRule.optional(), + external_directory: PermissionRule.optional(), + todowrite: PermissionAction.optional(), + question: PermissionAction.optional(), + webfetch: PermissionAction.optional(), + websearch: PermissionAction.optional(), + codesearch: PermissionAction.optional(), + lsp: PermissionRule.optional(), + doom_loop: PermissionAction.optional(), + skill: PermissionRule.optional(), + }) + .catchall(PermissionRule) + .or(PermissionAction), + ) + .transform(permissionTransform) + .meta({ + ref: "PermissionConfig", + }) +export type Permission = z.infer - export const Command = z.object({ - template: z.string(), - description: z.string().optional(), - agent: z.string().optional(), +export const Command = z.object({ + template: z.string(), + description: z.string().optional(), + agent: z.string().optional(), + model: ModelId.optional(), + subtask: z.boolean().optional(), +}) +export type Command = z.infer + +export const Skills = z.object({ + paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), + urls: z + .array(z.string()) + .optional() + .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), +}) +export type Skills = z.infer + +export const Agent = z + .object({ model: ModelId.optional(), - subtask: z.boolean().optional(), + variant: z + .string() + .optional() + .describe("Default model variant for this agent (applies only when using the agent's configured model)."), + temperature: z.number().optional(), + top_p: z.number().optional(), + prompt: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), + disable: z.boolean().optional(), + description: z.string().optional().describe("Description of when to use the agent"), + mode: z.enum(["subagent", "primary", "all"]).optional(), + hidden: z + .boolean() + .optional() + .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), + options: z.record(z.string(), z.any()).optional(), + color: z + .union([ + z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), + z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]), + ]) + .optional() + .describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"), + steps: z + .number() + .int() + .positive() + .optional() + .describe("Maximum number of agentic iterations before forcing text-only response"), + maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), + permission: Permission.optional(), }) - export type Command = z.infer + .catchall(z.any()) + .transform((agent, ctx) => { + const knownKeys = new Set([ + "name", + "model", + "variant", + "prompt", + "description", + "temperature", + "top_p", + "mode", + "hidden", + "color", + "steps", + "maxSteps", + "options", + "permission", + "disable", + "tools", + ]) - export const Skills = z.object({ - paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), - urls: z + // Extract unknown properties into options + const options: Record = { ...agent.options } + for (const [key, value] of Object.entries(agent)) { + if (!knownKeys.has(key)) options[key] = value + } + + // Convert legacy tools config to permissions + const permission: Permission = {} + for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { + const action = enabled ? "allow" : "deny" + // write, edit, patch, multiedit all map to edit permission + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + permission.edit = action + } else { + permission[tool] = action + } + } + Object.assign(permission, agent.permission) + + // Convert legacy maxSteps to steps + const steps = agent.steps ?? agent.maxSteps + + return { ...agent, options, permission, steps } as typeof agent & { + options?: Record + permission?: Permission + steps?: number + } + }) + .meta({ + ref: "AgentConfig", + }) +export type Agent = z.infer + +export const Keybinds = z + .object({ + leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), + editor_open: z.string().optional().default("e").describe("Open external editor"), + theme_list: z.string().optional().default("t").describe("List available themes"), + sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), + scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), + username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + status_view: z.string().optional().default("s").describe("View status"), + session_export: z.string().optional().default("x").describe("Export session to editor"), + session_new: z.string().optional().default("n").describe("Create a new session"), + session_list: z.string().optional().default("l").describe("List all sessions"), + session_timeline: z.string().optional().default("g").describe("Show session timeline"), + session_fork: z.string().optional().default("none").describe("Fork session from message"), + session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), + session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), + stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), + model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), + model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_share: z.string().optional().default("none").describe("Share current session"), + session_unshare: z.string().optional().default("none").describe("Unshare current session"), + session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), + session_compact: z.string().optional().default("c").describe("Compact the session"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), + messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"), + messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), + messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), + messages_next: z.string().optional().default("none").describe("Navigate to next message"), + messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), + messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), + messages_copy: z.string().optional().default("y").describe("Copy message"), + messages_undo: z.string().optional().default("u").describe("Undo message"), + messages_redo: z.string().optional().default("r").describe("Redo message"), + messages_toggle_conceal: z + .string() + .optional() + .default("h") + .describe("Toggle code block concealment in messages"), + tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), + model_list: z.string().optional().default("m").describe("List available models"), + model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), + model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), + command_list: z.string().optional().default("ctrl+p").describe("List available commands"), + agent_list: z.string().optional().default("a").describe("List agents"), + agent_cycle: z.string().optional().default("tab").describe("Next agent"), + agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), + variant_list: z.string().optional().default("none").describe("List model variants"), + input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), + input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_submit: z.string().optional().default("return").describe("Submit input"), + input_newline: z + .string() + .optional() + .default("shift+return,ctrl+return,alt+return,ctrl+j") + .describe("Insert newline in input"), + input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), + input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), + input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), + input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), + input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), + input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), + input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), + input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), + input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), + input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), + input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"), + input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), + input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), + input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), + input_select_visual_line_home: z + .string() + .optional() + .default("alt+shift+a") + .describe("Select to start of visual line in input"), + input_select_visual_line_end: z + .string() + .optional() + .default("alt+shift+e") + .describe("Select to end of visual line in input"), + input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), + input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), + input_select_buffer_home: z + .string() + .optional() + .default("shift+home") + .describe("Select to start of buffer in input"), + input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), + input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), + input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), + input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), + input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), + input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), + input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), + input_word_forward: z + .string() + .optional() + .default("alt+f,alt+right,ctrl+right") + .describe("Move word forward in input"), + input_word_backward: z + .string() + .optional() + .default("alt+b,alt+left,ctrl+left") + .describe("Move word backward in input"), + input_select_word_forward: z + .string() + .optional() + .default("alt+shift+f,alt+shift+right") + .describe("Select word forward in input"), + input_select_word_backward: z + .string() + .optional() + .default("alt+shift+b,alt+shift+left") + .describe("Select word backward in input"), + input_delete_word_forward: z + .string() + .optional() + .default("alt+d,alt+delete,ctrl+delete") + .describe("Delete word forward in input"), + input_delete_word_backward: z + .string() + .optional() + .default("ctrl+w,ctrl+backspace,alt+backspace") + .describe("Delete word backward in input"), + history_previous: z.string().optional().default("up").describe("Previous history item"), + history_next: z.string().optional().default("down").describe("Next history item"), + session_child_first: z.string().optional().default("down").describe("Go to first child session"), + session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), + session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), + session_parent: z.string().optional().default("up").describe("Go to parent session"), + terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), + tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), + plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), + display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), + }) + .strict() + .meta({ + ref: "KeybindsConfig", + }) + +export const Server = z + .object({ + port: z.number().int().positive().optional().describe("Port to listen on"), + hostname: z.string().optional().describe("Hostname to listen on"), + mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), + cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), + }) + .strict() + .meta({ + ref: "ServerConfig", + }) + +export const Layout = z.enum(["auto", "stretch"]).meta({ + ref: "LayoutConfig", +}) +export type Layout = z.infer + +export const Model = z + .object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + release_date: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + tool_call: z.boolean(), + interleaved: z + .union([ + z.literal(true), + z + .object({ + field: z.enum(["reasoning_content", "reasoning_details"]), + }) + .strict(), + ]) + .optional(), + cost: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + .optional(), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z.boolean().optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()).optional(), + variants: z + .record( + z.string(), + z + .object({ + disabled: z.boolean().optional().describe("Disable this variant for the model"), + }) + .catchall(z.any()), + ) + .optional() + .describe("Variant-specific configuration"), + }) + .partial() + +export const Provider = z + .object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()), + id: z.string(), + npm: z.string().optional(), + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), + options: z + .object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), + setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), + timeout: z + .union([ + z + .number() + .int() + .positive() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + z.literal(false).describe("Disable timeout for this provider entirely."), + ]) + .optional() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + chunkTimeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", + ), + }) + .catchall(z.any()) + .optional(), + models: z.record(z.string(), Model).optional(), + }) + .partial() + .strict() + .meta({ + ref: "ProviderConfig", + }) + +export type Provider = z.infer + +export const Info = z + .object({ + $schema: z.string().optional().describe("JSON schema reference for configuration validation"), + logLevel: Log.Level.optional().describe("Log level"), + server: Server.optional().describe("Server configuration for opencode serve and web commands"), + command: z + .record(z.string(), Command) + .optional() + .describe("Command configuration, see https://opencode.ai/docs/commands"), + skills: Skills.optional().describe("Additional skill folder paths"), + watcher: z + .object({ + ignore: z.array(z.string()).optional(), + }) + .optional(), + snapshot: z + .boolean() + .optional() + .describe( + "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", + ), + plugin: PluginSpec.array().optional(), + share: z + .enum(["manual", "auto", "disabled"]) + .optional() + .describe( + "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", + ), + autoshare: z + .boolean() + .optional() + .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), + autoupdate: z + .union([z.boolean(), z.literal("notify")]) + .optional() + .describe( + "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", + ), + disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), + enabled_providers: z .array(z.string()) .optional() - .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), - }) - export type Skills = z.infer - - export const Agent = z - .object({ - model: ModelId.optional(), - variant: z - .string() - .optional() - .describe("Default model variant for this agent (applies only when using the agent's configured model)."), - temperature: z.number().optional(), - top_p: z.number().optional(), - prompt: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), - disable: z.boolean().optional(), - description: z.string().optional().describe("Description of when to use the agent"), - mode: z.enum(["subagent", "primary", "all"]).optional(), - hidden: z - .boolean() - .optional() - .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), - options: z.record(z.string(), z.any()).optional(), - color: z - .union([ - z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), - z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]), - ]) - .optional() - .describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"), - steps: z - .number() - .int() - .positive() - .optional() - .describe("Maximum number of agentic iterations before forcing text-only response"), - maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), - permission: Permission.optional(), - }) - .catchall(z.any()) - .transform((agent, ctx) => { - const knownKeys = new Set([ - "name", - "model", - "variant", - "prompt", - "description", - "temperature", - "top_p", - "mode", - "hidden", - "color", - "steps", - "maxSteps", - "options", - "permission", - "disable", - "tools", - ]) - - // Extract unknown properties into options - const options: Record = { ...agent.options } - for (const [key, value] of Object.entries(agent)) { - if (!knownKeys.has(key)) options[key] = value - } - - // Convert legacy tools config to permissions - const permission: Permission = {} - for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { - const action = enabled ? "allow" : "deny" - // write, edit, patch, multiedit all map to edit permission - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { - permission.edit = action - } else { - permission[tool] = action - } - } - Object.assign(permission, agent.permission) - - // Convert legacy maxSteps to steps - const steps = agent.steps ?? agent.maxSteps - - return { ...agent, options, permission, steps } as typeof agent & { - options?: Record - permission?: Permission - steps?: number - } - }) - .meta({ - ref: "AgentConfig", - }) - export type Agent = z.infer - - export const Keybinds = z - .object({ - leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), - app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), - editor_open: z.string().optional().default("e").describe("Open external editor"), - theme_list: z.string().optional().default("t").describe("List available themes"), - sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), - scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), - username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), - status_view: z.string().optional().default("s").describe("View status"), - session_export: z.string().optional().default("x").describe("Export session to editor"), - session_new: z.string().optional().default("n").describe("Create a new session"), - session_list: z.string().optional().default("l").describe("List all sessions"), - session_timeline: z.string().optional().default("g").describe("Show session timeline"), - session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), - session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), - stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), - model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), - session_share: z.string().optional().default("none").describe("Share current session"), - session_unshare: z.string().optional().default("none").describe("Unshare current session"), - session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), - session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown,ctrl+alt+f") - .describe("Scroll messages down by one page"), - messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), - messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), - messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), - messages_half_page_down: z - .string() - .optional() - .default("ctrl+alt+d") - .describe("Scroll messages down by half page"), - messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), - messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), - messages_next: z.string().optional().default("none").describe("Navigate to next message"), - messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), - messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), - messages_copy: z.string().optional().default("y").describe("Copy message"), - messages_undo: z.string().optional().default("u").describe("Undo message"), - messages_redo: z.string().optional().default("r").describe("Redo message"), - messages_toggle_conceal: z - .string() - .optional() - .default("h") - .describe("Toggle code block concealment in messages"), - tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), - model_list: z.string().optional().default("m").describe("List available models"), - model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), - model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), - model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), - model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), - command_list: z.string().optional().default("ctrl+p").describe("List available commands"), - agent_list: z.string().optional().default("a").describe("List agents"), - agent_cycle: z.string().optional().default("tab").describe("Next agent"), - agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), - variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), - variant_list: z.string().optional().default("none").describe("List model variants"), - input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), - input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), - input_submit: z.string().optional().default("return").describe("Submit input"), - input_newline: z - .string() - .optional() - .default("shift+return,ctrl+return,alt+return,ctrl+j") - .describe("Insert newline in input"), - input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), - input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), - input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), - input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), - input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), - input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), - input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), - input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), - input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), - input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), - input_select_line_home: z - .string() - .optional() - .default("ctrl+shift+a") - .describe("Select to start of line in input"), - input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), - input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), - input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), - input_select_visual_line_home: z - .string() - .optional() - .default("alt+shift+a") - .describe("Select to start of visual line in input"), - input_select_visual_line_end: z - .string() - .optional() - .default("alt+shift+e") - .describe("Select to end of visual line in input"), - input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), - input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), - input_select_buffer_home: z - .string() - .optional() - .default("shift+home") - .describe("Select to start of buffer in input"), - input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), - input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), - input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), - input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), - input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), - input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), - input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), - input_word_forward: z - .string() - .optional() - .default("alt+f,alt+right,ctrl+right") - .describe("Move word forward in input"), - input_word_backward: z - .string() - .optional() - .default("alt+b,alt+left,ctrl+left") - .describe("Move word backward in input"), - input_select_word_forward: z - .string() - .optional() - .default("alt+shift+f,alt+shift+right") - .describe("Select word forward in input"), - input_select_word_backward: z - .string() - .optional() - .default("alt+shift+b,alt+shift+left") - .describe("Select word backward in input"), - input_delete_word_forward: z - .string() - .optional() - .default("alt+d,alt+delete,ctrl+delete") - .describe("Delete word forward in input"), - input_delete_word_backward: z - .string() - .optional() - .default("ctrl+w,ctrl+backspace,alt+backspace") - .describe("Delete word backward in input"), - history_previous: z.string().optional().default("up").describe("Previous history item"), - history_next: z.string().optional().default("down").describe("Next history item"), - session_child_first: z.string().optional().default("down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent session"), - terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), - terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), - tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), - plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), - display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), - }) - .strict() - .meta({ - ref: "KeybindsConfig", - }) - - export const Server = z - .object({ - port: z.number().int().positive().optional().describe("Port to listen on"), - hostname: z.string().optional().describe("Hostname to listen on"), - mdns: z.boolean().optional().describe("Enable mDNS service discovery"), - mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), - cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), - }) - .strict() - .meta({ - ref: "ServerConfig", - }) - - export const Layout = z.enum(["auto", "stretch"]).meta({ - ref: "LayoutConfig", - }) - export type Layout = z.infer - - export const Model = z - .object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), + .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), + model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + small_model: ModelId.describe( + "Small model to use for tasks like title generation in the format of provider/model", + ).optional(), + default_agent: z + .string() + .optional() + .describe( + "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + ), + username: z.string().optional().describe("Custom username to display in conversations instead of system username"), + mode: z + .object({ + build: Agent.optional(), + plan: Agent.optional(), + }) + .catchall(Agent) + .optional() + .describe("@deprecated Use `agent` field instead."), + agent: z + .object({ + // primary + plan: Agent.optional(), + build: Agent.optional(), + // subagent + general: Agent.optional(), + explore: Agent.optional(), + // specialized + title: Agent.optional(), + summary: Agent.optional(), + compaction: Agent.optional(), + }) + .catchall(Agent) + .optional() + .describe("Agent configuration, see https://opencode.ai/docs/agents"), + provider: z.record(z.string(), Provider).optional().describe("Custom provider configurations and model overrides"), + mcp: z + .record( + z.string(), + z.union([ + Mcp, z .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), + enabled: z.boolean(), }) .strict(), - ]) - .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), - }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), - variants: z - .record( + ]), + ) + .optional() + .describe("MCP (Model Context Protocol) server configurations"), + formatter: z + .union([ + z.literal(false), + z.record( z.string(), - z - .object({ - disabled: z.boolean().optional().describe("Disable this variant for the model"), - }) - .catchall(z.any()), - ) - .optional() - .describe("Variant-specific configuration"), - }) - .partial() - - export const Provider = z - .object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - options: z - .object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), - setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), - timeout: z - .union([ - z - .number() - .int() - .positive() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - z.literal(false).describe("Disable timeout for this provider entirely."), - ]) - .optional() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - chunkTimeout: z - .number() - .int() - .positive() - .optional() - .describe( - "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - ), - }) - .catchall(z.any()) - .optional(), - models: z.record(z.string(), Model).optional(), - }) - .partial() - .strict() - .meta({ - ref: "ProviderConfig", - }) - - export type Provider = z.infer - - export const Info = z - .object({ - $schema: z.string().optional().describe("JSON schema reference for configuration validation"), - logLevel: Log.Level.optional().describe("Log level"), - server: Server.optional().describe("Server configuration for opencode serve and web commands"), - command: z - .record(z.string(), Command) - .optional() - .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: Skills.optional().describe("Additional skill folder paths"), - watcher: z - .object({ - ignore: z.array(z.string()).optional(), - }) - .optional(), - snapshot: z - .boolean() - .optional() - .describe( - "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", + z.object({ + disabled: z.boolean().optional(), + command: z.array(z.string()).optional(), + environment: z.record(z.string(), z.string()).optional(), + extensions: z.array(z.string()).optional(), + }), ), - plugin: PluginSpec.array().optional(), - share: z - .enum(["manual", "auto", "disabled"]) - .optional() - .describe( - "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", - ), - autoshare: z - .boolean() - .optional() - .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), - autoupdate: z - .union([z.boolean(), z.literal("notify")]) - .optional() - .describe( - "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", - ), - disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), - enabled_providers: z - .array(z.string()) - .optional() - .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), - small_model: ModelId.describe( - "Small model to use for tasks like title generation in the format of provider/model", - ).optional(), - default_agent: z - .string() - .optional() - .describe( - "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", - ), - username: z - .string() - .optional() - .describe("Custom username to display in conversations instead of system username"), - mode: z - .object({ - build: Agent.optional(), - plan: Agent.optional(), - }) - .catchall(Agent) - .optional() - .describe("@deprecated Use `agent` field instead."), - agent: z - .object({ - // primary - plan: Agent.optional(), - build: Agent.optional(), - // subagent - general: Agent.optional(), - explore: Agent.optional(), - // specialized - title: Agent.optional(), - summary: Agent.optional(), - compaction: Agent.optional(), - }) - .catchall(Agent) - .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), - provider: z - .record(z.string(), Provider) - .optional() - .describe("Custom provider configurations and model overrides"), - mcp: z - .record( + ]) + .optional(), + lsp: z + .union([ + z.literal(false), + z.record( z.string(), z.union([ - Mcp, - z - .object({ - enabled: z.boolean(), - }) - .strict(), - ]), - ) - .optional() - .describe("MCP (Model Context Protocol) server configurations"), - formatter: z - .union([ - z.literal(false), - z.record( - z.string(), z.object({ - disabled: z.boolean().optional(), - command: z.array(z.string()).optional(), - environment: z.record(z.string(), z.string()).optional(), - extensions: z.array(z.string()).optional(), + disabled: z.literal(true), }), - ), - ]) - .optional(), - lsp: z - .union([ - z.literal(false), - z.record( - z.string(), - z.union([ - z.object({ - disabled: z.literal(true), - }), - z.object({ - command: z.array(z.string()), - extensions: z.array(z.string()).optional(), - disabled: z.boolean().optional(), - env: z.record(z.string(), z.string()).optional(), - initialization: z.record(z.string(), z.any()).optional(), - }), - ]), - ), - ]) - .optional() - .refine( - (data) => { - if (!data) return true - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) - - return Object.entries(data).every(([id, config]) => { - if (config.disabled) return true - if (serverIds.has(id)) return true - return Boolean(config.extensions) - }) - }, - { - error: "For custom LSP servers, 'extensions' array is required.", - }, + z.object({ + command: z.array(z.string()), + extensions: z.array(z.string()).optional(), + disabled: z.boolean().optional(), + env: z.record(z.string(), z.string()).optional(), + initialization: z.record(z.string(), z.any()).optional(), + }), + ]), ), - instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), - layout: Layout.optional().describe("@deprecated Always uses stretch layout."), - permission: Permission.optional(), - tools: z.record(z.string(), z.boolean()).optional(), - enterprise: z - .object({ - url: z.string().optional().describe("Enterprise URL"), - }) - .optional(), - compaction: z - .object({ - auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), - prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), - reserved: z - .number() - .int() - .min(0) - .optional() - .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), - }) - .optional(), - experimental: z - .object({ - disable_paste_summary: z.boolean().optional(), - batch_tool: z.boolean().optional().describe("Enable the batch tool"), - openTelemetry: z - .boolean() - .optional() - .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), - primary_tools: z - .array(z.string()) - .optional() - .describe("Tools that should only be available to primary agents."), - continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), - mcp_timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for model context protocol (MCP) requests"), - }) - .optional(), - }) - .strict() - .meta({ - ref: "Config", - }) + ]) + .optional() + .refine( + (data) => { + if (!data) return true + if (typeof data === "boolean") return true + const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) - export type Info = z.output & { - plugin_origins?: PluginOrigin[] - } - - type State = { - config: Info - directories: string[] - deps: Fiber.Fiber[] - consoleState: ConsoleState - } - - export interface Interface { - readonly get: () => Effect.Effect - readonly getGlobal: () => Effect.Effect - readonly getConsoleState: () => Effect.Effect - readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect - readonly update: (config: Info) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect - readonly invalidate: (wait?: boolean) => Effect.Effect - readonly directories: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Config") {} - - function globalConfigFile() { - const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => - path.join(Global.Path.config, file), - ) - for (const file of candidates) { - if (existsSync(file)) return file - } - return candidates[0] - } - - function patchJsonc(input: string, patch: unknown, path: string[] = []): string { - if (!isRecord(patch)) { - const edits = modify(input, path, patch, { - formattingOptions: { - insertSpaces: true, - tabSize: 2, + return Object.entries(data).every(([id, config]) => { + if (config.disabled) return true + if (serverIds.has(id)) return true + return Boolean(config.extensions) + }) }, + { + error: "For custom LSP servers, 'extensions' array is required.", + }, + ), + instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), + layout: Layout.optional().describe("@deprecated Always uses stretch layout."), + permission: Permission.optional(), + tools: z.record(z.string(), z.boolean()).optional(), + enterprise: z + .object({ + url: z.string().optional().describe("Enterprise URL"), }) - return applyEdits(input, edits) - } + .optional(), + compaction: z + .object({ + auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), + prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + reserved: z + .number() + .int() + .min(0) + .optional() + .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), + }) + .optional(), + experimental: z + .object({ + disable_paste_summary: z.boolean().optional(), + batch_tool: z.boolean().optional().describe("Enable the batch tool"), + openTelemetry: z + .boolean() + .optional() + .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), + primary_tools: z + .array(z.string()) + .optional() + .describe("Tools that should only be available to primary agents."), + continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), + mcp_timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + }) + .optional(), + }) + .strict() + .meta({ + ref: "Config", + }) - return Object.entries(patch).reduce((result, [key, value]) => { - if (value === undefined) return result - return patchJsonc(result, value, [...path, key]) - }, input) +export type Info = z.output & { + plugin_origins?: PluginOrigin[] +} + +type State = { + config: Info + directories: string[] + deps: Fiber.Fiber[] + consoleState: ConsoleState +} + +export interface Interface { + readonly get: () => Effect.Effect + readonly getGlobal: () => Effect.Effect + readonly getConsoleState: () => Effect.Effect + readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect + readonly invalidate: (wait?: boolean) => Effect.Effect + readonly directories: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Config") {} + +function globalConfigFile() { + const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => + path.join(Global.Path.config, file), + ) + for (const file of candidates) { + if (existsSync(file)) return file + } + return candidates[0] +} + +function patchJsonc(input: string, patch: unknown, path: string[] = []): string { + if (!isRecord(patch)) { + const edits = modify(input, path, patch, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }) + return applyEdits(input, edits) } - function writable(info: Info) { - const { plugin_origins, ...next } = info - return next - } + return Object.entries(patch).reduce((result, [key, value]) => { + if (value === undefined) return result + return patchJsonc(result, value, [...path, key]) + }, input) +} - function parseConfig(text: string, filepath: string): Info { - const errors: JsoncParseError[] = [] - const data = parseJsonc(text, errors, { allowTrailingComma: true }) - if (errors.length) { - const lines = text.split("\n") - const errorDetails = errors - .map((e) => { - const beforeOffset = text.substring(0, e.offset).split("\n") - const line = beforeOffset.length - const column = beforeOffset[beforeOffset.length - 1].length + 1 - const problemLine = lines[line - 1] +function writable(info: Info) { + const { plugin_origins, ...next } = info + return next +} - const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` - if (!problemLine) return error +function parseConfig(text: string, filepath: string): Info { + const errors: JsoncParseError[] = [] + const data = parseJsonc(text, errors, { allowTrailingComma: true }) + if (errors.length) { + const lines = text.split("\n") + const errorDetails = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] - return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` - }) - .join("\n") + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error - throw new JsonError({ - path: filepath, - message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` }) - } + .join("\n") - const parsed = Info.safeParse(data) - if (parsed.success) return parsed.data - - throw new InvalidError({ + throw new JsonError({ path: filepath, - issues: parsed.error.issues, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, }) } - export const { JsonError, InvalidError } = ConfigPaths + const parsed = Info.safeParse(data) + if (parsed.success) return parsed.data - export const ConfigDirectoryTypoError = NamedError.create( - "ConfigDirectoryTypoError", - z.object({ - path: z.string(), - dir: z.string(), - suggestion: z.string(), - }), - ) + throw new InvalidError({ + path: filepath, + issues: parsed.error.issues, + }) +} - export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | Auth.Service | Account.Service | Env.Service - > = Layer.effect( +export const { JsonError, InvalidError } = ConfigPaths + +export const ConfigDirectoryTypoError = NamedError.create( + "ConfigDirectoryTypoError", + z.object({ + path: z.string(), + dir: z.string(), + suggestion: z.string(), + }), +) + +export const layer: Layer.Layer = + Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -1531,9 +1511,9 @@ export namespace Config { } if (result.tools) { - const perms: Record = {} + const perms: Record = {} for (const [tool, enabled] of Object.entries(result.tools)) { - const action: Config.PermissionAction = enabled ? "allow" : "deny" + const action: PermissionAction = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { perms.edit = action continue @@ -1654,10 +1634,9 @@ export namespace Config { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), - ) -} +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Account.defaultLayer), +) diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts new file mode 100644 index 0000000000..60e39c3163 --- /dev/null +++ b/packages/opencode/src/config/index.ts @@ -0,0 +1 @@ +export * as Config from "./config" diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index a373b4d800..fd5cd8c88d 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -1,5 +1,5 @@ import z from "zod" -import { Config } from "./config" +import { Config } from "." const KeybindOverride = z .object( diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index e64b226c14..163bd4d7d7 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,7 +2,7 @@ import { existsSync } from "fs" import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" -import { Config } from "./config" +import { Config } from "." import { ConfigPaths } from "./paths" import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 257922dafe..54139eb777 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account" -import { Config } from "@/config/config" +import { Config } from "@/config" import { Git } from "@/git" import { Ripgrep } from "@/file/ripgrep" import { FileTime } from "@/file/time" diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 8737045c18..74966fd47a 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -12,7 +12,7 @@ import { Flag } from "@/flag/flag" import { Git } from "@/git" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" -import { Config } from "../config/config" +import { Config } from "../config" import { FileIgnore } from "./ignore" import { Protected } from "./protected" import { Log } from "../util/log" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 1aeb2e51a4..595bb7a608 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -5,7 +5,7 @@ import { InstanceState } from "@/effect/instance-state" import path from "path" import { mergeDeep } from "remeda" import z from "zod" -import { Config } from "../config/config" +import { Config } from "../config" import { Instance } from "../project/instance" import { Log } from "../util/log" import * as Formatter from "./formatter" diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0c83890e55..4daacd30b8 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -6,7 +6,7 @@ import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { LSPServer } from "./server" import z from "zod" -import { Config } from "../config/config" +import { Config } from "../config" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util/process" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index a68c6c1d8d..cbaa2c24b3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -9,7 +9,7 @@ import { type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" -import { Config } from "../config/config" +import { Config } from "../config" import { Log } from "../util/log" import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod/v4" diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index 44a9f3b430..6f020576d9 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -1,4 +1,4 @@ -export { Config } from "./config/config" +export { Config } from "./config" export { Server } from "./server/server" export { bootstrap } from "./cli/bootstrap" export { Log } from "./util/log" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index b6a44e2582..71d321080a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,6 +1,6 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { Config } from "@/config/config" +import { Config } from "@/config" import { InstanceState } from "@/effect/instance-state" import { ProjectID } from "@/project/schema" import { Instance } from "@/project/instance" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9f618eff8c..f31e0b9ff2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -5,7 +5,7 @@ import type { PluginModule, WorkspaceAdaptor as PluginWorkspaceAdaptor, } from "@opencode-ai/plugin" -import { Config } from "../config/config" +import { Config } from "../config" import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 634fe6aad0..12617f9010 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -1,4 +1,4 @@ -import { Config } from "@/config/config" +import { Config } from "@/config" import { Installation } from "@/installation" import { checkPluginCompatibility, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c029e5c5c6..1dd6027db9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,7 +1,7 @@ import z from "zod" import os from "os" import fuzzysort from "fuzzysort" -import { Config } from "../config/config" +import { Config } from "../config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" import { Log } from "../util/log" diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index aa770726df..11845c69c9 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { Config } from "../../config/config" +import { Config } from "../../config" import { Provider } from "../../provider/provider" import { mapValues } from "remeda" import { errors } from "../error" diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index e8e46b2e3b..6e1a47ed20 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -8,7 +8,7 @@ import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" import { Session } from "../../session" -import { Config } from "../../config/config" +import { Config } from "../../config" import { ConsoleState } from "../../config/console-state" import { Account, AccountID, OrgID } from "../../account" import { AppRuntime } from "../../effect/app-runtime" diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index d462a07f74..b69f35a649 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -12,7 +12,7 @@ import { Instance } from "../../project/instance" import { Installation } from "@/installation" import { Log } from "../../util/log" import { lazy } from "../../util/lazy" -import { Config } from "../../config/config" +import { Config } from "../../config" import { errors } from "../error" const log = Log.create({ service: "server" }) diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/instance/mcp.ts index f1c8701c4e..695008fc4e 100644 --- a/packages/opencode/src/server/instance/mcp.ts +++ b/packages/opencode/src/server/instance/mcp.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "../../mcp" -import { Config } from "../../config/config" +import { Config } from "../../config" import { AppRuntime } from "../../effect/app-runtime" import { errors } from "../error" import { lazy } from "../../util/lazy" diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 6988d56e4e..b9e39d4eff 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { Config } from "../../config/config" +import { Config } from "../../config" import { Provider } from "../../provider/provider" import { ModelsDev } from "../../provider/models" import { ProviderAuth } from "../../provider/auth" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4978ef5478..810b949743 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -11,7 +11,7 @@ import { Log } from "../util/log" import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" -import { Config } from "@/config/config" +import { Config } from "@/config" import { NotFoundError } from "@/storage/db" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index b4794ba5b1..23dd88ff5a 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -2,7 +2,7 @@ import os from "os" import path from "path" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Config } from "@/config/config" +import { Config } from "@/config" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@/flag/flag" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 05d7882757..2efe4a4054 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -6,7 +6,7 @@ import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, json import { mergeDeep, pipe } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" -import { Config } from "@/config/config" +import { Config } from "@/config" import { Instance } from "@/project/instance" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index f0e52565d8..c4c6d09279 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,4 +1,4 @@ -import type { Config } from "@/config/config" +import type { Config } from "@/config" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import type { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b02e7cc81c..d91b1427b0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -2,7 +2,7 @@ import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" -import { Config } from "@/config/config" +import { Config } from "@/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { Snapshot } from "@/snapshot" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 08210de8a1..0a673f81c6 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -2,7 +2,7 @@ import { Session } from "@/session" import { SessionID } from "@/session/schema" import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" -import { Config } from "../config/config" +import { Config } from "../config" import { Flag } from "../flag/flag" import { ShareNext } from "./share-next" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index ad247f5466..667e0720c4 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -10,7 +10,7 @@ import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" import { Database, eq } from "@/storage/db" -import { Config } from "@/config/config" +import { Config } from "@/config" import { Log } from "@/util/log" import { SessionShareTable } from "./share.sql" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 79b426c69c..4bf5d0cfed 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -11,7 +11,7 @@ import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Config } from "../config/config" +import { Config } from "../config" import { ConfigMarkdown } from "../config/markdown" import { Glob } from "@opencode-ai/shared/util/glob" import { Log } from "../util/log" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 9378e309aa..83963e3511 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -7,7 +7,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Hash } from "@opencode-ai/shared/util/hash" -import { Config } from "../config/config" +import { Config } from "../config" import { Global } from "../global" import { Log } from "../util/log" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6900feecc3..2e9971ad71 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -13,7 +13,7 @@ import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" import { Tool } from "./tool" -import { Config } from "../config/config" +import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ce99ab2992..bbb07caa40 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,7 +6,7 @@ import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" -import { Config } from "../config/config" +import { Config } from "../config" import { Effect } from "effect" import { Log } from "@/util/log" diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index af9565cba8..d77782354c 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import path from "path" import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "../../src/util/color" import { AppRuntime } from "../../src/effect/app-runtime" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ed7e689da4..88957c6141 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 529d88bce1..4767e94b01 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -3,7 +3,7 @@ import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { TuiConfig } from "../../src/config/tui" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 0c8968d94b..0c23550083 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -5,7 +5,7 @@ import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" import { tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 7970543547..fd7f5e3808 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -6,7 +6,7 @@ import { Effect, Context } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import type { Config } from "../../src/config/config" +import type { Config } from "../../src/config" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index d415d23ebc..3c53314b6a 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" -import { Config } from "../src/config/config" +import { Config } from "../src/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 251447762d..1174cdf6a1 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -5,7 +5,7 @@ import * as Stream from "effect/Stream" import path from "path" import z from "zod" import { Bus } from "../../src/bus" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d384513087..10945be188 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -5,7 +5,7 @@ import path from "path" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 31727e3df9..ec1a87e969 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -7,7 +7,7 @@ import z from "zod" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 80d74c7565..a0ea47c89c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -32,7 +32,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index fd230f5459..135d44db09 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -8,7 +8,7 @@ import { Account } from "../../src/account" import { AccountRepo } from "../../src/account/repo" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Bus } from "../../src/bus" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index e7a143c9af..bc90dc0f22 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" From f7d4665e4091c88c2fde0f9db70d6333f83b86fd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:33:54 -0400 Subject: [PATCH 07/75] =?UTF-8?q?fix:=20resolve=20oxlint=20warnings=20?= =?UTF-8?q?=E2=80=94=20suppress=20false=20positives,=20remove=20unused=20i?= =?UTF-8?q?mports=20(#22687)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .oxlintrc.json | 8 +++++++- github/index.ts | 6 +++--- infra/enterprise.ts | 2 +- packages/app/src/addons/serialize.ts | 4 ++-- .../app/src/components/session/session-header.tsx | 2 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/global-sync/queue.ts | 1 + packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/session/review-tab.tsx | 2 +- packages/console/app/src/component/email-signup.tsx | 1 - packages/console/app/src/component/header.tsx | 2 +- packages/console/app/src/context/auth.session.ts | 1 + packages/console/app/src/routes/bench/[id].tsx | 2 +- packages/console/app/src/routes/go/index.tsx | 2 +- packages/console/app/src/routes/workspace-picker.tsx | 2 +- packages/console/app/src/routes/zen/index.tsx | 2 +- packages/console/app/src/routes/zen/util/handler.ts | 2 +- .../app/src/routes/zen/util/provider/anthropic.ts | 2 +- .../app/src/routes/zen/util/provider/google.ts | 2 +- .../src/routes/zen/util/provider/openai-compatible.ts | 6 +++--- .../app/src/routes/zen/util/provider/openai.ts | 2 +- packages/console/core/script/black-cancel-waitlist.ts | 6 ++---- packages/console/core/script/black-gift.ts | 6 ++---- .../console/core/script/black-onboard-waitlist.ts | 6 ++---- .../console/core/script/black-select-workspaces.ts | 2 +- packages/console/core/src/util/env.cloudflare.ts | 1 + packages/console/core/src/util/log.ts | 2 +- packages/enterprise/test/core/share.test.ts | 2 +- packages/opencode/script/publish.ts | 2 +- packages/opencode/src/cli/cmd/debug/lsp.ts | 1 - packages/opencode/src/cli/cmd/export.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 4 ++-- packages/opencode/src/cli/cmd/serve.ts | 3 --- packages/opencode/src/cli/cmd/tui/app.tsx | 3 ++- .../src/cli/cmd/tui/component/dialog-theme-list.tsx | 2 +- .../src/cli/cmd/tui/component/prompt/index.tsx | 2 +- packages/opencode/src/cli/cmd/tui/event.ts | 1 - .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 6 +++--- .../opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 2 +- .../opencode/src/control-plane/workspace-context.ts | 2 +- packages/opencode/src/file/ignore.ts | 1 - packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/index.ts | 1 - packages/opencode/src/global/index.ts | 2 +- packages/opencode/src/ide/index.ts | 1 - packages/opencode/src/lsp/server.ts | 2 +- packages/opencode/src/patch/index.ts | 2 +- packages/opencode/src/permission/index.ts | 1 - packages/opencode/src/plugin/codex.ts | 4 +--- .../responses/openai-responses-language-model.ts | 1 + packages/opencode/src/server/instance/config.ts | 1 - packages/opencode/src/server/instance/event.ts | 1 - packages/opencode/src/server/instance/pty.ts | 2 +- packages/opencode/src/session/compaction.ts | 1 - packages/opencode/src/session/processor.ts | 2 ++ packages/opencode/src/session/projectors.ts | 6 ++---- packages/opencode/src/session/revert.ts | 1 - packages/opencode/src/storage/json-migration.ts | 4 ++++ packages/opencode/src/sync/index.ts | 1 - packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/task.ts | 1 - packages/opencode/src/tool/webfetch.ts | 2 +- packages/opencode/src/util/lazy.ts | 11 +++-------- packages/opencode/src/v2/session.ts | 2 -- .../opencode/test/cli/tui/plugin-lifecycle.test.ts | 1 - .../opencode/test/effect/cross-spawn-spawner.test.ts | 3 +-- packages/opencode/test/effect/instance-state.test.ts | 2 +- packages/opencode/test/permission/next.test.ts | 2 +- packages/opencode/test/provider/gitlab-duo.test.ts | 1 + .../opencode/test/server/project-init-git.test.ts | 1 - packages/opencode/test/session/compaction.test.ts | 1 - packages/opencode/test/session/prompt-effect.test.ts | 1 - .../opencode/test/session/snapshot-tool-race.test.ts | 1 - packages/opencode/test/share/share-next.test.ts | 1 - packages/opencode/test/tool/question.test.ts | 1 - packages/slack/src/index.ts | 2 +- packages/ui/src/components/message-part.tsx | 1 - .../ui/src/components/timeline-playground.stories.tsx | 2 +- packages/ui/src/pierre/commented-lines.ts | 2 +- sdks/vscode/src/extension.ts | 2 +- 80 files changed, 82 insertions(+), 106 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0875f38326..c366084ee7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,7 +4,13 @@ // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield "require-yield": "off", // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime - "no-unassigned-vars": "off" + "no-unassigned-vars": "off", + // SolidJS tracks reactive deps by reading properties inside createEffect + "no-unused-expressions": "off", + // Intentional control char matching (ANSI escapes, null byte sanitization) + "no-control-regex": "off", + // SST and plugin tools require triple-slash references + "triple-slash-reference": "off" }, "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] } diff --git a/github/index.ts b/github/index.ts index 6bfa964623..be8e5aafcd 100644 --- a/github/index.ts +++ b/github/index.ts @@ -281,7 +281,7 @@ async function assertOpencodeConnected() { }) connected = true break - } catch (e) {} + } catch {} await sleep(300) } while (retry++ < 30) @@ -561,7 +561,7 @@ async function subscribeSessionEvents() { if (evt.properties.info.id !== session.id) continue session = evt.properties.info } - } catch (e) { + } catch { // Ignore parse errors } } @@ -576,7 +576,7 @@ async function subscribeSessionEvents() { async function summarize(response: string) { try { return await chat(`Summarize the following in less than 40 characters:\n\n${response}`) - } catch (e) { + } catch { if (isScheduleEvent()) { return "Scheduled task changes" } diff --git a/infra/enterprise.ts b/infra/enterprise.ts index 22b4c6f44e..38f0c3c8fd 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -1,5 +1,5 @@ import { SECRET } from "./secret" -import { domain, shortDomain } from "./stage" +import { shortDomain } from "./stage" const storage = new sst.cloudflare.Bucket("EnterpriseStorage") diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 4cab55b3f2..3823fb443a 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler { } protected _beforeSerialize(rows: number, start: number, _end: number): void { - this._allRows = new Array(rows) - this._allRowSeparators = new Array(rows) + this._allRows = Array.from({ length: rows }) + this._allRowSeparators = Array.from({ length: rows }) this._rowIndex = 0 this._currentRow = "" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index e65b575ac5..7acfdfc374 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/shared/util/path" -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0a41f31196..a90178abdd 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" +import { createEffect, createMemo, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b9..5c228dac04 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) { } } finally { running = false + // oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused if (input.paused()) return if (root || queued.size) schedule() } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 62d5cba615..3ba2659a3b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -704,7 +704,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { const active = new Set(visibleSessionDirs()) - for (const directory of [...prefetchedByDir.keys()]) { + for (const directory of prefetchedByDir.keys()) { if (active.has(directory)) continue prefetchedByDir.delete(directory) } diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 71dfe375e0..5719fce318 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" +import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx index bd33e92006..caedaf0f2e 100644 --- a/packages/console/app/src/component/email-signup.tsx +++ b/packages/console/app/src/component/email-signup.tsx @@ -1,5 +1,4 @@ import { action, useSubmission } from "@solidjs/router" -import dock from "../asset/lander/dock.png" import { Resource } from "@opencode-ai/console-resource" import { Show } from "solid-js" import { useI18n } from "~/context/i18n" diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 1e129d5908..cc45ed534f 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo notation: "compact", compactDisplay: "short", maximumFractionDigits: 0, - }).format(githubData()?.stars!) + }).format(githubData()?.stars) : config.github.starsFormatted.compact, ) diff --git a/packages/console/app/src/context/auth.session.ts b/packages/console/app/src/context/auth.session.ts index e69de29bb2..336ce12bb9 100644 --- a/packages/console/app/src/context/auth.session.ts +++ b/packages/console/app/src/context/auth.session.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/console/app/src/routes/bench/[id].tsx b/packages/console/app/src/routes/bench/[id].tsx index dd96bcbbce..c6d10826b3 100644 --- a/packages/console/app/src/routes/bench/[id].tsx +++ b/packages/console/app/src/routes/bench/[id].tsx @@ -1,7 +1,7 @@ import { Title } from "@solidjs/meta" import { createAsync, query, useParams } from "@solidjs/router" import { createSignal, For, Show } from "solid-js" -import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" import { useI18n } from "~/context/i18n" diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 0ac85a9570..82b3caf664 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -1,5 +1,5 @@ import "./index.css" -import { createAsync, query, redirect } from "@solidjs/router" +import { createAsync, query } from "@solidjs/router" import { Title, Meta } from "@solidjs/meta" import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js" //import { HttpHeader } from "@solidjs/start" diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx index ffec2f3bee..8778abefd1 100644 --- a/packages/console/app/src/routes/workspace-picker.tsx +++ b/packages/console/app/src/routes/workspace-picker.tsx @@ -1,5 +1,5 @@ import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router" -import { For, Show, createEffect } from "solid-js" +import { For, createEffect } from "solid-js" import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Actor } from "@opencode-ai/console-core/actor.js" diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 62e8f5d379..6285a0bd8a 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -1,5 +1,5 @@ import "./index.css" -import { createAsync, query, redirect } from "@solidjs/router" +import { createAsync, query } from "@solidjs/router" import { Title, Meta } from "@solidjs/meta" //import { HttpHeader } from "@solidjs/start" import zenLogoLight from "../../asset/zen-ornate-light.svg" diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 358d8736c4..d1c5985a81 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -345,7 +345,7 @@ export async function handler( logger.metric({ "error.cause2": JSON.stringify(error.cause), }) - } catch (e) {} + } catch {} } // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index b63be8688a..0f6f11da78 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => let json try { json = JSON.parse(data.slice(6)) - } catch (e) { + } catch { return } diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts index f6f7d6e19b..ef7937c358 100644 --- a/packages/console/app/src/routes/zen/util/provider/google.ts +++ b/packages/console/app/src/routes/zen/util/provider/google.ts @@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({ let json try { json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage } - } catch (e) { + } catch { return } diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index cf9ee287c4..e05f0d6c0b 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif let json try { json = JSON.parse(chunk.slice(6)) as { usage?: Usage } - } catch (e) { + } catch { return } @@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse { index: 0, message: { role: "assistant" as const, - ...(content.length > 0 && content.some((c) => c.type === "text") + ...(content.some((c) => c.type === "text") ? { content: content .filter((c) => c.type === "text") @@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse { .join(""), } : {}), - ...(content.length > 0 && content.some((c) => c.type === "tool_use") + ...(content.some((c) => c.type === "tool_use") ? { tool_calls: content .filter((c) => c.type === "tool_use") diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 3c5831a9af..bee1e01ec0 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ let json try { json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } } - } catch (e) { + } catch { return } diff --git a/packages/console/core/script/black-cancel-waitlist.ts b/packages/console/core/script/black-cancel-waitlist.ts index ab2aa16d5d..7c3584e009 100644 --- a/packages/console/core/script/black-cancel-waitlist.ts +++ b/packages/console/core/script/black-cancel-waitlist.ts @@ -1,7 +1,5 @@ -import { subscribe } from "diagnostics_channel" -import { Billing } from "../src/billing.js" -import { and, Database, eq } from "../src/drizzle/index.js" -import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { Database, eq } from "../src/drizzle/index.js" +import { BillingTable } from "../src/schema/billing.sql.js" const workspaceID = process.argv[2] diff --git a/packages/console/core/script/black-gift.ts b/packages/console/core/script/black-gift.ts index c666a1ab66..e57ec9775f 100644 --- a/packages/console/core/script/black-gift.ts +++ b/packages/console/core/script/black-gift.ts @@ -1,12 +1,10 @@ import { Billing } from "../src/billing.js" -import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js" +import { and, Database, eq, isNull } from "../src/drizzle/index.js" import { UserTable } from "../src/schema/user.sql.js" -import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js" import { Identifier } from "../src/identifier.js" -import { centsToMicroCents } from "../src/util/price.js" import { AuthTable } from "../src/schema/auth.sql.js" import { BlackData } from "../src/black.js" -import { Actor } from "../src/actor.js" const plan = "200" const couponID = "JAIr0Pe1" diff --git a/packages/console/core/script/black-onboard-waitlist.ts b/packages/console/core/script/black-onboard-waitlist.ts index 96d0f8f912..9e7d9e935d 100644 --- a/packages/console/core/script/black-onboard-waitlist.ts +++ b/packages/console/core/script/black-onboard-waitlist.ts @@ -1,7 +1,5 @@ -import { subscribe } from "diagnostics_channel" -import { Billing } from "../src/billing.js" -import { and, Database, eq } from "../src/drizzle/index.js" -import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { Database, eq } from "../src/drizzle/index.js" +import { BillingTable } from "../src/schema/billing.sql.js" const workspaceID = process.argv[2] diff --git a/packages/console/core/script/black-select-workspaces.ts b/packages/console/core/script/black-select-workspaces.ts index 63bfab8875..0772bd2129 100644 --- a/packages/console/core/script/black-select-workspaces.ts +++ b/packages/console/core/script/black-select-workspaces.ts @@ -1,4 +1,4 @@ -import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js" +import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js" import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js" import { UserTable } from "../src/schema/user.sql.js" import { AuthTable } from "../src/schema/auth.sql.js" diff --git a/packages/console/core/src/util/env.cloudflare.ts b/packages/console/core/src/util/env.cloudflare.ts index e69de29bb2..336ce12bb9 100644 --- a/packages/console/core/src/util/env.cloudflare.ts +++ b/packages/console/core/src/util/env.cloudflare.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/console/core/src/util/log.ts b/packages/console/core/src/util/log.ts index 4f2d25c136..ef3ad85c6b 100644 --- a/packages/console/core/src/util/log.ts +++ b/packages/console/core/src/util/log.ts @@ -48,7 +48,7 @@ export namespace Log { function use() { try { return ctx.use() - } catch (e) { + } catch { return { tags: {} } } } diff --git a/packages/enterprise/test/core/share.test.ts b/packages/enterprise/test/core/share.test.ts index 34f3b17a3f..2877f8e0e0 100644 --- a/packages/enterprise/test/core/share.test.ts +++ b/packages/enterprise/test/core/share.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, afterAll } from "bun:test" +import { describe, expect, test } from "bun:test" import { Share } from "../../src/core/share" import { Storage } from "../../src/core/storage" import { Identifier } from "@opencode-ai/shared/util/identifier" diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index fbc1c83ba6..9c4b8f187d 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -107,7 +107,7 @@ if (!Script.preview) { await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` await $`cd ./dist/aur-${pkg} && git push` break - } catch (e) { + } catch { continue } } diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 5f0a1807d8..18f67b3917 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -5,7 +5,6 @@ import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { Log } from "../../../util/log" import { EOL } from "os" -import { setTimeout as sleep } from "node:timers/promises" export const LSPCommand = cmd({ command: "lsp", diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 9a1a51adc4..06b361c6d5 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -297,7 +297,7 @@ export const ExportCommand = cmd({ process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) process.stdout.write(EOL) - } catch (error) { + } catch { UI.error(`Session not found: ${sessionID!}`) process.exit(1) } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 074d9e5185..b6781d0852 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -362,7 +362,7 @@ export const GithubInstallCommand = cmd({ retries++ await sleep(1000) - } while (true) + } while (true) // oxlint-disable-line no-constant-condition s.stop("Installed GitHub app") @@ -931,7 +931,7 @@ export const GithubRunCommand = cmd({ async function summarize(response: string) { try { return await chat(`Summarize the following in less than 40 characters:\n\n${response}`) - } catch (e) { + } catch { const title = issueEvent ? issueEvent.issue.title : (payload as PullRequestReviewCommentEvent).pull_request.title diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 73e7a18a70..d5eee75dd1 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,9 +2,6 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" -import { Workspace } from "../../control-plane/workspace" -import { Project } from "../../project/project" -import { Installation } from "../../installation" export const ServeCommand = cmd({ command: "serve", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index acf007197b..4c6c74ff3d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -23,7 +23,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" -import { ProjectProvider, useProject } from "@tui/context/project" +import { ProjectProvider } from "@tui/context/project" import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" @@ -115,6 +115,7 @@ export function tui(input: { events?: EventSource }) { // promise to prevent immediate exit + // oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve return new Promise(async (resolve) => { const unguard = win32InstallCtrlCGuard() win32DisableProcessedInput() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index f4072c9785..6cf3539ad9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,7 +1,7 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -import { onCleanup, onMount } from "solid-js" +import { onCleanup } from "solid-js" export function DialogThemeList() { const theme = useTheme() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d0f5b481cb..87440d0e24 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,4 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index b2e4b92c55..fa164d53e8 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { SessionID } from "@/session/schema" import z from "zod" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2b95cd5ae4..f9fd5a9b9c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -863,7 +863,7 @@ export function Session() { ) await Clipboard.copy(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) - } catch (error) { + } catch { toast.show({ message: "Failed to copy session transcript", variant: "error" }) } dialog.clear() @@ -925,7 +925,7 @@ export function Session() { toast.show({ message: `Session exported to ${filename}`, variant: "success" }) } - } catch (error) { + } catch { toast.show({ message: "Failed to export session", variant: "error" }) } dialog.clear() @@ -1010,7 +1010,7 @@ export function Session() { ), } }) - } catch (error) { + } catch { return [] } }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 109b5f2f11..b6c937f411 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,6 +1,6 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" -import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" +import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 541657b88c..273adbb24a 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -19,7 +19,7 @@ export const WorkspaceContext = { get workspaceID() { try { return context.use().workspaceID - } catch (err) { + } catch { return undefined } }, diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index a102e7d170..63f2f594eb 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -1,4 +1,3 @@ -import { sep } from "node:path" import { Glob } from "@opencode-ai/shared/util/glob" export namespace FileIgnore { diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 74966fd47a..4dcec5094c 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Scope, Context } from "effect" +import { Cause, Effect, Layer, Context } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 595bb7a608..d65ed2944e 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -6,7 +6,6 @@ import path from "path" import { mergeDeep } from "remeda" import z from "zod" import { Config } from "../config" -import { Instance } from "../project/instance" import { Log } from "../util/log" import * as Formatter from "./formatter" diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 32d5153213..df46397816 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -53,6 +53,6 @@ if (version !== CACHE_VERSION) { }), ), ) - } catch (e) {} + } catch {} await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION) } diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 46efea2cce..24ba53f82e 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import z from "zod" import { NamedError } from "@opencode-ai/shared/util/error" import { Log } from "../util/log" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 9ffef7a425..f4554ae3e6 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -826,7 +826,7 @@ export namespace LSPServer { if (cargoTomlContent.includes("[workspace]")) { return currentDir } - } catch (err) { + } catch { // File doesn't exist or can't be read, continue searching up } diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index b87ad55528..f003606c4d 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -630,7 +630,7 @@ export namespace Patch { type: "delete", content, }) - } catch (error) { + } catch { return { type: MaybeApplyPatchVerified.CorrectnessError, error: new Error(`Failed to read file for deletion: ${deletePath}`), diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 71d321080a..0100485492 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -3,7 +3,6 @@ import { BusEvent } from "@/bus/bus-event" import { Config } from "@/config" import { InstanceState } from "@/effect/instance-state" import { ProjectID } from "@/project/schema" -import { Instance } from "@/project/instance" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 1e127fae54..ea356d55d2 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,10 +1,8 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" import { Installation } from "../installation" -import { Auth, OAUTH_DUMMY_KEY } from "../auth" +import { OAUTH_DUMMY_KEY } from "../auth" import os from "os" -import { ProviderTransform } from "@/provider/transform" -import { ModelID, ProviderID } from "@/provider/schema" import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts index 4606af7a15..92c8fd857b 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts @@ -793,6 +793,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { fetch: this.config.fetch, }) + // oxlint-disable-next-line no-this-alias -- needed for closure scope inside generator const self = this let finishReason: { diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 11845c69c9..68a6b50764 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -7,7 +7,6 @@ import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" import { AppRuntime } from "../../effect/app-runtime" -import { Effect } from "effect" import { jsonRequest } from "./trace" export const ConfigRoutes = lazy(() => diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/instance/event.ts index 5d631d954e..f13ed035e0 100644 --- a/packages/opencode/src/server/instance/event.ts +++ b/packages/opencode/src/server/instance/event.ts @@ -4,7 +4,6 @@ import { describeRoute, resolver } from "hono-openapi" import { streamSSE } from "hono/streaming" import { Log } from "@/util/log" import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" import { Bus } from "@/bus" import { AsyncQueue } from "../../util/queue" diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/instance/pty.ts index 576cbe5de6..3cb8dbfe2e 100644 --- a/packages/opencode/src/server/instance/pty.ts +++ b/packages/opencode/src/server/instance/pty.ts @@ -1,4 +1,4 @@ -import { Hono, type MiddlewareHandler } from "hono" +import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 810b949743..03f9723112 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -2,7 +2,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." import { SessionID, MessageID, PartID } from "./schema" -import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index d91b1427b0..0f8cd41b30 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -249,6 +249,7 @@ export namespace SessionProcessor { case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata @@ -431,6 +432,7 @@ export namespace SessionProcessor { case "text-end": if (!ctx.currentText) return + // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.currentText.text = ctx.currentText.text ctx.currentText.text = (yield* plugin.trigger( "experimental.text.complete", diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 460f0a41c5..a1b2e401d0 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,11 +1,9 @@ -import { NotFoundError, eq, and, sql } from "../storage/db" +import { NotFoundError, eq, and } from "../storage/db" import { SyncEvent } from "@/sync" import { Session } from "./index" import { MessageV2 } from "./message-v2" -import { SessionTable, MessageTable, PartTable, SessionEntryTable } from "./session.sql" +import { SessionTable, MessageTable, PartTable } from "./session.sql" import { Log } from "../util/log" -import { DateTime } from "effect" -import { SessionEntry } from "@/v2/session-entry" const log = Log.create({ service: "session.projector" }) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index a4a7a27d6d..7a7f847ad1 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -10,7 +10,6 @@ import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" -import { SessionStatus } from "./status" export namespace SessionRevert { const log = Log.create({ service: "session.revert" }) diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 89d27b9a7b..c13a005ca6 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -77,11 +77,13 @@ export namespace JsonMigration { async function read(files: string[], start: number, end: number) { const count = end - start + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill const tasks = new Array(count) for (let i = 0; i < count; i++) { tasks[i] = Filesystem.readJson(files[start + i]) } const results = await Promise.allSettled(tasks) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill const items = new Array(count) for (let i = 0; i < results.length; i++) { const result = results[i] @@ -243,6 +245,7 @@ export namespace JsonMigration { for (let i = 0; i < allMessageFiles.length; i += batchSize) { const end = Math.min(i + batchSize, allMessageFiles.length) const batch = await read(allMessageFiles, i, end) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill const values = new Array(batch.length) let count = 0 for (let j = 0; j < batch.length; j++) { @@ -273,6 +276,7 @@ export namespace JsonMigration { for (let i = 0; i < partFiles.length; i += batchSize) { const end = Math.min(i + batchSize, partFiles.length) const batch = await read(partFiles, i, end) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill const values = new Array(batch.length) let count = 0 for (let j = 0; j < batch.length; j++) { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index ce598dae67..e89d57e181 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,6 +1,5 @@ import z from "zod" import type { ZodObject } from "zod" -import { EventEmitter } from "events" import { Database, eq } from "@/storage/db" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 5c82463945..2303618a0b 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -416,7 +416,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) if (match) { yield match[0] } - } catch (e) { + } catch { // Invalid regex pattern, skip } } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bbb07caa40..8f7104e80d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -8,7 +8,6 @@ import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" import { Effect } from "effect" -import { Log } from "@/util/log" export interface TaskPromptOps { cancel(sessionID: SessionID): void diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 9339038b0f..14d5465846 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 55643dc6a7..86967e11a0 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -4,14 +4,9 @@ export function lazy(fn: () => T) { const result = (): T => { if (loaded) return value as T - try { - value = fn() - loaded = true - return value as T - } catch (e) { - // Don't mark as loaded if initialization failed - throw e - } + value = fn() + loaded = true + return value as T } result.reset = () => { diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index b7191a4c9b..97df0a2207 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,8 +1,6 @@ import { Context, Layer, Schema, Effect } from "effect" import { SessionEntry } from "./session-entry" import { Struct } from "effect" -import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" import { Session } from "@/session" import { SessionID } from "@/session/schema" diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index 9c868a4c99..b22180ef31 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -5,7 +5,6 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { mockTuiRuntime } from "../../fixture/tui-runtime" -import { TuiConfig } from "../../../src/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 2cc5092029..5990635aa2 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -1,8 +1,7 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { describe, expect } from "bun:test" import fs from "node:fs/promises" import path from "node:path" -import { Effect, Exit, Layer, Stream } from "effect" +import { Effect, Exit, Stream } from "effect" import type * as PlatformError from "effect/PlatformError" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 813ca344a9..ca74c915be 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" +import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" import { InstanceState } from "../../src/effect/instance-state" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 9e3007f6dc..805c230f3e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,7 +6,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { provideInstance, provideTmpdirInstance, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 9b5441fe22..a80ecf5aee 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -1,3 +1,4 @@ +export {} // TODO: UNCOMMENT WHEN GITLAB SUPPORT IS COMPLETED // // diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 25d7066434..406b3d6d89 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import path from "path" import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Filesystem } from "../../src/util/filesystem" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 1174cdf6a1..aaf34348b9 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" import * as Stream from "effect/Stream" -import path from "path" import z from "zod" import { Bus } from "../../src/bus" import { Config } from "../../src/config" diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index ec1a87e969..3963c815da 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -3,7 +3,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" -import z from "zod" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index a0ea47c89c..e32919aeda 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -49,7 +49,6 @@ import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" -import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 135d44db09..7475411953 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -13,7 +13,6 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "../../src/share/share-next" -import { Storage } from "../../src/storage/storage" import { SessionShareTable } from "../../src/share/share.sql" import { Database, eq } from "../../src/storage/db" import { provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index eb69f1d966..629e5d2d28 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" -import { Tool } from "../../src/tool/tool" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index d07e3dfb41..123710aa46 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -95,7 +95,7 @@ app.message(async ({ message, say }) => { const shareResult = await client.session.share({ path: { id: createResult.data.id } }) if (!shareResult.error && shareResult.data) { - const sessionUrl = shareResult.data.share?.url! + const sessionUrl = shareResult.data.share?.url console.log("🔗 Session shared:", sessionUrl) await app.client.chat.postMessage({ channel, thread_ts: thread, text: sessionUrl }) } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 48444cd017..81e6a52a26 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -37,7 +37,6 @@ import { type UiI18n, useI18n } from "../context/i18n" import { BasicTool, GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" -import { Card } from "./card" import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 98cdf85001..282592ff63 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, createMemo, createEffect, on, For, Show, Index, batch } from "solid-js" +import { createSignal, createMemo, createEffect, on, For, Show, batch } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Message, diff --git a/packages/ui/src/pierre/commented-lines.ts b/packages/ui/src/pierre/commented-lines.ts index d2fa648663..e970b7841b 100644 --- a/packages/ui/src/pierre/commented-lines.ts +++ b/packages/ui/src/pierre/commented-lines.ts @@ -1,5 +1,5 @@ import { type SelectedLineRange } from "@pierre/diffs" -import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection" +import { diffLineIndex, diffRowIndex } from "./diff-selection" export type CommentSide = "additions" | "deletions" diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 105ab0293a..772da9cc2b 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -78,7 +78,7 @@ export function activate(context: vscode.ExtensionContext) { await fetch(`http://localhost:${port}/app`) connected = true break - } catch (e) {} + } catch {} tries-- } while (tries > 0) From 1d81335ab5c7e4f3a4c0652c9c7d59240028fe9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:44:46 -0400 Subject: [PATCH 08/75] feat: unwrap Provider namespace + improved automation script (#22690) --- packages/opencode/script/unwrap-namespace.ts | 193 +- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/models.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- packages/opencode/src/cli/error.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/provider/index.ts | 1 + packages/opencode/src/provider/provider.ts | 3040 ++++++++--------- packages/opencode/src/provider/transform.ts | 2 +- .../opencode/src/server/instance/config.ts | 2 +- .../opencode/src/server/instance/provider.ts | 2 +- packages/opencode/src/server/middleware.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/index.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/overflow.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/system.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/tool/plan.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/fake/provider.ts | 2 +- .../test/provider/amazon-bedrock.test.ts | 2 +- .../opencode/test/provider/gitlab-duo.test.ts | 2 +- .../opencode/test/provider/provider.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 2 +- .../opencode/test/session/message-v2.test.ts | 2 +- .../test/session/processor-effect.test.ts | 2 +- .../test/session/prompt-effect.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 2 +- .../opencode/test/share/share-next.test.ts | 2 +- 40 files changed, 1711 insertions(+), 1599 deletions(-) create mode 100644 packages/opencode/src/provider/index.ts diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts index 65ce498be8..bdb49a7fcf 100644 --- a/packages/opencode/script/unwrap-namespace.ts +++ b/packages/opencode/script/unwrap-namespace.ts @@ -10,11 +10,11 @@ * 1. Reads the file and finds the `export namespace Foo { ... }` block * (uses ast-grep for accurate AST-based boundary detection) * 2. Removes the namespace wrapper and dedents the body - * 3. If the file is index.ts, renames it to .ts - * 4. Creates/updates index.ts with `export * as Foo from "./"` - * 5. Prints the import rewrite commands to run across the codebase - * - * Does NOT auto-rewrite imports — prints the commands so you can review them. + * 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction) + * 4. If the file is index.ts, renames it to .ts + * 5. Creates/updates index.ts with `export * as Foo from "./"` + * 6. Rewrites import paths across src/, test/, and script/ + * 7. Fixes sibling imports within the same directory * * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`) */ @@ -90,22 +90,107 @@ const after = lines.slice(closeLine + 1) const dedented = body.map((line) => { if (line === "") return "" if (line.startsWith(" ")) return line.slice(2) - return line // don't touch lines that aren't indented (shouldn't happen) + return line }) -const newContent = [...before, ...dedented, ...after].join("\n") +let newContent = [...before, ...dedented, ...after].join("\n") + +// --- Fix self-references --- +// After unwrapping, references like `Config.PermissionAction` inside the same file +// need to become just `PermissionAction`. Only fix code positions, not strings. +const exportedNames = new Set() +const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g +for (const line of dedented) { + for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1]) +} +const reExportRegex = /export\s*\{\s*([^}]+)\}/g +for (const line of dedented) { + for (const m of line.matchAll(reExportRegex)) { + for (const name of m[1].split(",")) { + const trimmed = name + .trim() + .split(/\s+as\s+/) + .pop()! + .trim() + if (trimmed) exportedNames.add(trimmed) + } + } +} + +let selfRefCount = 0 +if (exportedNames.size > 0) { + const fixedLines = newContent.split("\n").map((line) => { + // Split line into string-literal and code segments to avoid replacing inside strings + const segments: Array<{ text: string; isString: boolean }> = [] + let i = 0 + let current = "" + let inString: string | null = null + + while (i < line.length) { + const ch = line[i] + if (inString) { + current += ch + if (ch === "\\" && i + 1 < line.length) { + current += line[i + 1] + i += 2 + continue + } + if (ch === inString) { + segments.push({ text: current, isString: true }) + current = "" + inString = null + } + i++ + continue + } + if (ch === '"' || ch === "'" || ch === "`") { + if (current) segments.push({ text: current, isString: false }) + current = ch + inString = ch + i++ + continue + } + if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") { + current += line.slice(i) + segments.push({ text: current, isString: true }) + current = "" + i = line.length + continue + } + current += ch + i++ + } + if (current) segments.push({ text: current, isString: !!inString }) + + return segments + .map((seg) => { + if (seg.isString) return seg.text + let result = seg.text + for (const name of exportedNames) { + const pattern = `${nsName}.${name}` + while (result.includes(pattern)) { + const idx = result.indexOf(pattern) + const charBefore = idx > 0 ? result[idx - 1] : " " + const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " " + if (/\w/.test(charBefore) || /\w/.test(charAfter)) break + result = result.slice(0, idx) + name + result.slice(idx + pattern.length) + selfRefCount++ + } + } + return result + }) + .join("") + }) + newContent = fixedLines.join("\n") +} // Figure out file naming const dir = path.dirname(absPath) const basename = path.basename(absPath, ".ts") const isIndex = basename === "index" - -// The implementation file name (lowercase namespace name if currently index.ts) const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename const implFile = path.join(dir, `${implName}.ts`) const indexFile = path.join(dir, "index.ts") - -// The barrel line const barrelLine = `export * as ${nsName} from "./${implName}"\n` console.log("") @@ -114,6 +199,7 @@ if (isIndex) { } else { console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`) } +if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`) console.log("") if (dryRun) { @@ -128,19 +214,23 @@ if (dryRun) { console.log("") console.log(`=== index.ts ===`) console.log(` ${barrelLine.trim()}`) + console.log("") + if (!isIndex) { + const relDir = path.relative(path.resolve("src"), dir) + console.log(`=== Import rewrites (would apply) ===`) + console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`) + } else { + console.log("No import rewrites needed (was index.ts)") + } } else { - // Write the implementation file if (isIndex) { - // Rename: write new content to implFile, then overwrite index.ts with barrel fs.writeFileSync(implFile, newContent) fs.writeFileSync(indexFile, barrelLine) console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`) console.log(`Wrote index.ts (barrel)`) } else { - // Rewrite in place, create index.ts fs.writeFileSync(absPath, newContent) if (fs.existsSync(indexFile)) { - // Append to existing barrel const existing = fs.readFileSync(indexFile, "utf-8") if (!existing.includes(`export * as ${nsName}`)) { fs.appendFileSync(indexFile, barrelLine) @@ -154,37 +244,60 @@ if (dryRun) { } console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) } -} -// Print the import rewrite guidance -const relDir = path.relative(path.resolve("src"), dir) + // --- Rewrite import paths across src/, test/, script/ --- + const relDir = path.relative(path.resolve("src"), dir) + if (!isIndex) { + const oldTail = `${relDir}/${basename}` + const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d)) + const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], { + stdout: "pipe", + stderr: "pipe", + }) + const filesToRewrite = rgResult.stdout + .toString() + .trim() + .split("\n") + .filter((f) => f.length > 0) -console.log("") -console.log("=== Import rewrites ===") -console.log("") + if (filesToRewrite.length > 0) { + console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`) + for (const file of filesToRewrite) { + const content = fs.readFileSync(file, "utf-8") + fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`)) + } + console.log(` Done: ${oldTail}" → ${relDir}"`) + } else { + console.log("\nNo import rewrites needed") + } + } else { + console.log("\nNo import rewrites needed (was index.ts)") + } -if (!isIndex) { - // Non-index files: imports like "../provider/provider" need to become "../provider" - const oldTail = `${relDir}/${basename}` + // --- Fix sibling imports within the same directory --- + const siblingFiles = fs.readdirSync(dir).filter((f) => { + if (!f.endsWith(".ts")) return false + if (f === "index.ts" || f === `${implName}.ts`) return false + return true + }) - console.log(`# Find all imports to rewrite:`) - console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`) - console.log("") - - // Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences) - console.log("# Auto-rewrite (review diff afterward):") - console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`) - console.log("") - console.log("# What changes:") - console.log(`# import { ${nsName} } from ".../${oldTail}"`) - console.log(`# import { ${nsName} } from ".../${relDir}"`) -} else { - console.log("# File was index.ts — import paths already resolve correctly.") - console.log("# No import rewrites needed!") + let siblingFixCount = 0 + for (const sibFile of siblingFiles) { + const sibPath = path.join(dir, sibFile) + const content = fs.readFileSync(sibPath, "utf-8") + const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g") + if (pattern.test(content)) { + fs.writeFileSync(sibPath, content.replace(pattern, `from "."`)) + siblingFixCount++ + } + } + if (siblingFixCount > 0) { + console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`) + } } console.log("") console.log("=== Verify ===") console.log("") -console.log("bun typecheck # from packages/opencode") -console.log("bun run test # run tests") +console.log("bunx --bun tsgo --noEmit # typecheck") +console.log("bun run test # run tests") diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index c065c64ffc..5f0bcdc24b 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -37,7 +37,7 @@ import { Filesystem } from "../util/filesystem" import { Hash } from "@opencode-ai/shared/util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5887ee28e3..8e6bfe5e9b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,6 +1,6 @@ import { Config } from "../config" import z from "zod" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index b001389461..0e93946a23 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -4,7 +4,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "../../global" import { Agent } from "../../agent/agent" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import path from "path" import fs from "fs/promises" import { Filesystem } from "../../util/filesystem" diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ea45cde664..6c7ad39c1a 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -2,7 +2,7 @@ import { EOL } from "os" import { basename } from "path" import { Effect } from "effect" import { Agent } from "../../../agent/agent" -import { Provider } from "../../../provider/provider" +import { Provider } from "../../../provider" import { Session } from "../../../session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index b6781d0852..191aa2dfdf 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -25,7 +25,7 @@ import { SessionShare } from "@/share/session" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index ad9300da2e..af5ca2f957 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,6 +1,6 @@ import type { Argv } from "yargs" import { Instance } from "../../project/instance" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "../../provider/models" import { cmd } from "./cmd" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2d3574c683..e94ba5d119 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -9,7 +9,7 @@ import { EOL } from "os" import { Filesystem } from "../../util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import { Agent } from "../../agent/agent" import { Permission } from "../../permission" import { Tool } from "../../tool/tool" diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4c6c74ff3d..3d5350cb69 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -52,7 +52,7 @@ import { ExitProvider, useExit } from "./context/exit" import { Session as SessionApi } from "@/session" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" -import { Provider } from "@/provider/provider" +import { Provider } from "@/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index ec3931b209..29f95141c9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -8,7 +8,7 @@ import { Global } from "@/global" import { iife } from "@/util/iife" import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" -import { Provider } from "@/provider/provider" +import { Provider } from "@/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 1277f5046c..6ba110d34f 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -3,7 +3,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { errorFormat } from "@/util/error" import { Config } from "../config" import { MCP } from "../mcp" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { UI } from "./ui" export function FormatError(input: unknown) { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 54139eb777..f9f811e711 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -15,7 +15,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" -import { Provider } from "@/provider/provider" +import { Provider } from "@/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" diff --git a/packages/opencode/src/provider/index.ts b/packages/opencode/src/provider/index.ts new file mode 100644 index 0000000000..3c0174548d --- /dev/null +++ b/packages/opencode/src/provider/index.ts @@ -0,0 +1 @@ +export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1dd6027db9..36a5a68e99 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -59,1651 +59,1649 @@ import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" -export namespace Provider { - const log = Log.create({ service: "provider" }) +const log = Log.create({ service: "provider" }) - function shouldUseCopilotResponsesApi(modelID: string): boolean { - const match = /^gpt-(\d+)/.exec(modelID) - if (!match) return false - return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") - } +function shouldUseCopilotResponsesApi(modelID: string): boolean { + const match = /^gpt-(\d+)/.exec(modelID) + if (!match) return false + return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") +} - function wrapSSE(res: Response, ms: number, ctl: AbortController) { - if (typeof ms !== "number" || ms <= 0) return res - if (!res.body) return res - if (!res.headers.get("content-type")?.includes("text/event-stream")) return res +function wrapSSE(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res - const reader = res.body.getReader() - const body = new ReadableStream({ - async pull(ctrl) { - const part = await new Promise>>((resolve, reject) => { - const id = setTimeout(() => { - const err = new Error("SSE read timed out") - ctl.abort(err) - void reader.cancel(err) + const reader = res.body.getReader() + const body = new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>>((resolve, reject) => { + const id = setTimeout(() => { + const err = new Error("SSE read timed out") + ctl.abort(err) + void reader.cancel(err) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) reject(err) - }, ms) - - reader.read().then( - (part) => { - clearTimeout(id) - resolve(part) - }, - (err) => { - clearTimeout(id) - reject(err) - }, - ) - }) - - if (part.done) { - ctrl.close() - return - } - - ctrl.enqueue(part.value) - }, - async cancel(reason) { - ctl.abort(reason) - await reader.cancel(reason) - }, - }) - - return new Response(body, { - headers: new Headers(res.headers), - status: res.status, - statusText: res.statusText, - }) - } - - type BundledSDK = { - languageModel(modelId: string): LanguageModelV3 - } - - const BUNDLED_PROVIDERS: Record BundledSDK> = { - "@ai-sdk/amazon-bedrock": createAmazonBedrock, - "@ai-sdk/anthropic": createAnthropic, - "@ai-sdk/azure": createAzure, - "@ai-sdk/google": createGoogleGenerativeAI, - "@ai-sdk/google-vertex": createVertex, - "@ai-sdk/google-vertex/anthropic": createVertexAnthropic, - "@ai-sdk/openai": createOpenAI, - "@ai-sdk/openai-compatible": createOpenAICompatible, - "@openrouter/ai-sdk-provider": createOpenRouter, - "@ai-sdk/xai": createXai, - "@ai-sdk/mistral": createMistral, - "@ai-sdk/groq": createGroq, - "@ai-sdk/deepinfra": createDeepInfra, - "@ai-sdk/cerebras": createCerebras, - "@ai-sdk/cohere": createCohere, - "@ai-sdk/gateway": createGateway, - "@ai-sdk/togetherai": createTogetherAI, - "@ai-sdk/perplexity": createPerplexity, - "@ai-sdk/vercel": createVercel, - "@ai-sdk/alibaba": createAlibaba, - "gitlab-ai-provider": createGitLab, - "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, - "venice-ai-sdk-provider": createVenice, - } - - type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise - type CustomVarsLoader = (options: Record) => Record - type CustomDiscoverModels = () => Promise> - type CustomLoader = (provider: Info) => Effect.Effect<{ - autoload: boolean - getModel?: CustomModelLoader - vars?: CustomVarsLoader - options?: Record - discoverModels?: CustomDiscoverModels - }> - - type CustomDep = { - auth: (id: string) => Effect.Effect - config: () => Effect.Effect - env: () => Effect.Effect> - get: (key: string) => Effect.Effect - } - - function useLanguageModel(sdk: any) { - return sdk.responses === undefined && sdk.chat === undefined - } - - function custom(dep: CustomDep): Record { - return { - anthropic: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - }, }, - }), - opencode: Effect.fnUntraced(function* (input: Info) { - const env = yield* dep.env() - const hasKey = iife(() => { - if (input.env.some((item) => env[item])) return true - return false - }) - const ok = - hasKey || - Boolean(yield* dep.auth(input.id)) || - Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey) - - if (!ok) { - for (const [key, value] of Object.entries(input.models)) { - if (value.cost.input === 0) continue - delete input.models[key] - } - } - - return { - autoload: Object.keys(input.models).length > 0, - options: ok ? {} : { apiKey: "public" }, - } - }), - openai: () => - Effect.succeed({ - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - return sdk.responses(modelID) - }, - options: {}, - }), - xai: () => - Effect.succeed({ - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - return sdk.responses(modelID) - }, - options: {}, - }), - "github-copilot": () => - Effect.succeed({ - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) - }, - options: {}, - }), - azure: Effect.fnUntraced(function* (provider: Info) { - const env = yield* dep.env() - const resource = iife(() => { - const name = provider.options?.resourceName - if (typeof name === "string" && name.trim() !== "") return name - return env["AZURE_RESOURCE_NAME"] - }) - - return { - autoload: false, - async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } - }, - options: {}, - vars(_options) { - return { - ...(resource && { AZURE_RESOURCE_NAME: resource }), - } - }, - } - }), - "azure-cognitive-services": Effect.fnUntraced(function* () { - const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") - return { - autoload: false, - async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } - }, - options: { - baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, - }, - } - }), - "amazon-bedrock": Effect.fnUntraced(function* () { - const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"] - const auth = yield* dep.auth("amazon-bedrock") - const env = yield* dep.env() - - // Region precedence: 1) config file, 2) env var, 3) default - const configRegion = providerConfig?.options?.region - const envRegion = env["AWS_REGION"] - const defaultRegion = configRegion ?? envRegion ?? "us-east-1" - - // Profile: config file takes precedence over env var - const configProfile = providerConfig?.options?.profile - const envProfile = env["AWS_PROFILE"] - const profile = configProfile ?? envProfile - - const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"] - - // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, - // until the scope of the Env API is clarified (test only or runtime?) - const awsBearerToken = iife(() => { - const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK - if (envToken) return envToken - if (auth?.type === "api") { - process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key - return auth.key - } - return undefined - }) - - const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"] - - const containerCreds = Boolean( - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, ) + }) - if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) - return { autoload: false } + if (part.done) { + ctrl.close() + return + } - const providerOptions: AmazonBedrockProviderSettings = { - region: defaultRegion, + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} + +type BundledSDK = { + languageModel(modelId: string): LanguageModelV3 +} + +const BUNDLED_PROVIDERS: Record BundledSDK> = { + "@ai-sdk/amazon-bedrock": createAmazonBedrock, + "@ai-sdk/anthropic": createAnthropic, + "@ai-sdk/azure": createAzure, + "@ai-sdk/google": createGoogleGenerativeAI, + "@ai-sdk/google-vertex": createVertex, + "@ai-sdk/google-vertex/anthropic": createVertexAnthropic, + "@ai-sdk/openai": createOpenAI, + "@ai-sdk/openai-compatible": createOpenAICompatible, + "@openrouter/ai-sdk-provider": createOpenRouter, + "@ai-sdk/xai": createXai, + "@ai-sdk/mistral": createMistral, + "@ai-sdk/groq": createGroq, + "@ai-sdk/deepinfra": createDeepInfra, + "@ai-sdk/cerebras": createCerebras, + "@ai-sdk/cohere": createCohere, + "@ai-sdk/gateway": createGateway, + "@ai-sdk/togetherai": createTogetherAI, + "@ai-sdk/perplexity": createPerplexity, + "@ai-sdk/vercel": createVercel, + "@ai-sdk/alibaba": createAlibaba, + "gitlab-ai-provider": createGitLab, + "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, + "venice-ai-sdk-provider": createVenice, +} + +type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise +type CustomVarsLoader = (options: Record) => Record +type CustomDiscoverModels = () => Promise> +type CustomLoader = (provider: Info) => Effect.Effect<{ + autoload: boolean + getModel?: CustomModelLoader + vars?: CustomVarsLoader + options?: Record + discoverModels?: CustomDiscoverModels +}> + +type CustomDep = { + auth: (id: string) => Effect.Effect + config: () => Effect.Effect + env: () => Effect.Effect> + get: (key: string) => Effect.Effect +} + +function useLanguageModel(sdk: any) { + return sdk.responses === undefined && sdk.chat === undefined +} + +function custom(dep: CustomDep): Record { + return { + anthropic: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + }, + }, + }), + opencode: Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const hasKey = iife(() => { + if (input.env.some((item) => env[item])) return true + return false + }) + const ok = + hasKey || + Boolean(yield* dep.auth(input.id)) || + Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey) + + if (!ok) { + for (const [key, value] of Object.entries(input.models)) { + if (value.cost.input === 0) continue + delete input.models[key] } + } - // Only use credential chain if no bearer token exists - // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens) - if (!awsBearerToken) { - // Build credential provider options (only pass profile if specified) - const credentialProviderOptions = profile ? { profile } : {} + return { + autoload: Object.keys(input.models).length > 0, + options: ok ? {} : { apiKey: "public" }, + } + }), + openai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + }), + xai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + }), + "github-copilot": () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) + return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) + }, + options: {}, + }), + azure: Effect.fnUntraced(function* (provider: Info) { + const env = yield* dep.env() + const resource = iife(() => { + const name = provider.options?.resourceName + if (typeof name === "string" && name.trim() !== "") return name + return env["AZURE_RESOURCE_NAME"] + }) - providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) + return { + autoload: false, + async getModel(sdk: any, modelID: string, options?: Record) { + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) + if (options?.["useCompletionUrls"]) { + return sdk.chat(modelID) + } else { + return sdk.responses(modelID) + } + }, + options: {}, + vars(_options) { + return { + ...(resource && { AZURE_RESOURCE_NAME: resource }), + } + }, + } + }), + "azure-cognitive-services": Effect.fnUntraced(function* () { + const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") + return { + autoload: false, + async getModel(sdk: any, modelID: string, options?: Record) { + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) + if (options?.["useCompletionUrls"]) { + return sdk.chat(modelID) + } else { + return sdk.responses(modelID) + } + }, + options: { + baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, + }, + } + }), + "amazon-bedrock": Effect.fnUntraced(function* () { + const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"] + const auth = yield* dep.auth("amazon-bedrock") + const env = yield* dep.env() + + // Region precedence: 1) config file, 2) env var, 3) default + const configRegion = providerConfig?.options?.region + const envRegion = env["AWS_REGION"] + const defaultRegion = configRegion ?? envRegion ?? "us-east-1" + + // Profile: config file takes precedence over env var + const configProfile = providerConfig?.options?.profile + const envProfile = env["AWS_PROFILE"] + const profile = configProfile ?? envProfile + + const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"] + + // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, + // until the scope of the Env API is clarified (test only or runtime?) + const awsBearerToken = iife(() => { + const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK + if (envToken) return envToken + if (auth?.type === "api") { + process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key + return auth.key } + return undefined + }) - // Add custom endpoint if specified (endpoint takes precedence over baseURL) - const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL - if (endpoint) { - providerOptions.baseURL = endpoint - } + const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"] - return { - autoload: true, - options: providerOptions, - async getModel(sdk: any, modelID: string, options?: Record) { - // Skip region prefixing if model already has a cross-region inference profile prefix - // Models from models.dev may already include prefixes like us., eu., global., etc. - const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] - if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) { - return sdk.languageModel(modelID) - } + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) - // Region resolution precedence (highest to lowest): - // 1. options.region from opencode.json provider config - // 2. defaultRegion from AWS_REGION environment variable - // 3. Default "us-east-1" (baked into defaultRegion) - const region = options?.region ?? defaultRegion + if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) + return { autoload: false } - let regionPrefix = region.split("-")[0] + const providerOptions: AmazonBedrockProviderSettings = { + region: defaultRegion, + } - switch (regionPrefix) { - case "us": { - const modelRequiresPrefix = [ - "nova-micro", - "nova-lite", - "nova-pro", - "nova-premier", - "nova-2", - "claude", - "deepseek", - ].some((m) => modelID.includes(m)) - const isGovCloud = region.startsWith("us-gov") - if (modelRequiresPrefix && !isGovCloud) { - modelID = `${regionPrefix}.${modelID}` - } - break + // Only use credential chain if no bearer token exists + // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens) + if (!awsBearerToken) { + // Build credential provider options (only pass profile if specified) + const credentialProviderOptions = profile ? { profile } : {} + + providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) + } + + // Add custom endpoint if specified (endpoint takes precedence over baseURL) + const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL + if (endpoint) { + providerOptions.baseURL = endpoint + } + + return { + autoload: true, + options: providerOptions, + async getModel(sdk: any, modelID: string, options?: Record) { + // Skip region prefixing if model already has a cross-region inference profile prefix + // Models from models.dev may already include prefixes like us., eu., global., etc. + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) { + return sdk.languageModel(modelID) + } + + // Region resolution precedence (highest to lowest): + // 1. options.region from opencode.json provider config + // 2. defaultRegion from AWS_REGION environment variable + // 3. Default "us-east-1" (baked into defaultRegion) + const region = options?.region ?? defaultRegion + + let regionPrefix = region.split("-")[0] + + switch (regionPrefix) { + case "us": { + const modelRequiresPrefix = [ + "nova-micro", + "nova-lite", + "nova-pro", + "nova-premier", + "nova-2", + "claude", + "deepseek", + ].some((m) => modelID.includes(m)) + const isGovCloud = region.startsWith("us-gov") + if (modelRequiresPrefix && !isGovCloud) { + modelID = `${regionPrefix}.${modelID}` } - case "eu": { - const regionRequiresPrefix = [ - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-south-1", - "eu-south-2", - ].some((r) => region.includes(r)) - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) => + break + } + case "eu": { + const regionRequiresPrefix = [ + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-south-1", + "eu-south-2", + ].some((r) => region.includes(r)) + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) => + modelID.includes(m), + ) + if (regionRequiresPrefix && modelRequiresPrefix) { + modelID = `${regionPrefix}.${modelID}` + } + break + } + case "ap": { + const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) + const isTokyoRegion = region === "ap-northeast-1" + if ( + isAustraliaRegion && + ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m)) + ) { + regionPrefix = "au" + modelID = `${regionPrefix}.${modelID}` + } else if (isTokyoRegion) { + // Tokyo region uses jp. prefix for cross-region inference + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => modelID.includes(m), ) - if (regionRequiresPrefix && modelRequiresPrefix) { + if (modelRequiresPrefix) { + regionPrefix = "jp" modelID = `${regionPrefix}.${modelID}` } - break - } - case "ap": { - const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) - const isTokyoRegion = region === "ap-northeast-1" - if ( - isAustraliaRegion && - ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m)) - ) { - regionPrefix = "au" + } else { + // Other APAC regions use apac. prefix + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => + modelID.includes(m), + ) + if (modelRequiresPrefix) { + regionPrefix = "apac" modelID = `${regionPrefix}.${modelID}` - } else if (isTokyoRegion) { - // Tokyo region uses jp. prefix for cross-region inference - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => - modelID.includes(m), - ) - if (modelRequiresPrefix) { - regionPrefix = "jp" - modelID = `${regionPrefix}.${modelID}` - } - } else { - // Other APAC regions use apac. prefix - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => - modelID.includes(m), - ) - if (modelRequiresPrefix) { - regionPrefix = "apac" - modelID = `${regionPrefix}.${modelID}` - } } - break } + break } - - return sdk.languageModel(modelID) - }, - } - }), - openrouter: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - }, - }, - }), - vercel: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "http-referer": "https://opencode.ai/", - "x-title": "opencode", - }, - }, - }), - "google-vertex": Effect.fnUntraced(function* (provider: Info) { - const env = yield* dep.env() - const project = - provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] - - const location = String( - provider.options?.location ?? - env["GOOGLE_VERTEX_LOCATION"] ?? - env["GOOGLE_CLOUD_LOCATION"] ?? - env["VERTEX_LOCATION"] ?? - "us-central1", - ) - - const autoload = Boolean(project) - if (!autoload) return { autoload: false } - return { - autoload: true, - vars(_options: Record) { - const endpoint = - location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` - return { - ...(project && { GOOGLE_VERTEX_PROJECT: project }), - GOOGLE_VERTEX_LOCATION: location, - GOOGLE_VERTEX_ENDPOINT: endpoint, - } - }, - options: { - project, - location, - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - const auth = new GoogleAuth() - const client = await auth.getApplicationDefault() - const token = await client.credential.getAccessToken() - - const headers = new Headers(init?.headers) - headers.set("Authorization", `Bearer ${token.token}`) - - return fetch(input, { ...init, headers }) - }, - }, - async getModel(sdk: any, modelID: string) { - const id = String(modelID).trim() - return sdk.languageModel(id) - }, - } - }), - "google-vertex-anthropic": Effect.fnUntraced(function* () { - const env = yield* dep.env() - const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] - const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global" - const autoload = Boolean(project) - if (!autoload) return { autoload: false } - return { - autoload: true, - options: { - project, - location, - }, - async getModel(sdk: any, modelID) { - const id = String(modelID).trim() - return sdk.languageModel(id) - }, - } - }), - "sap-ai-core": Effect.fnUntraced(function* () { - const auth = yield* dep.auth("sap-ai-core") - // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), - // until the scope of the Env API is clarified (test only or runtime?) - const envServiceKey = iife(() => { - const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY - if (envAICoreServiceKey) return envAICoreServiceKey - if (auth?.type === "api") { - process.env.AICORE_SERVICE_KEY = auth.key - return auth.key } - return undefined - }) - const deploymentId = process.env.AICORE_DEPLOYMENT_ID - const resourceGroup = process.env.AICORE_RESOURCE_GROUP - return { - autoload: !!envServiceKey, - options: envServiceKey ? { deploymentId, resourceGroup } : {}, - async getModel(sdk: any, modelID: string) { - return sdk(modelID) + return sdk.languageModel(modelID) + }, + } + }), + openrouter: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", }, - } + }, }), - zenmux: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - }, + vercel: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "http-referer": "https://opencode.ai/", + "x-title": "opencode", }, - }), - gitlab: Effect.fnUntraced(function* (input: Info) { - const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" + }, + }), + "google-vertex": Effect.fnUntraced(function* (provider: Info) { + const env = yield* dep.env() + const project = + provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] - const auth = yield* dep.auth(input.id) - const apiKey = yield* Effect.sync(() => { - if (auth?.type === "oauth") return auth.access - if (auth?.type === "api") return auth.key - return undefined - }) - const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN")) + const location = String( + provider.options?.location ?? + env["GOOGLE_VERTEX_LOCATION"] ?? + env["GOOGLE_CLOUD_LOCATION"] ?? + env["VERTEX_LOCATION"] ?? + "us-central1", + ) - const providerConfig = (yield* dep.config()).provider?.["gitlab"] + const autoload = Boolean(project) + if (!autoload) return { autoload: false } + return { + autoload: true, + vars(_options: Record) { + const endpoint = + location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` + return { + ...(project && { GOOGLE_VERTEX_PROJECT: project }), + GOOGLE_VERTEX_LOCATION: location, + GOOGLE_VERTEX_ENDPOINT: endpoint, + } + }, + options: { + project, + location, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const auth = new GoogleAuth() + const client = await auth.getApplicationDefault() + const token = await client.credential.getAccessToken() - const aiGatewayHeaders = { - "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, - "anthropic-beta": "context-1m-2025-08-07", - ...providerConfig?.options?.aiGatewayHeaders, + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${token.token}`) + + return fetch(input, { ...init, headers }) + }, + }, + async getModel(sdk: any, modelID: string) { + const id = String(modelID).trim() + return sdk.languageModel(id) + }, + } + }), + "google-vertex-anthropic": Effect.fnUntraced(function* () { + const env = yield* dep.env() + const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] + const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global" + const autoload = Boolean(project) + if (!autoload) return { autoload: false } + return { + autoload: true, + options: { + project, + location, + }, + async getModel(sdk: any, modelID) { + const id = String(modelID).trim() + return sdk.languageModel(id) + }, + } + }), + "sap-ai-core": Effect.fnUntraced(function* () { + const auth = yield* dep.auth("sap-ai-core") + // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), + // until the scope of the Env API is clarified (test only or runtime?) + const envServiceKey = iife(() => { + const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY + if (envAICoreServiceKey) return envAICoreServiceKey + if (auth?.type === "api") { + process.env.AICORE_SERVICE_KEY = auth.key + return auth.key } + return undefined + }) + const deploymentId = process.env.AICORE_DEPLOYMENT_ID + const resourceGroup = process.env.AICORE_RESOURCE_GROUP - const featureFlags = { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...providerConfig?.options?.featureFlags, - } + return { + autoload: !!envServiceKey, + options: envServiceKey ? { deploymentId, resourceGroup } : {}, + async getModel(sdk: any, modelID: string) { + return sdk(modelID) + }, + } + }), + zenmux: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + gitlab: Effect.fnUntraced(function* (input: Info) { + const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" - return { - autoload: !!token, - options: { - instanceUrl, - apiKey: token, + const auth = yield* dep.auth(input.id) + const apiKey = yield* Effect.sync(() => { + if (auth?.type === "oauth") return auth.access + if (auth?.type === "api") return auth.key + return undefined + }) + const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN")) + + const providerConfig = (yield* dep.config()).provider?.["gitlab"] + + const aiGatewayHeaders = { + "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "anthropic-beta": "context-1m-2025-08-07", + ...providerConfig?.options?.aiGatewayHeaders, + } + + const featureFlags = { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...providerConfig?.options?.featureFlags, + } + + return { + autoload: !!token, + options: { + instanceUrl, + apiKey: token, + aiGatewayHeaders, + featureFlags, + }, + async getModel(sdk: ReturnType, modelID: string, options?: Record) { + if (modelID.startsWith("duo-workflow-")) { + const workflowRef = options?.workflowRef as string | undefined + // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef + const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" + const model = sdk.workflowChat(sdkModelID, { + featureFlags, + workflowDefinition: options?.workflowDefinition as string | undefined, + }) + if (workflowRef) { + model.selectedModelRef = workflowRef + } + return model + } + return sdk.agenticChat(modelID, { aiGatewayHeaders, featureFlags, - }, - async getModel(sdk: ReturnType, modelID: string, options?: Record) { - if (modelID.startsWith("duo-workflow-")) { - const workflowRef = options?.workflowRef as string | undefined - // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef - const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" - const model = sdk.workflowChat(sdkModelID, { - featureFlags, - workflowDefinition: options?.workflowDefinition as string | undefined, + }) + }, + async discoverModels(): Promise> { + if (!apiKey) { + log.info("gitlab model discovery skipped: no apiKey") + return {} + } + + try { + const token = apiKey + const getHeaders = (): Record => + auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } + + log.info("gitlab model discovery starting", { instanceUrl }) + const result = await discoverWorkflowModels( + { instanceUrl, getHeaders }, + { workingDirectory: Instance.directory }, + ) + + if (!result.models.length) { + log.info("gitlab model discovery skipped: no models found", { + project: result.project + ? { + id: result.project.id, + path: result.project.pathWithNamespace, + } + : null, }) - if (workflowRef) { - model.selectedModelRef = workflowRef - } - return model - } - return sdk.agenticChat(modelID, { - aiGatewayHeaders, - featureFlags, - }) - }, - async discoverModels(): Promise> { - if (!apiKey) { - log.info("gitlab model discovery skipped: no apiKey") return {} } - try { - const token = apiKey - const getHeaders = (): Record => - auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } - - log.info("gitlab model discovery starting", { instanceUrl }) - const result = await discoverWorkflowModels( - { instanceUrl, getHeaders }, - { workingDirectory: Instance.directory }, - ) - - if (!result.models.length) { - log.info("gitlab model discovery skipped: no models found", { - project: result.project - ? { - id: result.project.id, - path: result.project.pathWithNamespace, - } - : null, - }) - return {} - } - - const models: Record = {} - for (const m of result.models) { - if (!input.models[m.id]) { - models[m.id] = { - id: ModelID.make(m.id), - providerID: ProviderID.make("gitlab"), - name: `Agent Platform (${m.name})`, - family: "", - api: { - id: m.id, - url: instanceUrl, - npm: "gitlab-ai-provider", + const models: Record = {} + for (const m of result.models) { + if (!input.models[m.id]) { + models[m.id] = { + id: ModelID.make(m.id), + providerID: ProviderID.make("gitlab"), + name: `Agent Platform (${m.name})`, + family: "", + api: { + id: m.id, + url: instanceUrl, + npm: "gitlab-ai-provider", + }, + status: "active", + headers: {}, + options: { workflowRef: m.ref }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: m.context, output: m.output }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: true, }, - status: "active", - headers: {}, - options: { workflowRef: m.ref }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: m.context, output: m.output }, - capabilities: { - temperature: false, - reasoning: true, - attachment: true, - toolcall: true, - input: { - text: true, - audio: false, - image: true, - video: false, - pdf: true, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, }, - release_date: "", - variants: {}, - } + interleaved: false, + }, + release_date: "", + variants: {}, } } - - log.info("gitlab model discovery complete", { - count: Object.keys(models).length, - models: Object.keys(models), - }) - return models - } catch (e) { - log.warn("gitlab model discovery failed", { error: e }) - return {} } - }, - } - }), - "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { - // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway), - // skip the account ID check because the URL is already fully specified. - if (input.options?.baseURL) return { autoload: false } - const auth = yield* dep.auth(input.id) - const env = yield* dep.env() - const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) - if (!accountId) - return { - autoload: false, - async getModel() { - throw new Error( - "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=", - ) - }, + log.info("gitlab model discovery complete", { + count: Object.keys(models).length, + models: Object.keys(models), + }) + return models + } catch (e) { + log.warn("gitlab model discovery failed", { error: e }) + return {} } + }, + } + }), + "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { + // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway), + // skip the account ID check because the URL is already fully specified. + if (input.options?.baseURL) return { autoload: false } - const apiKey = yield* Effect.gen(function* () { - const envToken = env["CLOUDFLARE_API_KEY"] - if (envToken) return envToken - if (auth?.type === "api") return auth.key - return undefined - }) - + const auth = yield* dep.auth(input.id) + const env = yield* dep.env() + const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + if (!accountId) return { - autoload: !!apiKey, - options: { - apiKey, - headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, - }, - }, - async getModel(sdk: any, modelID: string) { - return sdk.languageModel(modelID) - }, - vars(_options) { - return { - CLOUDFLARE_ACCOUNT_ID: accountId, - } + autoload: false, + async getModel() { + throw new Error( + "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=", + ) }, } - }), - "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) { - // When baseURL is already configured (e.g. corporate config), skip the ID checks. - if (input.options?.baseURL) return { autoload: false } - const auth = yield* dep.auth(input.id) - const env = yield* dep.env() - const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) - const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) + const apiKey = yield* Effect.gen(function* () { + const envToken = env["CLOUDFLARE_API_KEY"] + if (envToken) return envToken + if (auth?.type === "api") return auth.key + return undefined + }) - if (!accountId || !gateway) { - const missing = [ - !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined, - !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined, - ].filter((x): x is string => Boolean(x)) - return { - autoload: false, - async getModel() { - throw new Error( - `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=`).join(" && ")}`, - ) - }, - } - } - - // Get API token from env or auth - required for authenticated gateways - const apiToken = yield* Effect.gen(function* () { - const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] - if (envToken) return envToken - if (auth?.type === "api") return auth.key - return undefined - }) - - if (!apiToken) { - throw new Error( - "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " + - "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.", - ) - } - - // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility) - const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) - const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) - - const metadata = iife(() => { - if (input.options?.metadata) return input.options.metadata - try { - return JSON.parse(input.options?.headers?.["cf-aig-metadata"]) - } catch { - return undefined - } - }) - const opts = { - metadata, - cacheTtl: input.options?.cacheTtl, - cacheKey: input.options?.cacheKey, - skipCache: input.options?.skipCache, - collectLog: input.options?.collectLog, + return { + autoload: !!apiKey, + options: { + apiKey, headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, }, - } + }, + async getModel(sdk: any, modelID: string) { + return sdk.languageModel(modelID) + }, + vars(_options) { + return { + CLOUDFLARE_ACCOUNT_ID: accountId, + } + }, + } + }), + "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) { + // When baseURL is already configured (e.g. corporate config), skip the ID checks. + if (input.options?.baseURL) return { autoload: false } - const aigateway = createAiGateway({ - accountId, - gateway, - apiKey: apiToken, - ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), - }) - const unified = createUnified() + const auth = yield* dep.auth(input.id) + const env = yield* dep.env() + const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) + if (!accountId || !gateway) { + const missing = [ + !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined, + !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined, + ].filter((x): x is string => Boolean(x)) return { - autoload: true, - async getModel(_sdk: any, modelID: string, _options?: Record) { - // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") - return aigateway(unified(modelID)) + autoload: false, + async getModel() { + throw new Error( + `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=`).join(" && ")}`, + ) }, - options: {}, } - }), - cerebras: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "X-Cerebras-3rd-Party-Integration": "opencode", - }, - }, - }), - kilo: () => - Effect.succeed({ - autoload: false, - options: { - headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", - }, - }, - }), - } - } + } - export const Model = z - .object({ - id: ModelID.zod, - providerID: ProviderID.zod, - api: z.object({ - id: z.string(), - url: z.string(), - npm: z.string(), + // Get API token from env or auth - required for authenticated gateways + const apiToken = yield* Effect.gen(function* () { + const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] + if (envToken) return envToken + if (auth?.type === "api") return auth.key + return undefined + }) + + if (!apiToken) { + throw new Error( + "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " + + "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.", + ) + } + + // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility) + const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) + const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) + + const metadata = iife(() => { + if (input.options?.metadata) return input.options.metadata + try { + return JSON.parse(input.options?.headers?.["cf-aig-metadata"]) + } catch { + return undefined + } + }) + const opts = { + metadata, + cacheTtl: input.options?.cacheTtl, + cacheKey: input.options?.cacheKey, + skipCache: input.options?.skipCache, + collectLog: input.options?.collectLog, + headers: { + "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + }, + } + + const aigateway = createAiGateway({ + accountId, + gateway, + apiKey: apiToken, + ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), + }) + const unified = createUnified() + + return { + autoload: true, + async getModel(_sdk: any, modelID: string, _options?: Record) { + // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") + return aigateway(unified(modelID)) + }, + options: {}, + } + }), + cerebras: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "X-Cerebras-3rd-Party-Integration": "opencode", + }, + }, }), - name: z.string(), - family: z.string().optional(), - capabilities: z.object({ - temperature: z.boolean(), - reasoning: z.boolean(), - attachment: z.boolean(), - toolcall: z.boolean(), - input: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), + kilo: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + } +} + +export const Model = z + .object({ + id: ModelID.zod, + providerID: ProviderID.zod, + api: z.object({ + id: z.string(), + url: z.string(), + npm: z.string(), + }), + name: z.string(), + family: z.string().optional(), + capabilities: z.object({ + temperature: z.boolean(), + reasoning: z.boolean(), + attachment: z.boolean(), + toolcall: z.boolean(), + input: z.object({ + text: z.boolean(), + audio: z.boolean(), + image: z.boolean(), + video: z.boolean(), + pdf: z.boolean(), + }), + output: z.object({ + text: z.boolean(), + audio: z.boolean(), + image: z.boolean(), + video: z.boolean(), + pdf: z.boolean(), + }), + interleaved: z.union([ + z.boolean(), + z.object({ + field: z.enum(["reasoning_content", "reasoning_details"]), }), - output: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), - }), - interleaved: z.union([ - z.boolean(), - z.object({ - field: z.enum(["reasoning_content", "reasoning_details"]), + ]), + }), + cost: z.object({ + input: z.number(), + output: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), + }), + experimentalOver200K: z + .object({ + input: z.number(), + output: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), }), - ]), - }), - cost: z.object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - experimentalOver200K: z - .object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }) - .optional(), - }), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), - }), - status: z.enum(["alpha", "beta", "deprecated", "active"]), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()), - release_date: z.string(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), - }) - .meta({ - ref: "Model", - }) - export type Model = z.infer + }) + .optional(), + }), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + status: z.enum(["alpha", "beta", "deprecated", "active"]), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()), + release_date: z.string(), + variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), + }) + .meta({ + ref: "Model", + }) +export type Model = z.infer - export const Info = z - .object({ - id: ProviderID.zod, - name: z.string(), - source: z.enum(["env", "config", "custom", "api"]), - env: z.string().array(), - key: z.string().optional(), - options: z.record(z.string(), z.any()), - models: z.record(z.string(), Model), - }) - .meta({ - ref: "Provider", - }) - export type Info = z.infer +export const Info = z + .object({ + id: ProviderID.zod, + name: z.string(), + source: z.enum(["env", "config", "custom", "api"]), + env: z.string().array(), + key: z.string().optional(), + options: z.record(z.string(), z.any()), + models: z.record(z.string(), Model), + }) + .meta({ + ref: "Provider", + }) +export type Info = z.infer - export interface Interface { - readonly list: () => Effect.Effect> - readonly getProvider: (providerID: ProviderID) => Effect.Effect - readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect - readonly getLanguage: (model: Model) => Effect.Effect - readonly closest: ( - providerID: ProviderID, - query: string[], - ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> - readonly getSmallModel: (providerID: ProviderID) => Effect.Effect - readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> +export interface Interface { + readonly list: () => Effect.Effect> + readonly getProvider: (providerID: ProviderID) => Effect.Effect + readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect + readonly getLanguage: (model: Model) => Effect.Effect + readonly closest: ( + providerID: ProviderID, + query: string[], + ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> + readonly getSmallModel: (providerID: ProviderID) => Effect.Effect + readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> +} + +interface State { + models: Map + providers: Record + sdk: Map + modelLoaders: Record + varsLoaders: Record +} + +export class Service extends Context.Service()("@opencode/Provider") {} + +function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, } - - interface State { - models: Map - providers: Record - sdk: Map - modelLoaders: Record - varsLoaders: Record - } - - export class Service extends Context.Service()("@opencode/Provider") {} - - function cost(c: ModelsDev.Model["cost"]): Model["cost"] { - const result: Model["cost"] = { - input: c?.input ?? 0, - output: c?.output ?? 0, + if (c?.context_over_200k) { + result.experimentalOver200K = { cache: { - read: c?.cache_read ?? 0, - write: c?.cache_write ?? 0, + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, }, - } - if (c?.context_over_200k) { - result.experimentalOver200K = { - cache: { - read: c.context_over_200k.cache_read ?? 0, - write: c.context_over_200k.cache_write ?? 0, - }, - input: c.context_over_200k.input, - output: c.context_over_200k.output, - } - } - return result - } - - function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { - const m: Model = { - id: ModelID.make(model.id), - providerID: ProviderID.make(provider.id), - name: model.name, - family: model.family, - api: { - id: model.id, - url: model.provider?.api ?? provider.api!, - npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", - }, - status: model.status ?? "active", - headers: {}, - options: {}, - cost: cost(model.cost), - limit: { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - }, - capabilities: { - temperature: model.temperature, - reasoning: model.reasoning, - attachment: model.attachment, - toolcall: model.tool_call, - input: { - text: model.modalities?.input?.includes("text") ?? false, - audio: model.modalities?.input?.includes("audio") ?? false, - image: model.modalities?.input?.includes("image") ?? false, - video: model.modalities?.input?.includes("video") ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? false, - }, - output: { - text: model.modalities?.output?.includes("text") ?? false, - audio: model.modalities?.output?.includes("audio") ?? false, - image: model.modalities?.output?.includes("image") ?? false, - video: model.modalities?.output?.includes("video") ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? false, - }, - interleaved: model.interleaved ?? false, - }, - release_date: model.release_date, - variants: {}, - } - - m.variants = mapValues(ProviderTransform.variants(m), (v) => v) - - return m - } - - export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { - const models: Record = {} - for (const [key, model] of Object.entries(provider.models)) { - models[key] = fromModelsDevModel(provider, model) - for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { - const id = `${model.id}-${mode}` - const m = fromModelsDevModel(provider, model) - m.id = ModelID.make(id) - m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` - if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) - // convert body params to camelCase for ai sdk compatibility - if (opts.provider?.body) - m.options = Object.fromEntries( - Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), - ) - if (opts.provider?.headers) m.headers = opts.provider.headers - models[id] = m - } - } - return { - id: ProviderID.make(provider.id), - source: "custom", - name: provider.name, - env: provider.env ?? [], - options: {}, - models, + input: c.context_over_200k.input, + output: c.context_over_200k.output, } } + return result +} - const layer: Layer.Layer< - Service, - never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service - > = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const config = yield* Config.Service - const auth = yield* Auth.Service - const env = yield* Env.Service - const plugin = yield* Plugin.Service +function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { + const m: Model = { + id: ModelID.make(model.id), + providerID: ProviderID.make(provider.id), + name: model.name, + family: model.family, + api: { + id: model.id, + url: model.provider?.api ?? provider.api!, + npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", + }, + status: model.status ?? "active", + headers: {}, + options: {}, + cost: cost(model.cost), + limit: { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + }, + capabilities: { + temperature: model.temperature, + reasoning: model.reasoning, + attachment: model.attachment, + toolcall: model.tool_call, + input: { + text: model.modalities?.input?.includes("text") ?? false, + audio: model.modalities?.input?.includes("audio") ?? false, + image: model.modalities?.input?.includes("image") ?? false, + video: model.modalities?.input?.includes("video") ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? false, + audio: model.modalities?.output?.includes("audio") ?? false, + image: model.modalities?.output?.includes("image") ?? false, + video: model.modalities?.output?.includes("video") ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? false, + }, + interleaved: model.interleaved ?? false, + }, + release_date: model.release_date, + variants: {}, + } - const state = yield* InstanceState.make(() => - Effect.gen(function* () { - using _ = log.time("state") - const bridge = yield* EffectBridge.make() - const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) - const database = mapValues(modelsDev, fromModelsDevProvider) + m.variants = mapValues(ProviderTransform.variants(m), (v) => v) - const providers: Record = {} as Record - const languages = new Map() - const modelLoaders: { - [providerID: string]: CustomModelLoader - } = {} - const varsLoaders: { - [providerID: string]: CustomVarsLoader - } = {} - const sdk = new Map() - const discoveryLoaders: { - [providerID: string]: CustomDiscoverModels - } = {} - const dep = { - auth: (id: string) => auth.get(id).pipe(Effect.orDie), - config: () => config.get(), - env: () => env.all(), - get: (key: string) => env.get(key), - } + return m +} - log.info("init") +export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const m = fromModelsDevModel(provider, model) + m.id = ModelID.make(id) + m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` + if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) + // convert body params to camelCase for ai sdk compatibility + if (opts.provider?.body) + m.options = Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), + ) + if (opts.provider?.headers) m.headers = opts.provider.headers + models[id] = m + } + } + return { + id: ProviderID.make(provider.id), + source: "custom", + name: provider.name, + env: provider.env ?? [], + options: {}, + models, + } +} - function mergeProvider(providerID: ProviderID, provider: Partial) { - const existing = providers[providerID] - if (existing) { - // @ts-expect-error - providers[providerID] = mergeDeep(existing, provider) - return - } - const match = database[providerID] - if (!match) return +const layer: Layer.Layer< + Service, + never, + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const config = yield* Config.Service + const auth = yield* Auth.Service + const env = yield* Env.Service + const plugin = yield* Plugin.Service + + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + using _ = log.time("state") + const bridge = yield* EffectBridge.make() + const cfg = yield* config.get() + const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const database = mapValues(modelsDev, fromModelsDevProvider) + + const providers: Record = {} as Record + const languages = new Map() + const modelLoaders: { + [providerID: string]: CustomModelLoader + } = {} + const varsLoaders: { + [providerID: string]: CustomVarsLoader + } = {} + const sdk = new Map() + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} + const dep = { + auth: (id: string) => auth.get(id).pipe(Effect.orDie), + config: () => config.get(), + env: () => env.all(), + get: (key: string) => env.get(key), + } + + log.info("init") + + function mergeProvider(providerID: ProviderID, provider: Partial) { + const existing = providers[providerID] + if (existing) { // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + providers[providerID] = mergeDeep(existing, provider) + return + } + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + } + + // load plugins first so config() hook runs before reading cfg.provider + const plugins = yield* plugin.list() + + // now read config providers - includes any modifications from plugin config() hook + const configProviders = Object.entries(cfg.provider ?? {}) + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null + + function isProviderAllowed(providerID: ProviderID): boolean { + if (enabled && !enabled.has(providerID)) return false + if (disabled.has(providerID)) return false + return true + } + + // extend database from config + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: Info = { + id: ProviderID.make(providerID), + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), + source: "config", + models: existing?.models ?? {}, } - // load plugins first so config() hook runs before reading cfg.provider - const plugins = yield* plugin.list() - - // now read config providers - includes any modifications from plugin config() hook - const configProviders = Object.entries(cfg.provider ?? {}) - const disabled = new Set(cfg.disabled_providers ?? []) - const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null - - function isProviderAllowed(providerID: ProviderID): boolean { - if (enabled && !enabled.has(providerID)) return false - if (disabled.has(providerID)) return false - return true - } - - // extend database from config - for (const [providerID, provider] of configProviders) { - const existing = database[providerID] - const parsed: Info = { - id: ProviderID.make(providerID), - name: provider.name ?? existing?.name ?? providerID, - env: provider.env ?? existing?.env ?? [], - options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), - source: "config", - models: existing?.models ?? {}, + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existingModel = parsed.models[model.id ?? modelID] + const name = iife(() => { + if (model.name) return model.name + if (model.id && model.id !== modelID) return modelID + return existingModel?.name ?? modelID + }) + const parsedModel: Model = { + id: ModelID.make(modelID), + api: { + id: model.id ?? existingModel?.api.id ?? modelID, + npm: + model.provider?.npm ?? + provider.npm ?? + existingModel?.api.npm ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible", + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + }, + status: model.status ?? existingModel?.status ?? "active", + name, + providerID: ProviderID.make(providerID), + capabilities: { + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, + audio: + model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, + image: + model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + video: + model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + audio: + model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, + image: + model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, + video: + model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, + }, + interleaved: model.interleaved ?? false, + }, + cost: { + input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, + cache: { + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, + }, + }, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + limit: { + context: model.limit?.context ?? existingModel?.limit?.context ?? 0, + input: model.limit?.input ?? existingModel?.limit?.input, + output: model.limit?.output ?? existingModel?.limit?.output ?? 0, + }, + headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", + variants: {}, } + const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) + parsedModel.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + parsed.models[modelID] = parsedModel + } + database[providerID] = parsed + } - for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existingModel = parsed.models[model.id ?? modelID] - const name = iife(() => { - if (model.name) return model.name - if (model.id && model.id !== modelID) return modelID - return existingModel?.name ?? modelID - }) - const parsedModel: Model = { - id: ModelID.make(modelID), - api: { - id: model.id ?? existingModel?.api.id ?? modelID, - npm: - model.provider?.npm ?? - provider.npm ?? - existingModel?.api.npm ?? - modelsDev[providerID]?.npm ?? - "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, - }, - status: model.status ?? existingModel?.status ?? "active", - name, - providerID: ProviderID.make(providerID), - capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, - reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, - input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, - audio: - model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, - image: - model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, - video: - model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, - }, - output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, - audio: - model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, - image: - model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, - video: - model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, - }, - interleaved: model.interleaved ?? false, - }, - cost: { - input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, - output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, - cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, - }, - }, - options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), - limit: { - context: model.limit?.context ?? existingModel?.limit?.context ?? 0, - input: model.limit?.input ?? existingModel?.limit?.input, - output: model.limit?.output ?? existingModel?.limit?.output ?? 0, - }, - headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), - family: model.family ?? existingModel?.family ?? "", - release_date: model.release_date ?? existingModel?.release_date ?? "", - variants: {}, + // load env + const envs = yield* env.all() + for (const [id, provider] of Object.entries(database)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => envs[item]).find(Boolean) + if (!apiKey) continue + mergeProvider(providerID, { + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, + }) + } + + // load apikeys + const auths = yield* auth.all().pipe(Effect.orDie) + for (const [id, provider] of Object.entries(auths)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + if (provider.type === "api") { + mergeProvider(providerID, { + source: "api", + key: provider.key, + }) + } + } + + // plugin auth loader - database now has entries for config providers + for (const plugin of plugins) { + if (!plugin.auth) continue + const providerID = ProviderID.make(plugin.auth.provider) + if (disabled.has(providerID)) continue + + const stored = yield* auth.get(providerID).pipe(Effect.orDie) + if (!stored) continue + if (!plugin.auth.loader) continue + + const options = yield* Effect.promise(() => + plugin.auth!.loader!( + () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, + database[plugin.auth!.provider], + ), + ) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + + for (const [id, fn] of Object.entries(custom(dep))) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const data = database[providerID] + if (!data) { + log.error("Provider does not exist in model list " + providerID) + continue + } + const result = yield* fn(data) + if (result && (result.autoload || providers[providerID])) { + if (result.getModel) modelLoaders[providerID] = result.getModel + if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels + const opts = result.options ?? {} + const patch: Partial = providers[providerID] + ? { options: opts } + : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + } + + // load config - re-apply with updated data + for (const [id, provider] of configProviders) { + const providerID = ProviderID.make(id) + const partial: Partial = { source: "config" } + if (provider.env) partial.env = provider.env + if (provider.name) partial.name = provider.name + if (provider.options) partial.options = provider.options + mergeProvider(providerID, partial) + } + + const gitlab = ProviderID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model + } } - const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) - parsedModel.variants = mapValues( + } catch (e) { + log.warn("state discovery error", { id: "gitlab", error: e }) + } + }) + } + + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = providers[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } + + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderID.make(id) + if (!isProviderAllowed(providerID)) { + delete providers[providerID] + continue + } + + const configProvider = cfg.provider?.[providerID] + + for (const [modelID, model] of Object.entries(provider.models)) { + model.api.id = model.api.id ?? model.id ?? modelID + if ( + modelID === "gpt-5-chat-latest" || + (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") + ) + delete provider.models[modelID] + if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] + if (model.status === "deprecated") delete provider.models[modelID] + if ( + (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || + (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) + ) + delete provider.models[modelID] + + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + + const configVariants = configProvider?.models?.[modelID]?.variants + if (configVariants && model.variants) { + const merged = mergeDeep(model.variants, configVariants) + model.variants = mapValues( pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]), ) - parsed.models[modelID] = parsedModel - } - database[providerID] = parsed - } - - // load env - const envs = yield* env.all() - for (const [id, provider] of Object.entries(database)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const apiKey = provider.env.map((item) => envs[item]).find(Boolean) - if (!apiKey) continue - mergeProvider(providerID, { - source: "env", - key: provider.env.length === 1 ? apiKey : undefined, - }) - } - - // load apikeys - const auths = yield* auth.all().pipe(Effect.orDie) - for (const [id, provider] of Object.entries(auths)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { - source: "api", - key: provider.key, - }) } } - // plugin auth loader - database now has entries for config providers - for (const plugin of plugins) { - if (!plugin.auth) continue - const providerID = ProviderID.make(plugin.auth.provider) - if (disabled.has(providerID)) continue - - const stored = yield* auth.get(providerID).pipe(Effect.orDie) - if (!stored) continue - if (!plugin.auth.loader) continue - - const options = yield* Effect.promise(() => - plugin.auth!.loader!( - () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], - ), - ) - const opts = options ?? {} - const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } - mergeProvider(providerID, patch) + if (Object.keys(provider.models).length === 0) { + delete providers[providerID] + continue } - for (const [id, fn] of Object.entries(custom(dep))) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const data = database[providerID] - if (!data) { - log.error("Provider does not exist in model list " + providerID) - continue - } - const result = yield* fn(data) - if (result && (result.autoload || providers[providerID])) { - if (result.getModel) modelLoaders[providerID] = result.getModel - if (result.vars) varsLoaders[providerID] = result.vars - if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels - const opts = result.options ?? {} - const patch: Partial = providers[providerID] - ? { options: opts } - : { source: "custom", options: opts } - mergeProvider(providerID, patch) + log.info("found", { providerID }) + } + + return { + models: languages, + providers, + sdk, + modelLoaders, + varsLoaders, + } + }), + ) + + const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + + async function resolveSDK(model: Model, s: State, envs: Record) { + try { + using _ = log.time("getSDK", { + providerID: model.providerID, + }) + const provider = s.providers[model.providerID] + const options = { ...provider.options } + + if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { + delete options.fetch + } + + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + options["includeUsage"] = true + } + + const baseURL = iife(() => { + let url = + typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url + if (!url) return + + const loader = s.varsLoaders[model.providerID] + if (loader) { + const vars = loader(options) + for (const [key, value] of Object.entries(vars)) { + const field = "${" + key + "}" + url = url.replaceAll(field, value) } } - // load config - re-apply with updated data - for (const [id, provider] of configProviders) { - const providerID = ProviderID.make(id) - const partial: Partial = { source: "config" } - if (provider.env) partial.env = provider.env - if (provider.name) partial.name = provider.name - if (provider.options) partial.options = provider.options - mergeProvider(providerID, partial) + url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { + const val = envs[String(key)] + return val ?? item + }) + return url + }) + + if (baseURL !== undefined) options["baseURL"] = baseURL + if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key + if (model.headers) + options["headers"] = { + ...options["headers"], + ...model.headers, } - const gitlab = ProviderID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } - } - } catch (e) { - log.warn("state discovery error", { id: "gitlab", error: e }) - } - }) - } - - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = providers[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelID.make(id), - providerID, - }, - ]), - ) - }) - } - - for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderID.make(id) - if (!isProviderAllowed(providerID)) { - delete providers[providerID] - continue - } - - const configProvider = cfg.provider?.[providerID] - - for (const [modelID, model] of Object.entries(provider.models)) { - model.api.id = model.api.id ?? model.id ?? modelID - if ( - modelID === "gpt-5-chat-latest" || - (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") - ) - delete provider.models[modelID] - if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] - if (model.status === "deprecated") delete provider.models[modelID] - if ( - (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || - (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) - ) - delete provider.models[modelID] - - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) - - const configVariants = configProvider?.models?.[modelID]?.variants - if (configVariants && model.variants) { - const merged = mergeDeep(model.variants, configVariants) - model.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - } - } - - if (Object.keys(provider.models).length === 0) { - delete providers[providerID] - continue - } - - log.info("found", { providerID }) - } - - return { - models: languages, - providers, - sdk, - modelLoaders, - varsLoaders, - } - }), - ) - - const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) - - async function resolveSDK(model: Model, s: State, envs: Record) { - try { - using _ = log.time("getSDK", { + const key = Hash.fast( + JSON.stringify({ providerID: model.providerID, - }) - const provider = s.providers[model.providerID] - const options = { ...provider.options } + npm: model.api.npm, + options, + }), + ) + const existing = s.sdk.get(key) + if (existing) return existing - if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { - delete options.fetch - } + const customFetch = options["fetch"] + const chunkTimeout = options["chunkTimeout"] + delete options["chunkTimeout"] - if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { - options["includeUsage"] = true - } + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + const fetchFn = customFetch ?? fetch + const opts = init ?? {} + const chunkAbortCtl = + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const signals: AbortSignal[] = [] - const baseURL = iife(() => { - let url = - typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url - if (!url) return + if (opts.signal) signals.push(opts.signal) + if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) + signals.push(AbortSignal.timeout(options["timeout"])) - const loader = s.varsLoaders[model.providerID] - if (loader) { - const vars = loader(options) - for (const [key, value] of Object.entries(vars)) { - const field = "${" + key + "}" - url = url.replaceAll(field, value) - } - } + const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) + if (combined) opts.signal = combined - url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { - const val = envs[String(key)] - return val ?? item - }) - return url - }) - - if (baseURL !== undefined) options["baseURL"] = baseURL - if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key - if (model.headers) - options["headers"] = { - ...options["headers"], - ...model.headers, - } - - const key = Hash.fast( - JSON.stringify({ - providerID: model.providerID, - npm: model.api.npm, - options, - }), - ) - const existing = s.sdk.get(key) - if (existing) return existing - - const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] - delete options["chunkTimeout"] - - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { - const fetchFn = customFetch ?? fetch - const opts = init ?? {} - const chunkAbortCtl = - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined - const signals: AbortSignal[] = [] - - if (opts.signal) signals.push(opts.signal) - if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) - if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) - signals.push(AbortSignal.timeout(options["timeout"])) - - const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) - if (combined) opts.signal = combined - - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { - const body = JSON.parse(opts.body as string) - const isAzure = model.providerID.includes("azure") - const keepIds = isAzure && body.store === true - if (!keepIds && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) { - delete item.id - } + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id } - opts.body = JSON.stringify(body) } + opts.body = JSON.stringify(body) } - - const res = await fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }) - - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { - log.info("using bundled provider", { - providerID: model.providerID, - pkg: model.api.npm, - }) - const loaded = bundledFn({ - name: model.providerID, - ...options, - }) - s.sdk.set(key, loaded) - return loaded as SDK - } + const res = await fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }) - let installedPath: string - if (!model.api.npm.startsWith("file://")) { - const item = await Npm.add(model.api.npm) - if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) - installedPath = item.entrypoint - } else { - log.info("loading local provider", { pkg: model.api.npm }) - installedPath = model.api.npm - } + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } - const mod = await import(installedPath) - - const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] - const loaded = fn({ + const bundledFn = BUNDLED_PROVIDERS[model.api.npm] + if (bundledFn) { + log.info("using bundled provider", { + providerID: model.providerID, + pkg: model.api.npm, + }) + const loaded = bundledFn({ name: model.providerID, ...options, }) s.sdk.set(key, loaded) return loaded as SDK + } + + let installedPath: string + if (!model.api.npm.startsWith("file://")) { + const item = await Npm.add(model.api.npm) + if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) + installedPath = item.entrypoint + } else { + log.info("loading local provider", { pkg: model.api.npm }) + installedPath = model.api.npm + } + + const mod = await import(installedPath) + + const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] + const loaded = fn({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } catch (e) { + throw new InitError({ providerID: model.providerID }, { cause: e }) + } + } + + const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => + InstanceState.use(state, (s) => s.providers[providerID]), + ) + + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) { + const available = Object.keys(s.providers) + const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + + const info = provider.models[modelID] + if (!info) { + const available = Object.keys(provider.models) + const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + return info + }) + + const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { + const s = yield* InstanceState.get(state) + const envs = yield* env.all() + const key = `${model.providerID}/${model.id}` + if (s.models.has(key)) return s.models.get(key)! + + return yield* Effect.promise(async () => { + const provider = s.providers[model.providerID] + const sdk = await resolveSDK(model, s, envs) + + try { + const language = s.modelLoaders[model.providerID] + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { + ...provider.options, + ...model.options, + }) + : sdk.languageModel(model.api.id) + s.models.set(key, language) + return language } catch (e) { - throw new InitError({ providerID: model.providerID }, { cause: e }) + if (e instanceof NoSuchModelError) + throw new ModelNotFoundError( + { + modelID: model.id, + providerID: model.providerID, + }, + { cause: e }, + ) + throw e + } + }) + }) + + const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + for (const item of query) { + for (const modelID of Object.keys(provider.models)) { + if (modelID.includes(item)) return { providerID, modelID } + } + } + return undefined + }) + + const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { + const cfg = yield* config.get() + + if (cfg.small_model) { + const parsed = parseModel(cfg.small_model) + return yield* getModel(parsed.providerID, parsed.modelID) + } + + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + + let priority = [ + "claude-haiku-4-5", + "claude-haiku-4.5", + "3-5-haiku", + "3.5-haiku", + "gemini-3-flash", + "gemini-2.5-flash", + "gpt-5-nano", + ] + if (providerID.startsWith("opencode")) { + priority = ["gpt-5-nano"] + } + if (providerID.startsWith("github-copilot")) { + priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] + } + for (const item of priority) { + if (providerID === ProviderID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) + + const globalMatch = candidates.find((m) => m.startsWith("global.")) + if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) + + const region = provider.options?.region + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) + } + } + + const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) + if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) + } else { + for (const model of Object.keys(provider.models)) { + if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) + } } } - const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => - InstanceState.use(state, (s) => s.providers[providerID]), + return undefined + }) + + const defaultModel = Effect.fn("Provider.defaultModel")(function* () { + const cfg = yield* config.get() + if (cfg.model) return parseModel(cfg.model) + + const s = yield* InstanceState.get(state) + const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( + Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { + if (!isRecord(x) || !Array.isArray(x.recent)) return [] + return x.recent.flatMap((item) => { + if (!isRecord(item)) return [] + if (typeof item.providerID !== "string") return [] + if (typeof item.modelID !== "string") return [] + return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }] + }) + }), + Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])), ) + for (const entry of recent) { + const provider = s.providers[entry.providerID] + if (!provider) continue + if (!provider.models[entry.modelID]) continue + return { providerID: entry.providerID, modelID: entry.modelID } + } - const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) { - const available = Object.keys(s.providers) - const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 }) - throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) - } + const provider = Object.values(s.providers).find( + (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), + ) + if (!provider) throw new Error("no providers found") + const [model] = sort(Object.values(provider.models)) + if (!model) throw new Error("no models found") + return { + providerID: provider.id, + modelID: model.id, + } + }) - const info = provider.models[modelID] - if (!info) { - const available = Object.keys(provider.models) - const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }) - throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) - } - return info - }) + return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + }), +) - const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { - const s = yield* InstanceState.get(state) - const envs = yield* env.all() - const key = `${model.providerID}/${model.id}` - if (s.models.has(key)) return s.models.get(key)! +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Plugin.defaultLayer), + ), +) - return yield* Effect.promise(async () => { - const provider = s.providers[model.providerID] - const sdk = await resolveSDK(model, s, envs) - - try { - const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, { - ...provider.options, - ...model.options, - }) - : sdk.languageModel(model.api.id) - s.models.set(key, language) - return language - } catch (e) { - if (e instanceof NoSuchModelError) - throw new ModelNotFoundError( - { - modelID: model.id, - providerID: model.providerID, - }, - { cause: e }, - ) - throw e - } - }) - }) - - const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) return undefined - for (const item of query) { - for (const modelID of Object.keys(provider.models)) { - if (modelID.includes(item)) return { providerID, modelID } - } - } - return undefined - }) - - const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { - const cfg = yield* config.get() - - if (cfg.small_model) { - const parsed = parseModel(cfg.small_model) - return yield* getModel(parsed.providerID, parsed.modelID) - } - - const s = yield* InstanceState.get(state) - const provider = s.providers[providerID] - if (!provider) return undefined - - let priority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] - if (providerID.startsWith("opencode")) { - priority = ["gpt-5-nano"] - } - if (providerID.startsWith("github-copilot")) { - priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] - } - for (const item of priority) { - if (providerID === ProviderID.amazonBedrock) { - const crossRegionPrefixes = ["global.", "us.", "eu."] - const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) - - const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) - - const region = provider.options?.region - if (region) { - const regionPrefix = region.split("-")[0] - if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) - } - } - - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) - } - } - } - - return undefined - }) - - const defaultModel = Effect.fn("Provider.defaultModel")(function* () { - const cfg = yield* config.get() - if (cfg.model) return parseModel(cfg.model) - - const s = yield* InstanceState.get(state) - const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( - Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { - if (!isRecord(x) || !Array.isArray(x.recent)) return [] - return x.recent.flatMap((item) => { - if (!isRecord(item)) return [] - if (typeof item.providerID !== "string") return [] - if (typeof item.modelID !== "string") return [] - return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }] - }) - }), - Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])), - ) - for (const entry of recent) { - const provider = s.providers[entry.providerID] - if (!provider) continue - if (!provider.models[entry.modelID]) continue - return { providerID: entry.providerID, modelID: entry.modelID } - } - - const provider = Object.values(s.providers).find( - (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), - ) - if (!provider) throw new Error("no providers found") - const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") - return { - providerID: provider.id, - modelID: model.id, - } - }) - - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) - }), - ) - - export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - ), - ) - - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] - export function sort(models: T[]) { - return sortBy( - models, - [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], - [(model) => (model.id.includes("latest") ? 0 : 1), "asc"], - [(model) => model.id, "desc"], - ) - } - - export function parseModel(model: string) { - const [providerID, ...rest] = model.split("/") - return { - providerID: ProviderID.make(providerID), - modelID: ModelID.make(rest.join("/")), - } - } - - export const ModelNotFoundError = NamedError.create( - "ProviderModelNotFoundError", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - suggestions: z.array(z.string()).optional(), - }), - ) - - export const InitError = NamedError.create( - "ProviderInitError", - z.object({ - providerID: ProviderID.zod, - }), +const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] +export function sort(models: T[]) { + return sortBy( + models, + [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], + [(model) => (model.id.includes("latest") ? 0 : 1), "asc"], + [(model) => model.id, "desc"], ) } + +export function parseModel(model: string) { + const [providerID, ...rest] = model.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(rest.join("/")), + } +} + +export const ModelNotFoundError = NamedError.create( + "ProviderModelNotFoundError", + z.object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, + suggestions: z.array(z.string()).optional(), + }), +) + +export const InitError = NamedError.create( + "ProviderInitError", + z.object({ + providerID: ProviderID.zod, + }), +) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 61561ec969..3138f8e293 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -2,7 +2,7 @@ import type { ModelMessage } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" -import type { Provider } from "./provider" +import type { Provider } from "." import type { ModelsDev } from "./models" import { iife } from "@/util/iife" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 68a6b50764..e3291a8c36 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Config } from "../../config" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index b9e39d4eff..8018dfbea4 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Config } from "../../config" -import { Provider } from "../../provider/provider" +import { Provider } from "../../provider" import { ModelsDev } from "../../provider/models" import { ProviderAuth } from "../../provider/auth" import { ProviderID } from "../../provider/schema" diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 6e91651866..880c432c7c 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -1,4 +1,4 @@ -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { NamedError } from "@opencode-ai/shared/util/error" import { NotFoundError } from "../storage/db" import { Session } from "../session" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 03f9723112..644a76752d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." import { SessionID, MessageID, PartID } from "./schema" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { MessageV2 } from "./message-v2" import z from "zod" import { Token } from "../util/token" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 49d8359497..585b9a135d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -24,7 +24,7 @@ import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" -import type { Provider } from "@/provider/provider" +import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 2efe4a4054..3db1c99d6b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,4 +1,4 @@ -import { Provider } from "@/provider/provider" +import { Provider } from "@/provider" import { Log } from "@/util/log" import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8c82d4d73f..2a501167a5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -12,7 +12,7 @@ import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import type { SystemError } from "bun" -import type { Provider } from "@/provider/provider" +import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" import { EffectLogger } from "@/effect/logger" diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index c4c6d09279..10f4bccda3 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,5 +1,5 @@ import type { Config } from "@/config" -import type { Provider } from "@/provider/provider" +import type { Provider } from "@/provider" import { ProviderTransform } from "@/provider/transform" import type { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 0f8cd41b30..1ae70c3c6e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -15,7 +15,7 @@ import type { SessionID } from "./schema" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { SessionSummary } from "./summary" -import type { Provider } from "@/provider/provider" +import type { Provider } from "@/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { Log } from "@/util/log" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f2a160e268..4e10fdf2d6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -7,7 +7,7 @@ import { Log } from "../util/log" import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { SessionCompaction } from "./compaction" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 2a001ba9b1..952ff5b04b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -11,7 +11,7 @@ import PROMPT_KIMI from "./prompt/kimi.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" -import type { Provider } from "@/provider/provider" +import type { Provider } from "@/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 667e0720c4..c764c20b99 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { Account } from "@/account" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { Provider } from "@/provider/provider" +import { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 1613821fe0..cc52c2abde 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -5,7 +5,7 @@ import { Tool } from "./tool" import { Question } from "../question" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { Instance } from "../project/instance" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2e9971ad71..ef55758a57 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,7 +17,7 @@ import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index b6f72f53db..bfb185a4b1 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -1,5 +1,5 @@ import { Effect, Layer } from "effect" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" export namespace ProviderTest { diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 6783ff5889..6809e4d17e 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,7 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index a80ecf5aee..907a32d61d 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,7 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" -// import { Provider } from "../../src/provider/provider" +// import { Provider } from "../../src/provider" // import { Env } from "../../src/env" // import { Global } from "../../src/global" // import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index dafa9dd822..a6a93e8091 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -7,7 +7,7 @@ import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "../../src/provider/models" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { Env } from "../../src/env" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index aaf34348b9..d658f48bd8 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -20,7 +20,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { ModelID, ProviderID } from "../../src/provider/schema" -import type { Provider } from "../../src/provider/provider" +import type { Provider } from "../../src/provider" import * as SessionProcessorModule from "../../src/session/processor" import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index a7fde90f01..e908545d4a 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,7 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { ProviderTransform } from "../../src/provider/transform" import { ModelsDev } from "../../src/provider/models" import { ProviderID, ModelID } from "../../src/provider/schema" diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 64a5d3e4b2..6d4e994a87 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" -import type { Provider } from "../../src/provider/provider" +import type { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { Question } from "../../src/question" diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 10945be188..982399d6d1 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -8,7 +8,7 @@ import { Bus } from "../../src/bus" import { Config } from "../../src/config" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 3963c815da..91297aed1d 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -12,9 +12,9 @@ import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { Provider as ProviderSvc } from "../../src/provider/provider" +import { Provider as ProviderSvc } from "../../src/provider" import { Env } from "../../src/env" -import type { Provider } from "../../src/provider/provider" +import type { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index e32919aeda..3681b14f7a 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -38,7 +38,7 @@ import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { Provider as ProviderSvc } from "../../src/provider/provider" +import { Provider as ProviderSvc } from "../../src/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" import { Skill } from "../../src/skill" diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 7475411953..8150e03623 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -9,7 +9,7 @@ import { AccountRepo } from "../../src/account/repo" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Bus } from "../../src/bus" import { Config } from "../../src/config" -import { Provider } from "../../src/provider/provider" +import { Provider } from "../../src/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "../../src/share/share-next" From 7baf998752f0cd1df0ff818d1cd5587e27f8d721 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 01:45:44 +0000 Subject: [PATCH 09/75] chore: generate --- packages/opencode/src/provider/provider.ts | 23 +++++++--------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 36a5a68e99..fed4d93583 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -454,8 +454,7 @@ function custom(dep: CustomDep): Record { return { autoload: true, vars(_options: Record) { - const endpoint = - location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` + const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` return { ...(project && { GOOGLE_VERTEX_PROJECT: project }), GOOGLE_VERTEX_LOCATION: location, @@ -1136,12 +1135,9 @@ const layer: Layer.Layer< toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, - audio: - model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, - image: - model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, - video: - model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, + audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, + image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, }, output: { @@ -1246,9 +1242,7 @@ const layer: Layer.Layer< if (result.vars) varsLoaders[providerID] = result.vars if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels const opts = result.options ?? {} - const patch: Partial = providers[providerID] - ? { options: opts } - : { source: "custom", options: opts } + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) } } @@ -1424,8 +1418,7 @@ const layer: Layer.Layer< options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { const fetchFn = customFetch ?? fetch const opts = init ?? {} - const chunkAbortCtl = - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined const signals: AbortSignal[] = [] if (opts.signal) signals.push(opts.signal) @@ -1646,9 +1639,7 @@ const layer: Layer.Layer< return { providerID: entry.providerID, modelID: entry.modelID } } - const provider = Object.values(s.providers).find( - (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), - ) + const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) if (!provider) throw new Error("no providers found") const [model] = sort(Object.values(provider.models)) if (!model) throw new Error("no models found") From 66257663509bc12bb208c4c65f73c45206106aae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:56:02 -0400 Subject: [PATCH 10/75] feat: unwrap MCP namespace to flat exports + barrel (#22693) --- packages/opencode/src/mcp/index.ts | 931 +---------------------------- packages/opencode/src/mcp/mcp.ts | 928 ++++++++++++++++++++++++++++ 2 files changed, 929 insertions(+), 930 deletions(-) create mode 100644 packages/opencode/src/mcp/mcp.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index cbaa2c24b3..c42b9eb5c1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,930 +1 @@ -import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { - CallToolResultSchema, - type Tool as MCPToolDef, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js" -import { Config } from "../config" -import { Log } from "../util/log" -import { NamedError } from "@opencode-ai/shared/util/error" -import z from "zod/v4" -import { Instance } from "../project/instance" -import { Installation } from "../installation" -import { withTimeout } from "@/util/timeout" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { McpOAuthProvider } from "./oauth-provider" -import { McpOAuthCallback } from "./oauth-callback" -import { McpAuth } from "./auth" -import { BusEvent } from "../bus/bus-event" -import { Bus } from "@/bus" -import { TuiEvent } from "@/cli/cmd/tui/event" -import open from "open" -import { Effect, Exit, Layer, Option, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { InstanceState } from "@/effect/instance-state" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" - -export namespace MCP { - const log = Log.create({ service: "mcp" }) - const DEFAULT_TIMEOUT = 30_000 - - export const Resource = z - .object({ - name: z.string(), - uri: z.string(), - description: z.string().optional(), - mimeType: z.string().optional(), - client: z.string(), - }) - .meta({ ref: "McpResource" }) - export type Resource = z.infer - - export const ToolsChanged = BusEvent.define( - "mcp.tools.changed", - z.object({ - server: z.string(), - }), - ) - - export const BrowserOpenFailed = BusEvent.define( - "mcp.browser.open.failed", - z.object({ - mcpName: z.string(), - url: z.string(), - }), - ) - - export const Failed = NamedError.create( - "MCPFailed", - z.object({ - name: z.string(), - }), - ) - - type MCPClient = Client - - export const Status = z - .discriminatedUnion("status", [ - z - .object({ - status: z.literal("connected"), - }) - .meta({ - ref: "MCPStatusConnected", - }), - z - .object({ - status: z.literal("disabled"), - }) - .meta({ - ref: "MCPStatusDisabled", - }), - z - .object({ - status: z.literal("failed"), - error: z.string(), - }) - .meta({ - ref: "MCPStatusFailed", - }), - z - .object({ - status: z.literal("needs_auth"), - }) - .meta({ - ref: "MCPStatusNeedsAuth", - }), - z - .object({ - status: z.literal("needs_client_registration"), - error: z.string(), - }) - .meta({ - ref: "MCPStatusNeedsClientRegistration", - }), - ]) - .meta({ - ref: "MCPStatus", - }) - export type Status = z.infer - - // Store transports for OAuth servers to allow finishing auth - type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport - const pendingOAuthTransports = new Map() - - // Prompt cache types - type PromptInfo = Awaited>["prompts"][number] - type ResourceInfo = Awaited>["resources"][number] - type McpEntry = NonNullable[string] - - function isMcpConfigured(entry: McpEntry): entry is Config.Mcp { - return typeof entry === "object" && entry !== null && "type" in entry - } - - const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") - - // Convert MCP tool definition to AI SDK Tool type - function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { - const inputSchema = mcpTool.inputSchema - - // Spread first, then override type to ensure it's always "object" - const schema: JSONSchema7 = { - ...(inputSchema as JSONSchema7), - type: "object", - properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"], - additionalProperties: false, - } - - return dynamicTool({ - description: mcpTool.description ?? "", - inputSchema: jsonSchema(schema), - execute: async (args: unknown) => { - return client.callTool( - { - name: mcpTool.name, - arguments: (args || {}) as Record, - }, - CallToolResultSchema, - { - resetTimeoutOnProgress: true, - timeout, - }, - ) - }, - }) - } - - function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), - Effect.catch((err) => { - log.error("failed to get tools from client", { key, error: err }) - return Effect.succeed(undefined) - }), - ) - } - - function fetchFromClient( - clientName: string, - client: Client, - listFn: (c: Client) => Promise, - label: string, - ) { - return Effect.tryPromise({ - try: () => listFn(client), - catch: (e: any) => { - log.error(`failed to get ${label}`, { clientName, error: e.message }) - return e - }, - }).pipe( - Effect.map((items) => { - const out: Record = {} - const sanitizedClient = sanitize(clientName) - for (const item of items) { - out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName } - } - return out - }), - Effect.orElseSucceed(() => undefined), - ) - } - - interface CreateResult { - mcpClient?: MCPClient - status: Status - defs?: MCPToolDef[] - } - - interface AuthResult { - authorizationUrl: string - oauthState: string - client?: MCPClient - } - - // --- Effect Service --- - - interface State { - status: Record - clients: Record - defs: Record - } - - export interface Interface { - readonly status: () => Effect.Effect> - readonly clients: () => Effect.Effect> - readonly tools: () => Effect.Effect> - readonly prompts: () => Effect.Effect> - readonly resources: () => Effect.Effect> - readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record | Status }> - readonly connect: (name: string) => Effect.Effect - readonly disconnect: (name: string) => Effect.Effect - readonly getPrompt: ( - clientName: string, - name: string, - args?: Record, - ) => Effect.Effect> | undefined> - readonly readResource: ( - clientName: string, - resourceUri: string, - ) => Effect.Effect> | undefined> - readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }> - readonly authenticate: (mcpName: string) => Effect.Effect - readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect - readonly removeAuth: (mcpName: string) => Effect.Effect - readonly supportsOAuth: (mcpName: string) => Effect.Effect - readonly hasStoredTokens: (mcpName: string) => Effect.Effect - readonly getAuthStatus: (mcpName: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/MCP") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const auth = yield* McpAuth.Service - const bus = yield* Bus.Service - - type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport - - /** - * Connect a client via the given transport with resource safety: - * on failure the transport is closed; on success the caller owns it. - */ - const connectTransport = (transport: Transport, timeout: number) => - Effect.acquireUseRelease( - Effect.succeed(transport), - (t) => - Effect.tryPromise({ - try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) - return withTimeout(client.connect(t), timeout).then(() => client) - }, - catch: (e) => (e instanceof Error ? e : new Error(String(e))), - }), - (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), - ) - - const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } - - const connectRemote = Effect.fn("MCP.connectRemote")(function* ( - key: string, - mcp: Config.Mcp & { type: "remote" }, - ) { - const oauthDisabled = mcp.oauth === false - const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined - let authProvider: McpOAuthProvider | undefined - - if (!oauthDisabled) { - authProvider = new McpOAuthProvider( - key, - mcp.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async (url) => { - log.info("oauth redirect requested", { key, url: url.toString() }) - }, - }, - auth, - ) - } - - const transports: Array<{ name: string; transport: TransportWithAuth }> = [ - { - name: "StreamableHTTP", - transport: new StreamableHTTPClientTransport(new URL(mcp.url), { - authProvider, - requestInit: mcp.headers ? { headers: mcp.headers } : undefined, - }), - }, - { - name: "SSE", - transport: new SSEClientTransport(new URL(mcp.url), { - authProvider, - requestInit: mcp.headers ? { headers: mcp.headers } : undefined, - }), - }, - ] - - const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT - let lastStatus: Status | undefined - - for (const { name, transport } of transports) { - const result = yield* connectTransport(transport, connectTimeout).pipe( - Effect.map((client) => ({ client, transportName: name })), - Effect.catch((error) => { - const lastError = error instanceof Error ? error : new Error(String(error)) - const isAuthError = - error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) - - if (isAuthError) { - log.info("mcp server requires authentication", { key, transport: name }) - - if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { - lastStatus = { - status: "needs_client_registration" as const, - error: "Server does not support dynamic client registration. Please provide clientId in config.", - } - return bus - .publish(TuiEvent.ToastShow, { - title: "MCP Authentication Required", - message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, - variant: "warning", - duration: 8000, - }) - .pipe(Effect.ignore, Effect.as(undefined)) - } else { - pendingOAuthTransports.set(key, transport) - lastStatus = { status: "needs_auth" as const } - return bus - .publish(TuiEvent.ToastShow, { - title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, - variant: "warning", - duration: 8000, - }) - .pipe(Effect.ignore, Effect.as(undefined)) - } - } - - log.debug("transport connection failed", { - key, - transport: name, - url: mcp.url, - error: lastError.message, - }) - lastStatus = { status: "failed" as const, error: lastError.message } - return Effect.succeed(undefined) - }), - ) - if (result) { - log.info("connected", { key, transport: result.transportName }) - return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status } - } - // If this was an auth error, stop trying other transports - if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break - } - - return { - client: undefined as MCPClient | undefined, - status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status, - } - }) - - const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) { - const [cmd, ...args] = mcp.command - const cwd = Instance.directory - const transport = new StdioClientTransport({ - stderr: "pipe", - command: cmd, - args, - cwd, - env: { - ...process.env, - ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), - ...mcp.environment, - }, - }) - transport.stderr?.on("data", (chunk: Buffer) => { - log.info(`mcp stderr: ${chunk.toString()}`, { key }) - }) - - const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT - return yield* connectTransport(transport, connectTimeout).pipe( - Effect.map((client): { client: MCPClient | undefined; status: Status } => ({ - client, - status: { status: "connected" }, - })), - Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => { - const msg = error instanceof Error ? error.message : String(error) - log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg }) - return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } }) - }), - ) - }) - - const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) { - if (mcp.enabled === false) { - log.info("mcp server disabled", { key }) - return DISABLED_RESULT - } - - log.info("found", { key, type: mcp.type }) - - const { client: mcpClient, status } = - mcp.type === "remote" - ? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" }) - : yield* connectLocal(key, mcp as Config.Mcp & { type: "local" }) - - if (!mcpClient) { - return { status } satisfies CreateResult - } - - const listed = yield* defs(key, mcpClient, mcp.timeout) - if (!listed) { - yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore) - return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult - } - - log.info("create() successfully created client", { key, toolCount: listed.length }) - return { mcpClient, status, defs: listed } satisfies CreateResult - }) - const cfgSvc = yield* Config.Service - - const descendants = Effect.fnUntraced( - function* (pid: number) { - if (process.platform === "win32") return [] as number[] - const pids: number[] = [] - const queue = [pid] - while (queue.length > 0) { - const current = queue.shift()! - const handle = yield* spawner.spawn( - ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }), - ) - const text = yield* Stream.mkString(Stream.decodeText(handle.stdout)) - yield* handle.exitCode - for (const tok of text.split("\n")) { - const cpid = parseInt(tok, 10) - if (!isNaN(cpid) && !pids.includes(cpid)) { - pids.push(cpid) - queue.push(cpid) - } - } - } - return pids - }, - Effect.scoped, - Effect.catch(() => Effect.succeed([] as number[])), - ) - - function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { - log.info("tools list changed notification received", { server: name }) - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - - const listed = await bridge.promise(defs(name, client, timeout)) - if (!listed) return - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - - s.defs[name] = listed - await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - }) - } - - const state = yield* InstanceState.make( - Effect.fn("MCP.state")(function* () { - const cfg = yield* cfgSvc.get() - const bridge = yield* EffectBridge.make() - const config = cfg.mcp ?? {} - const s: State = { - status: {}, - clients: {}, - defs: {}, - } - - yield* Effect.forEach( - Object.entries(config), - ([key, mcp]) => - Effect.gen(function* () { - if (!isMcpConfigured(mcp)) { - log.error("Ignoring MCP config entry without type", { key }) - return - } - - if (mcp.enabled === false) { - s.status[key] = { status: "disabled" } - return - } - - const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void)) - if (!result) return - - s.status[key] = result.status - if (result.mcpClient) { - s.clients[key] = result.mcpClient - s.defs[key] = result.defs! - watch(s, key, result.mcpClient, bridge, mcp.timeout) - } - }), - { concurrency: "unbounded" }, - ) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* Effect.forEach( - Object.values(s.clients), - (client) => - Effect.gen(function* () { - const pid = (client.transport as any)?.pid - if (typeof pid === "number") { - const pids = yield* descendants(pid) - for (const dpid of pids) { - try { - process.kill(dpid, "SIGTERM") - } catch {} - } - } - yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore) - }), - { concurrency: "unbounded" }, - ) - pendingOAuthTransports.clear() - }), - ) - - return s - }), - ) - - function closeClient(s: State, name: string) { - const client = s.clients[name] - delete s.defs[name] - if (!client) return Effect.void - return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) - } - - const storeClient = Effect.fnUntraced(function* ( - s: State, - name: string, - client: MCPClient, - listed: MCPToolDef[], - timeout?: number, - ) { - const bridge = yield* EffectBridge.make() - yield* closeClient(s, name) - s.status[name] = { status: "connected" } - s.clients[name] = client - s.defs[name] = listed - watch(s, name, client, bridge, timeout) - return s.status[name] - }) - - const status = Effect.fn("MCP.status")(function* () { - const s = yield* InstanceState.get(state) - - const cfg = yield* cfgSvc.get() - const config = cfg.mcp ?? {} - const result: Record = {} - - for (const [key, mcp] of Object.entries(config)) { - if (!isMcpConfigured(mcp)) continue - result[key] = s.status[key] ?? { status: "disabled" } - } - - return result - }) - - const clients = Effect.fn("MCP.clients")(function* () { - const s = yield* InstanceState.get(state) - return s.clients - }) - - const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) { - const s = yield* InstanceState.get(state) - const result = yield* create(name, mcp) - - s.status[name] = result.status - if (!result.mcpClient) { - yield* closeClient(s, name) - delete s.clients[name] - return result.status - } - - return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) - }) - - const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) { - yield* createAndStore(name, mcp) - const s = yield* InstanceState.get(state) - return { status: s.status } - }) - - const connect = Effect.fn("MCP.connect")(function* (name: string) { - const mcp = yield* getMcpConfig(name) - if (!mcp) { - log.error("MCP config not found or invalid", { name }) - return - } - yield* createAndStore(name, { ...mcp, enabled: true }) - }) - - const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) { - const s = yield* InstanceState.get(state) - yield* closeClient(s, name) - delete s.clients[name] - s.status[name] = { status: "disabled" } - }) - - const tools = Effect.fn("MCP.tools")(function* () { - const result: Record = {} - const s = yield* InstanceState.get(state) - - const cfg = yield* cfgSvc.get() - const config = cfg.mcp ?? {} - const defaultTimeout = cfg.experimental?.mcp_timeout - - const connectedClients = Object.entries(s.clients).filter( - ([clientName]) => s.status[clientName]?.status === "connected", - ) - - yield* Effect.forEach( - connectedClients, - ([clientName, client]) => - Effect.gen(function* () { - const mcpConfig = config[clientName] - const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined - - const listed = s.defs[clientName] - if (!listed) { - log.warn("missing cached tools for connected server", { clientName }) - return - } - - const timeout = entry?.timeout ?? defaultTimeout - for (const mcpTool of listed) { - result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout) - } - }), - { concurrency: "unbounded" }, - ) - return result - }) - - function collectFromConnected( - s: State, - listFn: (c: Client) => Promise, - label: string, - ) { - return Effect.forEach( - Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), - ([clientName, client]) => - fetchFromClient(clientName, client, listFn, label).pipe(Effect.map((items) => Object.entries(items ?? {}))), - { concurrency: "unbounded" }, - ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) - } - - const prompts = Effect.fn("MCP.prompts")(function* () { - const s = yield* InstanceState.get(state) - return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts") - }) - - const resources = Effect.fn("MCP.resources")(function* () { - const s = yield* InstanceState.get(state) - return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources") - }) - - const withClient = Effect.fnUntraced(function* ( - clientName: string, - fn: (client: MCPClient) => Promise, - label: string, - meta?: Record, - ) { - const s = yield* InstanceState.get(state) - const client = s.clients[clientName] - if (!client) { - log.warn(`client not found for ${label}`, { clientName }) - return undefined - } - return yield* Effect.tryPromise({ - try: () => fn(client), - catch: (e: any) => { - log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message }) - return e - }, - }).pipe(Effect.orElseSucceed(() => undefined)) - }) - - const getPrompt = Effect.fn("MCP.getPrompt")(function* ( - clientName: string, - name: string, - args?: Record, - ) { - return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", { - promptName: name, - }) - }) - - const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) { - return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", { - resourceUri, - }) - }) - - const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { - const cfg = yield* cfgSvc.get() - const mcpConfig = cfg.mcp?.[mcpName] - if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined - return mcpConfig - }) - - const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) { - const mcpConfig = yield* getMcpConfig(mcpName) - if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) - if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) - if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) - - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined - - // Start the callback server with custom redirectUri if configured - yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) - - const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - yield* auth.updateOAuthState(mcpName, oauthState) - let capturedUrl: URL | undefined - const authProvider = new McpOAuthProvider( - mcpName, - mcpConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async (url) => { - capturedUrl = url - }, - }, - auth, - ) - - const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) - - return yield* Effect.tryPromise({ - try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) - return client - .connect(transport) - .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) - }, - catch: (error) => error, - }).pipe( - Effect.catch((error) => { - if (error instanceof UnauthorizedError && capturedUrl) { - pendingOAuthTransports.set(mcpName, transport) - return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult) - } - return Effect.die(error) - }), - ) - }) - - const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { - const result = yield* startAuth(mcpName) - if (!result.authorizationUrl) { - const client = "client" in result ? result.client : undefined - const mcpConfig = yield* getMcpConfig(mcpName) - if (!mcpConfig) { - yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) - return { status: "failed", error: "MCP config not found after auth" } as Status - } - - const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined - if (!client || !listed) { - yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) - return { status: "failed", error: "Failed to get tools" } as Status - } - - const s = yield* InstanceState.get(state) - yield* auth.clearOAuthState(mcpName) - return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) - } - - log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState }) - - const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) - - yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( - Effect.flatMap((subprocess) => - Effect.callback((resume) => { - const timer = setTimeout(() => resume(Effect.void), 500) - subprocess.on("error", (err) => { - clearTimeout(timer) - resume(Effect.fail(err)) - }) - subprocess.on("exit", (code) => { - if (code !== null && code !== 0) { - clearTimeout(timer) - resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) - } - }) - }), - ), - Effect.catch(() => { - log.warn("failed to open browser, user must open URL manually", { mcpName }) - return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) - }), - ) - - const code = yield* Effect.promise(() => callbackPromise) - - const storedState = yield* auth.getOAuthState(mcpName) - if (storedState !== result.oauthState) { - yield* auth.clearOAuthState(mcpName) - throw new Error("OAuth state mismatch - potential CSRF attack") - } - yield* auth.clearOAuthState(mcpName) - return yield* finishAuth(mcpName, code) - }) - - const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { - const transport = pendingOAuthTransports.get(mcpName) - if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) - - const result = yield* Effect.tryPromise({ - try: () => transport.finishAuth(authorizationCode).then(() => true as const), - catch: (error) => { - log.error("failed to finish oauth", { mcpName, error }) - return error - }, - }).pipe(Effect.option) - - if (Option.isNone(result)) { - return { status: "failed", error: "OAuth completion failed" } as Status - } - - yield* auth.clearCodeVerifier(mcpName) - pendingOAuthTransports.delete(mcpName) - - const mcpConfig = yield* getMcpConfig(mcpName) - if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status - - return yield* createAndStore(mcpName, mcpConfig) - }) - - const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) { - yield* auth.remove(mcpName) - McpOAuthCallback.cancelPending(mcpName) - pendingOAuthTransports.delete(mcpName) - log.info("removed oauth credentials", { mcpName }) - }) - - const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) { - const mcpConfig = yield* getMcpConfig(mcpName) - if (!mcpConfig) return false - return mcpConfig.type === "remote" && mcpConfig.oauth !== false - }) - - const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) { - const entry = yield* auth.get(mcpName) - return !!entry?.tokens - }) - - const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { - const entry = yield* auth.get(mcpName) - if (!entry?.tokens) return "not_authenticated" as AuthStatus - const expired = yield* auth.isTokenExpired(mcpName) - return (expired ? "expired" : "authenticated") as AuthStatus - }) - - return Service.of({ - status, - clients, - tools, - prompts, - resources, - add, - connect, - disconnect, - getPrompt, - readResource, - startAuth, - authenticate, - finishAuth, - removeAuth, - supportsOAuth, - hasStoredTokens, - getAuthStatus, - }) - }), - ) - - export type AuthStatus = "authenticated" | "expired" | "not_authenticated" - - // --- Per-service runtime --- - - export const defaultLayer = layer.pipe( - Layer.provide(McpAuth.layer), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ) -} +export * as MCP from "./mcp" diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts new file mode 100644 index 0000000000..1e3288682e --- /dev/null +++ b/packages/opencode/src/mcp/mcp.ts @@ -0,0 +1,928 @@ +import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { + CallToolResultSchema, + type Tool as MCPToolDef, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js" +import { Config } from "../config" +import { Log } from "../util/log" +import { NamedError } from "@opencode-ai/shared/util/error" +import z from "zod/v4" +import { Instance } from "../project/instance" +import { Installation } from "../installation" +import { withTimeout } from "@/util/timeout" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { McpOAuthProvider } from "./oauth-provider" +import { McpOAuthCallback } from "./oauth-callback" +import { McpAuth } from "./auth" +import { BusEvent } from "../bus/bus-event" +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import open from "open" +import { Effect, Exit, Layer, Option, Context, Stream } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" + +const log = Log.create({ service: "mcp" }) +const DEFAULT_TIMEOUT = 30_000 + +export const Resource = z + .object({ + name: z.string(), + uri: z.string(), + description: z.string().optional(), + mimeType: z.string().optional(), + client: z.string(), + }) + .meta({ ref: "McpResource" }) +export type Resource = z.infer + +export const ToolsChanged = BusEvent.define( + "mcp.tools.changed", + z.object({ + server: z.string(), + }), +) + +export const BrowserOpenFailed = BusEvent.define( + "mcp.browser.open.failed", + z.object({ + mcpName: z.string(), + url: z.string(), + }), +) + +export const Failed = NamedError.create( + "MCPFailed", + z.object({ + name: z.string(), + }), +) + +type MCPClient = Client + +export const Status = z + .discriminatedUnion("status", [ + z + .object({ + status: z.literal("connected"), + }) + .meta({ + ref: "MCPStatusConnected", + }), + z + .object({ + status: z.literal("disabled"), + }) + .meta({ + ref: "MCPStatusDisabled", + }), + z + .object({ + status: z.literal("failed"), + error: z.string(), + }) + .meta({ + ref: "MCPStatusFailed", + }), + z + .object({ + status: z.literal("needs_auth"), + }) + .meta({ + ref: "MCPStatusNeedsAuth", + }), + z + .object({ + status: z.literal("needs_client_registration"), + error: z.string(), + }) + .meta({ + ref: "MCPStatusNeedsClientRegistration", + }), + ]) + .meta({ + ref: "MCPStatus", + }) +export type Status = z.infer + +// Store transports for OAuth servers to allow finishing auth +type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport +const pendingOAuthTransports = new Map() + +// Prompt cache types +type PromptInfo = Awaited>["prompts"][number] +type ResourceInfo = Awaited>["resources"][number] +type McpEntry = NonNullable[string] + +function isMcpConfigured(entry: McpEntry): entry is Config.Mcp { + return typeof entry === "object" && entry !== null && "type" in entry +} + +const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") + +// Convert MCP tool definition to AI SDK Tool type +function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { + const inputSchema = mcpTool.inputSchema + + // Spread first, then override type to ensure it's always "object" + const schema: JSONSchema7 = { + ...(inputSchema as JSONSchema7), + type: "object", + properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"], + additionalProperties: false, + } + + return dynamicTool({ + description: mcpTool.description ?? "", + inputSchema: jsonSchema(schema), + execute: async (args: unknown) => { + return client.callTool( + { + name: mcpTool.name, + arguments: (args || {}) as Record, + }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + timeout, + }, + ) + }, + }) +} + +function defs(key: string, client: MCPClient, timeout?: number) { + return Effect.tryPromise({ + try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((err) => { + log.error("failed to get tools from client", { key, error: err }) + return Effect.succeed(undefined) + }), + ) +} + +function fetchFromClient( + clientName: string, + client: Client, + listFn: (c: Client) => Promise, + label: string, +) { + return Effect.tryPromise({ + try: () => listFn(client), + catch: (e: any) => { + log.error(`failed to get ${label}`, { clientName, error: e.message }) + return e + }, + }).pipe( + Effect.map((items) => { + const out: Record = {} + const sanitizedClient = sanitize(clientName) + for (const item of items) { + out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName } + } + return out + }), + Effect.orElseSucceed(() => undefined), + ) +} + +interface CreateResult { + mcpClient?: MCPClient + status: Status + defs?: MCPToolDef[] +} + +interface AuthResult { + authorizationUrl: string + oauthState: string + client?: MCPClient +} + +// --- Effect Service --- + +interface State { + status: Record + clients: Record + defs: Record +} + +export interface Interface { + readonly status: () => Effect.Effect> + readonly clients: () => Effect.Effect> + readonly tools: () => Effect.Effect> + readonly prompts: () => Effect.Effect> + readonly resources: () => Effect.Effect> + readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record | Status }> + readonly connect: (name: string) => Effect.Effect + readonly disconnect: (name: string) => Effect.Effect + readonly getPrompt: ( + clientName: string, + name: string, + args?: Record, + ) => Effect.Effect> | undefined> + readonly readResource: ( + clientName: string, + resourceUri: string, + ) => Effect.Effect> | undefined> + readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }> + readonly authenticate: (mcpName: string) => Effect.Effect + readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect + readonly removeAuth: (mcpName: string) => Effect.Effect + readonly supportsOAuth: (mcpName: string) => Effect.Effect + readonly hasStoredTokens: (mcpName: string) => Effect.Effect + readonly getAuthStatus: (mcpName: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MCP") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const auth = yield* McpAuth.Service + const bus = yield* Bus.Service + + type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport + + /** + * Connect a client via the given transport with resource safety: + * on failure the transport is closed; on success the caller owns it. + */ + const connectTransport = (transport: Transport, timeout: number) => + Effect.acquireUseRelease( + Effect.succeed(transport), + (t) => + Effect.tryPromise({ + try: () => { + const client = new Client({ name: "opencode", version: Installation.VERSION }) + return withTimeout(client.connect(t), timeout).then(() => client) + }, + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }), + (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), + ) + + const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } + + const connectRemote = Effect.fn("MCP.connectRemote")(function* ( + key: string, + mcp: Config.Mcp & { type: "remote" }, + ) { + const oauthDisabled = mcp.oauth === false + const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined + let authProvider: McpOAuthProvider | undefined + + if (!oauthDisabled) { + authProvider = new McpOAuthProvider( + key, + mcp.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async (url) => { + log.info("oauth redirect requested", { key, url: url.toString() }) + }, + }, + auth, + ) + } + + const transports: Array<{ name: string; transport: TransportWithAuth }> = [ + { + name: "StreamableHTTP", + transport: new StreamableHTTPClientTransport(new URL(mcp.url), { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + }, + { + name: "SSE", + transport: new SSEClientTransport(new URL(mcp.url), { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + }, + ] + + const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + let lastStatus: Status | undefined + + for (const { name, transport } of transports) { + const result = yield* connectTransport(transport, connectTimeout).pipe( + Effect.map((client) => ({ client, transportName: name })), + Effect.catch((error) => { + const lastError = error instanceof Error ? error : new Error(String(error)) + const isAuthError = + error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) + + if (isAuthError) { + log.info("mcp server requires authentication", { key, transport: name }) + + if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { + lastStatus = { + status: "needs_client_registration" as const, + error: "Server does not support dynamic client registration. Please provide clientId in config.", + } + return bus + .publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, + variant: "warning", + duration: 8000, + }) + .pipe(Effect.ignore, Effect.as(undefined)) + } else { + pendingOAuthTransports.set(key, transport) + lastStatus = { status: "needs_auth" as const } + return bus + .publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + variant: "warning", + duration: 8000, + }) + .pipe(Effect.ignore, Effect.as(undefined)) + } + } + + log.debug("transport connection failed", { + key, + transport: name, + url: mcp.url, + error: lastError.message, + }) + lastStatus = { status: "failed" as const, error: lastError.message } + return Effect.succeed(undefined) + }), + ) + if (result) { + log.info("connected", { key, transport: result.transportName }) + return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status } + } + // If this was an auth error, stop trying other transports + if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break + } + + return { + client: undefined as MCPClient | undefined, + status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status, + } + }) + + const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) { + const [cmd, ...args] = mcp.command + const cwd = Instance.directory + const transport = new StdioClientTransport({ + stderr: "pipe", + command: cmd, + args, + cwd, + env: { + ...process.env, + ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), + ...mcp.environment, + }, + }) + transport.stderr?.on("data", (chunk: Buffer) => { + log.info(`mcp stderr: ${chunk.toString()}`, { key }) + }) + + const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + return yield* connectTransport(transport, connectTimeout).pipe( + Effect.map((client): { client: MCPClient | undefined; status: Status } => ({ + client, + status: { status: "connected" }, + })), + Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => { + const msg = error instanceof Error ? error.message : String(error) + log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg }) + return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } }) + }), + ) + }) + + const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) { + if (mcp.enabled === false) { + log.info("mcp server disabled", { key }) + return DISABLED_RESULT + } + + log.info("found", { key, type: mcp.type }) + + const { client: mcpClient, status } = + mcp.type === "remote" + ? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" }) + : yield* connectLocal(key, mcp as Config.Mcp & { type: "local" }) + + if (!mcpClient) { + return { status } satisfies CreateResult + } + + const listed = yield* defs(key, mcpClient, mcp.timeout) + if (!listed) { + yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore) + return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult + } + + log.info("create() successfully created client", { key, toolCount: listed.length }) + return { mcpClient, status, defs: listed } satisfies CreateResult + }) + const cfgSvc = yield* Config.Service + + const descendants = Effect.fnUntraced( + function* (pid: number) { + if (process.platform === "win32") return [] as number[] + const pids: number[] = [] + const queue = [pid] + while (queue.length > 0) { + const current = queue.shift()! + const handle = yield* spawner.spawn( + ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }), + ) + const text = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + yield* handle.exitCode + for (const tok of text.split("\n")) { + const cpid = parseInt(tok, 10) + if (!isNaN(cpid) && !pids.includes(cpid)) { + pids.push(cpid) + queue.push(cpid) + } + } + } + return pids + }, + Effect.scoped, + Effect.catch(() => Effect.succeed([] as number[])), + ) + + function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + log.info("tools list changed notification received", { server: name }) + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + + const listed = await bridge.promise(defs(name, client, timeout)) + if (!listed) return + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + + s.defs[name] = listed + await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + }) + } + + const state = yield* InstanceState.make( + Effect.fn("MCP.state")(function* () { + const cfg = yield* cfgSvc.get() + const bridge = yield* EffectBridge.make() + const config = cfg.mcp ?? {} + const s: State = { + status: {}, + clients: {}, + defs: {}, + } + + yield* Effect.forEach( + Object.entries(config), + ([key, mcp]) => + Effect.gen(function* () { + if (!isMcpConfigured(mcp)) { + log.error("Ignoring MCP config entry without type", { key }) + return + } + + if (mcp.enabled === false) { + s.status[key] = { status: "disabled" } + return + } + + const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void)) + if (!result) return + + s.status[key] = result.status + if (result.mcpClient) { + s.clients[key] = result.mcpClient + s.defs[key] = result.defs! + watch(s, key, result.mcpClient, bridge, mcp.timeout) + } + }), + { concurrency: "unbounded" }, + ) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Effect.forEach( + Object.values(s.clients), + (client) => + Effect.gen(function* () { + const pid = (client.transport as any)?.pid + if (typeof pid === "number") { + const pids = yield* descendants(pid) + for (const dpid of pids) { + try { + process.kill(dpid, "SIGTERM") + } catch {} + } + } + yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + }), + { concurrency: "unbounded" }, + ) + pendingOAuthTransports.clear() + }), + ) + + return s + }), + ) + + function closeClient(s: State, name: string) { + const client = s.clients[name] + delete s.defs[name] + if (!client) return Effect.void + return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + } + + const storeClient = Effect.fnUntraced(function* ( + s: State, + name: string, + client: MCPClient, + listed: MCPToolDef[], + timeout?: number, + ) { + const bridge = yield* EffectBridge.make() + yield* closeClient(s, name) + s.status[name] = { status: "connected" } + s.clients[name] = client + s.defs[name] = listed + watch(s, name, client, bridge, timeout) + return s.status[name] + }) + + const status = Effect.fn("MCP.status")(function* () { + const s = yield* InstanceState.get(state) + + const cfg = yield* cfgSvc.get() + const config = cfg.mcp ?? {} + const result: Record = {} + + for (const [key, mcp] of Object.entries(config)) { + if (!isMcpConfigured(mcp)) continue + result[key] = s.status[key] ?? { status: "disabled" } + } + + return result + }) + + const clients = Effect.fn("MCP.clients")(function* () { + const s = yield* InstanceState.get(state) + return s.clients + }) + + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) { + const s = yield* InstanceState.get(state) + const result = yield* create(name, mcp) + + s.status[name] = result.status + if (!result.mcpClient) { + yield* closeClient(s, name) + delete s.clients[name] + return result.status + } + + return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) + }) + + const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) { + yield* createAndStore(name, mcp) + const s = yield* InstanceState.get(state) + return { status: s.status } + }) + + const connect = Effect.fn("MCP.connect")(function* (name: string) { + const mcp = yield* getMcpConfig(name) + if (!mcp) { + log.error("MCP config not found or invalid", { name }) + return + } + yield* createAndStore(name, { ...mcp, enabled: true }) + }) + + const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) { + const s = yield* InstanceState.get(state) + yield* closeClient(s, name) + delete s.clients[name] + s.status[name] = { status: "disabled" } + }) + + const tools = Effect.fn("MCP.tools")(function* () { + const result: Record = {} + const s = yield* InstanceState.get(state) + + const cfg = yield* cfgSvc.get() + const config = cfg.mcp ?? {} + const defaultTimeout = cfg.experimental?.mcp_timeout + + const connectedClients = Object.entries(s.clients).filter( + ([clientName]) => s.status[clientName]?.status === "connected", + ) + + yield* Effect.forEach( + connectedClients, + ([clientName, client]) => + Effect.gen(function* () { + const mcpConfig = config[clientName] + const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined + + const listed = s.defs[clientName] + if (!listed) { + log.warn("missing cached tools for connected server", { clientName }) + return + } + + const timeout = entry?.timeout ?? defaultTimeout + for (const mcpTool of listed) { + result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout) + } + }), + { concurrency: "unbounded" }, + ) + return result + }) + + function collectFromConnected( + s: State, + listFn: (c: Client) => Promise, + label: string, + ) { + return Effect.forEach( + Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), + ([clientName, client]) => + fetchFromClient(clientName, client, listFn, label).pipe(Effect.map((items) => Object.entries(items ?? {}))), + { concurrency: "unbounded" }, + ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) + } + + const prompts = Effect.fn("MCP.prompts")(function* () { + const s = yield* InstanceState.get(state) + return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts") + }) + + const resources = Effect.fn("MCP.resources")(function* () { + const s = yield* InstanceState.get(state) + return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources") + }) + + const withClient = Effect.fnUntraced(function* ( + clientName: string, + fn: (client: MCPClient) => Promise, + label: string, + meta?: Record, + ) { + const s = yield* InstanceState.get(state) + const client = s.clients[clientName] + if (!client) { + log.warn(`client not found for ${label}`, { clientName }) + return undefined + } + return yield* Effect.tryPromise({ + try: () => fn(client), + catch: (e: any) => { + log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message }) + return e + }, + }).pipe(Effect.orElseSucceed(() => undefined)) + }) + + const getPrompt = Effect.fn("MCP.getPrompt")(function* ( + clientName: string, + name: string, + args?: Record, + ) { + return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", { + promptName: name, + }) + }) + + const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) { + return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", { + resourceUri, + }) + }) + + const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { + const cfg = yield* cfgSvc.get() + const mcpConfig = cfg.mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined + return mcpConfig + }) + + const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) { + const mcpConfig = yield* getMcpConfig(mcpName) + if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) + if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) + if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) + + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) + + const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + yield* auth.updateOAuthState(mcpName, oauthState) + let capturedUrl: URL | undefined + const authProvider = new McpOAuthProvider( + mcpName, + mcpConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async (url) => { + capturedUrl = url + }, + }, + auth, + ) + + const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) + + return yield* Effect.tryPromise({ + try: () => { + const client = new Client({ name: "opencode", version: Installation.VERSION }) + return client + .connect(transport) + .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) + }, + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (error instanceof UnauthorizedError && capturedUrl) { + pendingOAuthTransports.set(mcpName, transport) + return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult) + } + return Effect.die(error) + }), + ) + }) + + const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { + const result = yield* startAuth(mcpName) + if (!result.authorizationUrl) { + const client = "client" in result ? result.client : undefined + const mcpConfig = yield* getMcpConfig(mcpName) + if (!mcpConfig) { + yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) + return { status: "failed", error: "MCP config not found after auth" } as Status + } + + const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined + if (!client || !listed) { + yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) + return { status: "failed", error: "Failed to get tools" } as Status + } + + const s = yield* InstanceState.get(state) + yield* auth.clearOAuthState(mcpName) + return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) + } + + log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState }) + + const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) + + yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( + Effect.flatMap((subprocess) => + Effect.callback((resume) => { + const timer = setTimeout(() => resume(Effect.void), 500) + subprocess.on("error", (err) => { + clearTimeout(timer) + resume(Effect.fail(err)) + }) + subprocess.on("exit", (code) => { + if (code !== null && code !== 0) { + clearTimeout(timer) + resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) + } + }) + }), + ), + Effect.catch(() => { + log.warn("failed to open browser, user must open URL manually", { mcpName }) + return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + }), + ) + + const code = yield* Effect.promise(() => callbackPromise) + + const storedState = yield* auth.getOAuthState(mcpName) + if (storedState !== result.oauthState) { + yield* auth.clearOAuthState(mcpName) + throw new Error("OAuth state mismatch - potential CSRF attack") + } + yield* auth.clearOAuthState(mcpName) + return yield* finishAuth(mcpName, code) + }) + + const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { + const transport = pendingOAuthTransports.get(mcpName) + if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) + + const result = yield* Effect.tryPromise({ + try: () => transport.finishAuth(authorizationCode).then(() => true as const), + catch: (error) => { + log.error("failed to finish oauth", { mcpName, error }) + return error + }, + }).pipe(Effect.option) + + if (Option.isNone(result)) { + return { status: "failed", error: "OAuth completion failed" } as Status + } + + yield* auth.clearCodeVerifier(mcpName) + pendingOAuthTransports.delete(mcpName) + + const mcpConfig = yield* getMcpConfig(mcpName) + if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status + + return yield* createAndStore(mcpName, mcpConfig) + }) + + const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) { + yield* auth.remove(mcpName) + McpOAuthCallback.cancelPending(mcpName) + pendingOAuthTransports.delete(mcpName) + log.info("removed oauth credentials", { mcpName }) + }) + + const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) { + const mcpConfig = yield* getMcpConfig(mcpName) + if (!mcpConfig) return false + return mcpConfig.type === "remote" && mcpConfig.oauth !== false + }) + + const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) { + const entry = yield* auth.get(mcpName) + return !!entry?.tokens + }) + + const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { + const entry = yield* auth.get(mcpName) + if (!entry?.tokens) return "not_authenticated" as AuthStatus + const expired = yield* auth.isTokenExpired(mcpName) + return (expired ? "expired" : "authenticated") as AuthStatus + }) + + return Service.of({ + status, + clients, + tools, + prompts, + resources, + add, + connect, + disconnect, + getPrompt, + readResource, + startAuth, + authenticate, + finishAuth, + removeAuth, + supportsOAuth, + hasStoredTokens, + getAuthStatus, + }) + }), +) + +export type AuthStatus = "authenticated" | "expired" | "not_authenticated" + +// --- Per-service runtime --- + +export const defaultLayer = layer.pipe( + Layer.provide(McpAuth.layer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), +) From d6b14e24678db678163c281257322c5a9bf0e6fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:56:23 -0400 Subject: [PATCH 11/75] fix: prefix 32 unused parameters with underscore (#22694) --- packages/console/app/src/component/icon.tsx | 4 ++-- .../console/app/src/routes/auth/status.ts | 2 +- .../console/app/src/routes/debug/index.ts | 2 +- .../zen/util/provider/openai-compatible.ts | 2 +- .../console/app/src/routes/zen/v1/models.ts | 2 +- .../app/src/routes/zen/v1/models/[model].ts | 4 ++-- packages/function/src/api.ts | 4 ++-- packages/opencode/src/agent/agent.ts | 2 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 2 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/provider/schema.ts | 2 +- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/src/v2/session.ts | 4 ++-- .../test/session/prompt-effect.test.ts | 20 +++++++++---------- packages/opencode/test/session/retry.test.ts | 2 +- packages/plugin/src/example.ts | 2 +- 17 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index 4627c00845..da2e87ef4c 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -1,6 +1,6 @@ import { JSX } from "solid-js" -export function IconZen(props: JSX.SvgSVGAttributes) { +export function IconZen(_props: JSX.SvgSVGAttributes) { return ( @@ -13,7 +13,7 @@ export function IconZen(props: JSX.SvgSVGAttributes) { ) } -export function IconGo(props: JSX.SvgSVGAttributes) { +export function IconGo(_props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/routes/auth/status.ts b/packages/console/app/src/routes/auth/status.ts index 215cae698f..ed522d7404 100644 --- a/packages/console/app/src/routes/auth/status.ts +++ b/packages/console/app/src/routes/auth/status.ts @@ -1,7 +1,7 @@ import { APIEvent } from "@solidjs/start" import { useAuthSession } from "~/context/auth" -export async function GET(input: APIEvent) { +export async function GET(_input: APIEvent) { const session = await useAuthSession() return Response.json(session.data) } diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts index 2bdd269e78..4bfb633944 100644 --- a/packages/console/app/src/routes/debug/index.ts +++ b/packages/console/app/src/routes/debug/index.ts @@ -3,7 +3,7 @@ import { json } from "@solidjs/router" import { Database } from "@opencode-ai/console-core/drizzle/index.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" -export async function GET(evt: APIEvent) { +export async function GET(_evt: APIEvent) { return json({ data: await Database.use(async (tx) => { const result = await tx.$count(UserTable) diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index e05f0d6c0b..97b0abc64f 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -30,7 +30,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif headers.set("authorization", `Bearer ${apiKey}`) headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "") }, - modifyBody: (body: Record, workspaceID?: string) => { + modifyBody: (body: Record, _workspaceID?: string) => { return { ...body, ...(body.stream ? { stream_options: { include_usage: true } } : {}), diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index d2592d20b0..6b4a878fc7 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -5,7 +5,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ZenData } from "@opencode-ai/console-core/model.js" -export async function OPTIONS(input: APIEvent) { +export async function OPTIONS(_input: APIEvent) { return new Response(null, { status: 200, headers: { diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index a4edd5861a..bc1168eb0c 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -6,8 +6,8 @@ export function POST(input: APIEvent) { format: "google", modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, - parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", - parseIsStream: (url: string, body: any) => + parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", + parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, }) diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 54b93ad715..d6565b2870 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -49,9 +49,9 @@ export class SyncServer extends DurableObject { }) } - async webSocketMessage(ws, message) {} + async webSocketMessage(_ws, _message) {} - async webSocketClose(ws, code, reason, wasClean) { + async webSocketClose(ws, code, _reason, _wasClean) { ws.close(code, "Durable Object is closing WebSocket") } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8e6bfe5e9b..8d11a93b39 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -80,7 +80,7 @@ export namespace Agent { const provider = yield* Provider.Service const state = yield* InstanceState.make( - Effect.fn("Agent.state")(function* (ctx) { + Effect.fn("Agent.state")(function* (_ctx) { const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df..173c5ff60c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -78,7 +78,7 @@ export function DialogMcp() { title="MCPs" options={options()} keybind={keybinds()} - onSelect={(option) => { + onSelect={(_option) => { // Don't close on select, only on escape }} /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index ef75764a29..48adddaedc 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -54,7 +54,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingLeft={1} paddingRight={1} backgroundColor={key === store.active ? theme.primary : undefined} - onMouseUp={(evt) => { + onMouseUp={(_evt) => { if (key === "confirm") props.onConfirm?.() if (key === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f35e8c83df..63e41f4455 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -510,7 +510,7 @@ export const Agent = z permission: Permission.optional(), }) .catchall(z.any()) - .transform((agent, ctx) => { + .transform((agent, _ctx) => { const knownKeys = new Set([ "name", "model", diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 4490ca2898..702616018a 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -30,7 +30,7 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) export type ModelID = typeof modelIdSchema.Type export const ModelID = modelIdSchema.pipe( - withStatics((schema: typeof modelIdSchema) => ({ + withStatics((_schema: typeof modelIdSchema) => ({ zod: z.string().pipe(z.custom()), })), ) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3138f8e293..7b83c245f4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -49,7 +49,7 @@ export namespace ProviderTransform { function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, - options: Record, + _options: Record, ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 97df0a2207..ce1b39031f 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -40,11 +40,11 @@ export namespace SessionV2 { Effect.gen(function* () { const session = yield* Session.Service - const create: Interface["create"] = Effect.fn("Session.create")(function* (input) { + const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { throw new Error("Not implemented") }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) { + const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { throw new Error("Not implemented") }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 91297aed1d..7a118cb050 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -786,7 +786,7 @@ it.live( const { task } = yield* registry.named() const original = task.execute task.execute = (_args, ctx) => - Effect.callback((resume) => { + Effect.callback((_resume) => { ready.resolve() ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) return Effect.sync(() => aborted.resolve()) @@ -856,7 +856,7 @@ it.live( it.live("concurrent loop callers get same result", () => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) @@ -997,7 +997,7 @@ it.live( it.live("assertNotBusy succeeds when idle", () => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const run = yield* SessionRunState.Service const sessions = yield* Session.Service @@ -1042,7 +1042,7 @@ it.live( unix("shell captures stdout and stderr in completed tool output", () => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ @@ -1117,7 +1117,7 @@ unix("shell lists files from the project directory", () => unix("shell captures stderr from a failing command", () => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ @@ -1143,7 +1143,7 @@ unix( () => withSh(() => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, chat } = yield* boot() @@ -1255,7 +1255,7 @@ unix( () => withSh(() => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() @@ -1292,7 +1292,7 @@ unix( () => withSh(() => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, chat } = yield* boot() @@ -1374,7 +1374,7 @@ unix( "cancel interrupts loop queued behind shell", () => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, chat } = yield* boot() @@ -1403,7 +1403,7 @@ unix( () => withSh(() => provideTmpdirInstance( - (dir) => + (_dir) => Effect.gen(function* () { const { prompt, chat } = yield* boot() diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 2d01a8f354..ade2647869 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -239,7 +239,7 @@ describe("session.message-v2.fromError", () => { using server = Bun.serve({ port: 0, idleTimeout: 8, - async fetch(req) { + async fetch(_req) { return new Response( new ReadableStream({ async pull(controller) { diff --git a/packages/plugin/src/example.ts b/packages/plugin/src/example.ts index 1cf042fe96..9d7e178a96 100644 --- a/packages/plugin/src/example.ts +++ b/packages/plugin/src/example.ts @@ -1,7 +1,7 @@ import { Plugin } from "./index.js" import { tool } from "./tool.js" -export const ExamplePlugin: Plugin = async (ctx) => { +export const ExamplePlugin: Plugin = async (_ctx) => { return { tool: { mytool: tool({ From 70aeebf2dfe59009ba7254facaa81b8baf73c3cf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 01:57:23 +0000 Subject: [PATCH 12/75] chore: generate --- packages/opencode/src/mcp/mcp.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts index 1e3288682e..947f29c05b 100644 --- a/packages/opencode/src/mcp/mcp.ts +++ b/packages/opencode/src/mcp/mcp.ts @@ -275,10 +275,7 @@ export const layer = Layer.effect( const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } - const connectRemote = Effect.fn("MCP.connectRemote")(function* ( - key: string, - mcp: Config.Mcp & { type: "remote" }, - ) { + const connectRemote = Effect.fn("MCP.connectRemote")(function* (key: string, mcp: Config.Mcp & { type: "remote" }) { const oauthDisabled = mcp.oauth === false const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined let authProvider: McpOAuthProvider | undefined @@ -451,9 +448,7 @@ export const layer = Layer.effect( const queue = [pid] while (queue.length > 0) { const current = queue.shift()! - const handle = yield* spawner.spawn( - ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }), - ) + const handle = yield* spawner.spawn(ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" })) const text = yield* Stream.mkString(Stream.decodeText(handle.stdout)) yield* handle.exitCode for (const tok of text.split("\n")) { From 34213d444681a8953c5693bd01dd754c4e79a30b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:01:02 -0400 Subject: [PATCH 13/75] fix: delete 9 dead functions with zero callers (#22697) --- .../app/src/context/global-sync/bootstrap.ts | 16 ------------- packages/function/src/api.ts | 14 ----------- packages/opencode/script/postinstall.mjs | 12 ---------- .../src/server/instance/httpapi/server.ts | 4 ---- packages/opencode/test/lib/llm-server.ts | 24 ------------------- .../opencode/test/session/compaction.test.ts | 19 --------------- packages/ui/src/components/session-diff.ts | 14 ----------- .../timeline-playground.stories.tsx | 4 ---- 8 files changed, 107 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ad987efa6e..2f9147498e 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -65,22 +65,6 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record) => string - formatMoreCount: (count: number) => string -}) { - if (input.errors.length === 0) return - const message = formatServerError(input.errors[0], input.translate) - const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" - showToast({ - variant: "error", - title: input.title, - description: message + more, - }) -} - export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index d6565b2870..4d8b295ec7 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -12,20 +12,6 @@ type Env = { WEB_DOMAIN: string } -async function getFeishuTenantToken(): Promise { - const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app_id: Resource.FEISHU_APP_ID.value, - app_secret: Resource.FEISHU_APP_SECRET.value, - }), - }) - const data = (await response.json()) as { tenant_access_token?: string } - if (!data.tenant_access_token) throw new Error("Failed to get Feishu tenant token") - return data.tenant_access_token -} - export class SyncServer extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 98f23e16fb..2b990251ce 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -85,18 +85,6 @@ function prepareBinDirectory(binaryName) { return { binDir, targetPath } } -function symlinkBinary(sourcePath, binaryName) { - const { targetPath } = prepareBinDirectory(binaryName) - - fs.symlinkSync(sourcePath, targetPath) - console.log(`opencode binary symlinked: ${targetPath} -> ${sourcePath}`) - - // Verify the file exists after operation - if (!fs.existsSync(targetPath)) { - throw new Error(`Failed to symlink binary to ${targetPath}`) - } -} - async function main() { try { if (os.platform() === "win32") { diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 363e93a240..54c3c57ff5 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -26,10 +26,6 @@ const Headers = Schema.Struct({ }) export namespace ExperimentalHttpApiServer { - function text(input: string, status: number, headers?: Record) { - return HttpServerResponse.text(input, { status, headers }) - } - function decode(input: string) { try { return decodeURIComponent(input) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 2e2a2ea895..1f873a9fbb 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -596,35 +596,11 @@ function hit(url: string, body: unknown) { } satisfies Hit } -/** Auto-acknowledging tool-result follow-ups avoids requiring tests to queue two responses per tool call. */ -function isToolResultFollowUp(body: unknown): boolean { - if (!body || typeof body !== "object") return false - // OpenAI chat format: last message has role "tool" - if ("messages" in body && Array.isArray(body.messages)) { - const last = body.messages[body.messages.length - 1] - return last?.role === "tool" - } - // Responses API: input contains function_call_output - if ("input" in body && Array.isArray(body.input)) { - return body.input.some((item: Record) => item?.type === "function_call_output") - } - return false -} - function isTitleRequest(body: unknown): boolean { if (!body || typeof body !== "object") return false return JSON.stringify(body).includes("Generate a title for this conversation") } -function requestSummary(body: unknown): string { - if (!body || typeof body !== "object") return "empty body" - if ("messages" in body && Array.isArray(body.messages)) { - const roles = body.messages.map((m: Record) => m.role).join(",") - return `messages=[${roles}]` - } - return `keys=[${Object.keys(body).join(",")}]` -} - namespace TestLLMServer { export interface Service { readonly url: string diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index d658f48bd8..7711d31931 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -143,25 +143,6 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string return msg } -async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) { - return svc.updatePart({ - id: PartID.ascending(), - messageID, - sessionID, - type: "tool", - callID: crypto.randomUUID(), - tool, - state: { - status: "completed", - input: {}, - output, - title: "done", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) -} - function fake( input: Parameters[0], result: "continue" | "compact", diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index d791c7fc10..a5fbdbc5c0 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -25,20 +25,6 @@ export type ViewDiff = { const cache = new Map() -function empty(file: string, key: string) { - return { - name: file, - type: "change", - hunks: [], - splitLineCount: 0, - unifiedLineCount: 0, - isPartial: true, - deletionLines: [], - additionLines: [], - cacheKey: key, - } satisfies FileDiffMetadata -} - function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { const [patch] = parsePatch(diff.patch) diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 282592ff63..fa3e7ff798 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -555,10 +555,6 @@ function toolPart(sample: (typeof TOOL_SAMPLES)[keyof typeof TOOL_SAMPLES], stat } as ToolPart } -function compactionPart(): CompactionPart { - return { id: uid(), type: "compaction", auto: true } as CompactionPart -} - // --------------------------------------------------------------------------- // CSS Controls definition // --------------------------------------------------------------------------- From cce05c16658a39d091f658bdb53dcce1e88c66d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:01:53 -0400 Subject: [PATCH 14/75] fix: clean up 49 unused variables, catch params, and stale imports (#22695) --- infra/enterprise.ts | 2 +- packages/app/src/addons/serialize.test.ts | 4 +-- packages/app/src/app.tsx | 7 +---- packages/app/src/pages/session.tsx | 3 -- packages/app/src/pages/session/file-tabs.tsx | 6 ---- .../console/app/script/generate-sitemap.ts | 1 - packages/console/app/src/routes/index.tsx | 2 -- packages/console/app/src/routes/user-menu.tsx | 2 +- packages/function/src/api.ts | 2 +- packages/opencode/src/bus/bus.ts | 1 - .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +-- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- .../tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/cli/network.ts | 2 -- packages/opencode/src/command/index.ts | 3 -- packages/opencode/src/config/config.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 4 +-- packages/opencode/src/mcp/oauth-callback.ts | 2 +- packages/opencode/src/server/fence.ts | 2 +- .../src/server/instance/middleware.ts | 2 -- .../opencode/src/server/instance/provider.ts | 3 -- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/test/file/index.test.ts | 2 +- .../test/fixture/lsp/fake-lsp-server.js | 2 -- .../opencode/test/provider/transform.test.ts | 2 -- .../opencode/test/server/session-list.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 1 - .../test/session/messages-pagination.test.ts | 8 +++--- packages/sdk/js/src/v2/data.ts | 2 +- .../shared/test/filesystem/filesystem.test.ts | 4 +-- .../test/fixture/effect-flock-worker.ts | 1 - packages/slack/src/index.ts | 2 +- script/publish.ts | 28 ------------------- script/stats.ts | 2 +- sdks/vscode/src/extension.ts | 6 ++-- 37 files changed, 32 insertions(+), 94 deletions(-) diff --git a/infra/enterprise.ts b/infra/enterprise.ts index 38f0c3c8fd..dc336a6843 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -3,7 +3,7 @@ import { shortDomain } from "./stage" const storage = new sst.cloudflare.Bucket("EnterpriseStorage") -const teams = new sst.cloudflare.x.SolidStart("Teams", { +new sst.cloudflare.x.SolidStart("Teams", { domain: shortDomain, path: "packages/enterprise", buildCommand: "bun run build:cloudflare", diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7f6780557d..6828e60f84 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -180,8 +180,8 @@ describe("SerializeAddon", () => { await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) - const origFg = origLine!.getCell(0)!.getFgColor() - const origBg = origLine!.getCell(0)!.getBgColor() + const _origFg = origLine!.getCell(0)!.getFgColor() + const _origBg = origLine!.getCell(0)!.getBgColor() expect(origLine!.getCell(0)!.isBold()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 35fd36cca3..9983548ba0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { type Duration, Effect } from "effect" +import { Effect } from "effect" import { type Component, createMemo, @@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -const effectMinDuration = - (duration: Duration.Input) => - (e: Effect.Effect) => - Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) - function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e328e3f0cc..32df997f7f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -433,7 +433,6 @@ export default function Page() { const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -443,8 +442,6 @@ export default function Page() { review: reviewTab, hasReview: canReview, }) - const contextOpen = tabState.contextOpen - const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index a64dff64e2..37bffcd2fa 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) { requestAnimationFrame(() => comments.clearFocus()) }) - const cancelCommenting = () => { - const p = path() - if (p) file.setSelectedLines(p, null) - setNote("commenting", null) - } - let prev = { loaded: false, ready: false, diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts index 89bca6bac5..9fd3ba0f0f 100755 --- a/packages/console/app/script/generate-sitemap.ts +++ b/packages/console/app/script/generate-sitemap.ts @@ -8,7 +8,6 @@ import { LOCALES, route } from "../src/lib/language.js" const __dirname = dirname(fileURLToPath(import.meta.url)) const BASE_URL = config.baseUrl const PUBLIC_DIR = join(__dirname, "../public") -const ROUTES_DIR = join(__dirname, "../src/routes") const DOCS_DIR = join(__dirname, "../../../web/src/content/docs") interface SitemapEntry { diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index e47134d2b9..b5b12a84bd 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -31,8 +31,6 @@ export default function Home() { const i18n = useI18n() const language = useLanguage() const githubData = createAsync(() => github()) - const release = createMemo(() => githubData()?.release) - const handleCopyClick = (event: Event) => { const button = event.currentTarget as HTMLButtonElement const text = button.textContent diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx index fa1c1f60bb..7b305d8ead 100644 --- a/packages/console/app/src/routes/user-menu.tsx +++ b/packages/console/app/src/routes/user-menu.tsx @@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import "./user-menu.css" -const logout = action(async () => { +const _logout = action(async () => { "use server" const auth = await useAuthSession() const event = getRequestEvent() diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 4d8b295ec7..68b2d450bb 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -181,7 +181,7 @@ export default new Hono<{ Bindings: Env }>() let info const messages: Record = {} data.forEach((d) => { - const [root, type, ...splits] = d.key.split("/") + const [root, type] = d.key.split("/") if (root !== "session") return if (type === "info") { info = d.content diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts index c5e31e6c20..fe9169171c 100644 --- a/packages/opencode/src/bus/bus.ts +++ b/packages/opencode/src/bus/bus.ts @@ -4,7 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import { Log } from "../util/log" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" -import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 2118fe98e1..7ca73310bc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -111,7 +111,7 @@ export function Autocomplete(props: { const position = createMemo(() => { if (!store.visible) return { x: 0, y: 0, width: 0 } - const dims = dimensions() + dimensions() positionTick() const anchor = props.anchor() const parent = anchor.parent diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f9fd5a9b9c..9f0dfa6038 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -157,10 +157,10 @@ export function Session() { const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) - const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) + const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") - const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const wide = createMemo(() => dimensions().width > 120) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index e0b5002b61..ad824fe48f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -599,7 +599,7 @@ function Prompt>(props: { }) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) - const renderer = useRenderer() + useRenderer() const content = () => ( (null) - const dimensions = useTerminalDimensions() + useTerminalDimensions() return ( diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 6321c056d0..ea281aafb9 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -43,8 +43,6 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") - const corsExplicitlySet = process.argv.includes("--cors") - const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 28fb37f272..539ae0dac6 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -8,13 +8,10 @@ import z from "zod" import { Config } from "../config" import { MCP } from "../mcp" import { Skill } from "../skill" -import { Log } from "../util/log" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" export namespace Command { - const log = Log.create({ service: "command" }) - type State = { commands: Record } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 63e41f4455..58d9343ad9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1095,7 +1095,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string } function writable(info: Info) { - const { plugin_origins, ...next } = info + const { plugin_origins: _plugin_origins, ...next } = info return next } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 67583107fc..4fef4f9321 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -328,7 +328,7 @@ export namespace Workspace { try { const adaptor = await getAdaptor(info.projectID, row.type) await adaptor.remove(info) - } catch (err) { + } catch { log.error("adaptor not available when removing workspace", { type: row.type }) } Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) @@ -404,7 +404,7 @@ export namespace Workspace { return synced(state) }, }) - } catch (error) { + } catch { if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) } diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index b5b6a7a6eb..6babccd779 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -218,7 +218,7 @@ export namespace McpOAuthCallback { log.info("oauth callback server stopped") } - for (const [name, pending] of pendingAuths) { + for (const [_name, pending] of pendingAuths) { clearTimeout(pending.timeout) pending.reject(new Error("OAuth callback server stopped")) } diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index bb41bd7a43..b6dbde0081 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -40,7 +40,7 @@ export function parse(headers: Headers) { try { data = JSON.parse(raw) - } catch (err) { + } catch { return } diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 0e29daa9ee..5fd1fc25e8 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -16,8 +16,6 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } -const OPENCODE_WORKSPACE = process.env.OPENCODE_WORKSPACE - const RULES: Array = [ { path: "/session/status", action: "forward" }, { method: "GET", path: "/session", action: "local" }, diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 8018dfbea4..bbde4c9552 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -10,11 +10,8 @@ import { AppRuntime } from "../../effect/app-runtime" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { Log } from "../../util/log" import { Effect } from "effect" -const log = Log.create({ service: "server" }) - export const ProviderRoutes = lazy(() => new Hono() .get( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4e10fdf2d6..b699676897 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1825,7 +1825,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the onSuccess: (output: unknown) => void }): AITool { // Remove $schema property if present (not needed for tool input) - const { $schema, ...toolSchema } = input.schema + const { $schema: _, ...toolSchema } = input.schema return tool({ id: "StructuredOutput" as any, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ef55758a57..b9870d194d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -176,7 +176,7 @@ export namespace ToolRegistry { } } - const cfg = yield* config.get() + yield* config.get() const questionEnabled = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index d8203ac12d..877e2ae0a3 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -276,7 +276,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty array buffer on error for images", async () => { await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "broken.png") + const _filepath = path.join(tmp.path, "broken.png") // Don't create the file await Instance.provide({ diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 39e5788012..be62f96f38 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -1,8 +1,6 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio // Implements a minimal LSP handshake and triggers a request upon notification -const net = require("net") - let nextId = 1 function encode(message) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4952a126b3..0e0810d0e9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "../../src/provider/transform" import { ModelID, ProviderID } from "../../src/provider/schema" -const OUTPUT_TOKEN_MAX = 32000 - describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 8c86dc2f06..75adb7f9f3 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -67,7 +67,7 @@ describe("session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await svc.create({ title: "new-session" }) + await svc.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 const sessions = [...svc.list({ start: futureStart })] diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index e908545d4a..f25ecc356a 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1181,7 +1181,6 @@ describe("session.llm.stream", () => { const providerID = "google" const modelID = "gemini-2.5-flash" const fixture = await loadFixture(providerID, modelID) - const provider = fixture.provider const model = fixture.model const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent` diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 668918ec83..f728bd3646 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -724,7 +724,7 @@ describe("MessageV2.filterCompacted", () => { const u1 = await addUser(session.id, "hello") await addCompactionPart(session.id, u1) - const u2 = await addUser(session.id, "world") + await addUser(session.id, "world") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(2) @@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => { isRetryable: true, }).toObject() as MessageV2.Assistant["error"] await addAssistant(session.id, u1, { summary: true, finish: "end_turn", error }) - const u2 = await addUser(session.id, "retry") + await addUser(session.id, "retry") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) // Error assistant doesn't add to completed, so compaction boundary never triggers @@ -770,7 +770,7 @@ describe("MessageV2.filterCompacted", () => { // summary=true but no finish await addAssistant(session.id, u1, { summary: true }) - const u2 = await addUser(session.id, "next") + await addUser(session.id, "next") const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(3) @@ -892,7 +892,7 @@ describe("MessageV2 consistency", () => { directory: root, fn: async () => { const session = await svc.create({}) - const ids = await fill(session.id, 4) + await fill(session.id, 4) const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) const all = Array.from(MessageV2.stream(session.id)).reverse() diff --git a/packages/sdk/js/src/v2/data.ts b/packages/sdk/js/src/v2/data.ts index baae6f278d..776b168ad9 100644 --- a/packages/sdk/js/src/v2/data.ts +++ b/packages/sdk/js/src/v2/data.ts @@ -5,7 +5,7 @@ export const message = { info: UserMessage parts: Part[] } { - const { parts, ...rest } = input + const { parts: _parts, ...rest } = input const info: UserMessage = { ...rest, diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts index ce990d3795..b49026bcba 100644 --- a/packages/shared/test/filesystem/filesystem.test.ts +++ b/packages/shared/test/filesystem/filesystem.test.ts @@ -290,7 +290,7 @@ describe("AppFileSystem", () => { it( "exists works", Effect.gen(function* () { - const fs = yield* AppFileSystem.Service + yield* AppFileSystem.Service const filesys = yield* FileSystem.FileSystem const tmp = yield* filesys.makeTempDirectoryScoped() const file = path.join(tmp, "exists.txt") @@ -304,7 +304,7 @@ describe("AppFileSystem", () => { it( "remove works", Effect.gen(function* () { - const fs = yield* AppFileSystem.Service + yield* AppFileSystem.Service const filesys = yield* FileSystem.FileSystem const tmp = yield* filesys.makeTempDirectoryScoped() const file = path.join(tmp, "delete-me.txt") diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts index 7fd2e144a2..c9116c2d5c 100644 --- a/packages/shared/test/fixture/effect-flock-worker.ts +++ b/packages/shared/test/fixture/effect-flock-worker.ts @@ -1,5 +1,4 @@ import fs from "fs/promises" -import path from "path" import os from "os" import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 123710aa46..85d6851296 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -27,7 +27,7 @@ const sessions = new Map tags -- Highlights with the same source attribute get grouped together ---> - - - -` - console.log("=== publishing ===\n") const pkgjsons = await Array.fromAsync( diff --git a/script/stats.ts b/script/stats.ts index 318b590af7..9201e26e43 100755 --- a/script/stats.ts +++ b/script/stats.ts @@ -193,7 +193,7 @@ console.log("Fetching GitHub releases for anomalyco/opencode...\n") const releases = await fetchReleases() console.log(`\nFetched ${releases.length} releases total\n`) -const { total: githubTotal, stats } = calculate(releases) +const { total: githubTotal } = calculate(releases) console.log("Fetching npm all-time downloads for opencode-ai...\n") const npmDownloads = await fetchNpmDownloads("opencode-ai") diff --git a/sdks/vscode/src/extension.ts b/sdks/vscode/src/extension.ts index 772da9cc2b..693e7267e5 100644 --- a/sdks/vscode/src/extension.ts +++ b/sdks/vscode/src/extension.ts @@ -6,11 +6,11 @@ import * as vscode from "vscode" const TERMINAL_NAME = "opencode" export function activate(context: vscode.ExtensionContext) { - let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { + const openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => { await openTerminal() }) - let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { + const openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => { // An opencode terminal already exists => focus it const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME) if (existingTerminal) { @@ -40,7 +40,7 @@ export function activate(context: vscode.ExtensionContext) { } }) - context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) + context.subscriptions.push(openNewTerminalDisposable, openTerminalDisposable, addFilepathDisposable) async function openTerminal() { // Create a new terminal in split screen From 5eae92684658c36a5026c9a36edcdf1163517022 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:07:42 -0400 Subject: [PATCH 15/75] add experimental provider auth HttpApi slice (#22389) --- bun.lock | 14 --- packages/opencode/package.json | 1 - packages/opencode/specs/effect/http-api.md | 13 +- packages/opencode/src/provider/auth.ts | 119 +++++++++--------- .../src/server/instance/httpapi/provider.ts | 46 +++++++ .../src/server/instance/httpapi/server.ts | 7 ++ .../opencode/src/server/instance/provider.ts | 4 +- packages/server/package.json | 30 ----- packages/server/src/api/index.ts | 2 - packages/server/src/api/question.ts | 37 ------ packages/server/src/definition/api.ts | 12 -- packages/server/src/definition/index.ts | 2 - packages/server/src/definition/question.ts | 94 -------------- packages/server/src/index.ts | 6 - packages/server/src/openapi.ts | 5 - packages/server/src/types.ts | 5 - packages/server/sst-env.d.ts | 10 -- packages/server/tsconfig.json | 15 --- 18 files changed, 122 insertions(+), 300 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/provider.ts delete mode 100644 packages/server/package.json delete mode 100644 packages/server/src/api/index.ts delete mode 100644 packages/server/src/api/question.ts delete mode 100644 packages/server/src/definition/api.ts delete mode 100644 packages/server/src/definition/index.ts delete mode 100644 packages/server/src/definition/question.ts delete mode 100644 packages/server/src/index.ts delete mode 100644 packages/server/src/openapi.ts delete mode 100644 packages/server/src/types.ts delete mode 100644 packages/server/sst-env.d.ts delete mode 100644 packages/server/tsconfig.json diff --git a/bun.lock b/bun.lock index 48243e652e..705181160a 100644 --- a/bun.lock +++ b/bun.lock @@ -358,7 +358,6 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/server": "workspace:*", "@openrouter/ai-sdk-provider": "2.5.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -506,17 +505,6 @@ "typescript": "catalog:", }, }, - "packages/server": { - "name": "@opencode-ai/server", - "version": "1.4.6", - "dependencies": { - "effect": "catalog:", - }, - "devDependencies": { - "@typescript/native-preview": "catalog:", - "typescript": "catalog:", - }, - }, "packages/shared": { "name": "@opencode-ai/shared", "version": "1.4.6", @@ -1568,8 +1556,6 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], - "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], - "@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 59be93d620..c0f82c1495 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -115,7 +115,6 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/server": "workspace:*", "@openrouter/ai-sdk-provider": "2.5.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 1794927cce..bd1213bb6d 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -156,6 +156,14 @@ Ordering for a route-group migration: 3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed 4. switch existing Zod boundary validators to derived `.zod` 5. define the `HttpApi` contract from the canonical Effect schemas +6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev` + +SDK shape rule: + +- every schema migration must preserve the generated SDK output byte-for-byte +- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema +- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec +- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging Temporary exception: @@ -195,8 +203,9 @@ Use the same sequence for each route group. 4. Define the `HttpApi` contract separately from the handlers. 5. Implement handlers by yielding the existing service from context. 6. Mount the new surface in parallel under an experimental prefix. -7. Add one end-to-end test and one OpenAPI-focused test. -8. Compare ergonomics before migrating the next endpoint. +7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above). +8. Add one end-to-end test and one OpenAPI-focused test. +9. Compare ergonomics before migrating the next endpoint. Rule of thumb: diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index c66ccffc12..0f2923a587 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -2,70 +2,62 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/shared/util/error" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" -import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect" +import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" import z from "zod" export namespace ProviderAuth { - export const Method = z - .object({ - type: z.union([z.literal("oauth"), z.literal("api")]), - label: z.string(), - prompts: z - .array( - z.union([ - z.object({ - type: z.literal("text"), - key: z.string(), - message: z.string(), - placeholder: z.string().optional(), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - z.object({ - type: z.literal("select"), - key: z.string(), - message: z.string(), - options: z.array( - z.object({ - label: z.string(), - value: z.string(), - hint: z.string().optional(), - }), - ), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - ]), - ) - .optional(), - }) - .meta({ - ref: "ProviderAuthMethod", - }) - export type Method = z.infer + const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, + }) - export const Authorization = z - .object({ - url: z.string(), - method: z.union([z.literal("auto"), z.literal("code")]), - instructions: z.string(), - }) - .meta({ - ref: "ProviderAuthAuthorization", - }) - export type Authorization = z.infer + const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: Schema.optional(Schema.String), + when: Schema.optional(When), + }) + + const SelectOption = Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), + }) + + const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array(SelectOption), + when: Schema.optional(When), + }) + + const Prompt = Schema.Union([TextPrompt, SelectPrompt]) + + export class Method extends Schema.Class("ProviderAuthMethod")({ + type: Schema.Literals(["oauth", "api"]), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), + }) { + static readonly zod = zod(this) + } + + export const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Methods = typeof Methods.Type + + export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ + url: Schema.String, + method: Schema.Literals(["auto", "code"]), + instructions: Schema.String, + }) { + static readonly zod = zod(this) + } export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) @@ -94,7 +86,7 @@ export namespace ProviderAuth { type Hook = NonNullable export interface Interface { - readonly methods: () => Effect.Effect> + readonly methods: () => Effect.Effect readonly authorize: (input: { providerID: ProviderID method: number @@ -131,11 +123,12 @@ export namespace ProviderAuth { }), ) + const decode = Schema.decodeUnknownSync(Methods) const methods = Effect.fn("ProviderAuth.methods")(function* () { const hooks = (yield* InstanceState.get(state)).hooks - return Record.map(hooks, (item) => - item.methods.map( - (method): Method => ({ + return decode( + Record.map(hooks, (item) => + item.methods.map((method) => ({ type: method.type, label: method.label, prompts: method.prompts?.map((prompt) => { @@ -156,7 +149,7 @@ export namespace ProviderAuth { when: prompt.when, } }), - }), + })), ), ) }) diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts new file mode 100644 index 0000000000..23e2d1ea73 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/provider.ts @@ -0,0 +1,46 @@ +import { ProviderAuth } from "@/provider/auth" +import { Effect, Layer } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/httpapi/provider" + +export const ProviderApi = HttpApi.make("provider") + .add( + HttpApiGroup.make("provider") + .add( + HttpApiEndpoint.get("auth", `${root}/auth`, { + success: ProviderAuth.Methods, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.auth", + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "provider", + description: "Experimental HttpApi provider routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const ProviderLive = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* ProviderAuth.Service + + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) + + return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => handlers.handle("auth", auth)) + }), +).pipe(Layer.provide(ProviderAuth.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 54c3c57ff5..9894343c56 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -10,8 +10,10 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import { Permission } from "@/permission" +import { ProviderAuth } from "@/provider/auth" import { Question } from "@/question" import { PermissionApi, PermissionLive } from "./permission" +import { ProviderApi, ProviderLive } from "./provider" import { QuestionApi, QuestionLive } from "./question" const Query = Schema.Struct({ @@ -108,6 +110,7 @@ export namespace ExperimentalHttpApiServer { const QuestionSecured = QuestionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization) + const ProviderSecured = ProviderApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( @@ -116,6 +119,9 @@ export namespace ExperimentalHttpApiServer { HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( Layer.provide(PermissionLive), ), + HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe( + Layer.provide(ProviderLive), + ), ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) export const layer = (opts: { hostname: string; port: number }) => @@ -127,5 +133,6 @@ export namespace ExperimentalHttpApiServer { Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(Question.defaultLayer), Layer.provideMerge(Permission.defaultLayer), + Layer.provideMerge(ProviderAuth.defaultLayer), ) } diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index bbde4c9552..0057218f3b 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -82,7 +82,7 @@ export const ProviderRoutes = lazy(() => description: "Provider auth methods", content: { "application/json": { - schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + schema: resolver(ProviderAuth.Methods.zod), }, }, }, @@ -103,7 +103,7 @@ export const ProviderRoutes = lazy(() => description: "Authorization URL and method", content: { "application/json": { - schema: resolver(ProviderAuth.Authorization.optional()), + schema: resolver(ProviderAuth.Authorization.zod.optional()), }, }, }, diff --git a/packages/server/package.json b/packages/server/package.json deleted file mode 100644 index 9b8b31299d..0000000000 --- a/packages/server/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "name": "@opencode-ai/server", - "version": "1.4.6", - "type": "module", - "license": "MIT", - "exports": { - ".": "./src/index.ts", - "./openapi": "./src/openapi.ts", - "./definition": "./src/definition/index.ts", - "./definition/api": "./src/definition/api.ts", - "./definition/question": "./src/definition/question.ts", - "./api": "./src/api/index.ts", - "./api/question": "./src/api/question.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo --noEmit", - "build": "tsc" - }, - "devDependencies": { - "@typescript/native-preview": "catalog:", - "typescript": "catalog:" - }, - "dependencies": { - "effect": "catalog:" - } -} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts deleted file mode 100644 index 375e3584b4..0000000000 --- a/packages/server/src/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { makeQuestionHandler } from "./question.js" -export type { QuestionOps } from "./question.js" diff --git a/packages/server/src/api/question.ts b/packages/server/src/api/question.ts deleted file mode 100644 index f72c37aa19..0000000000 --- a/packages/server/src/api/question.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Effect, Schema } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import { QuestionReply, QuestionRequest, questionApi } from "../definition/question.js" - -export interface QuestionOps { - readonly list: () => Effect.Effect, never, R> - readonly reply: (input: { - requestID: string - answers: Schema.Schema.Type["answers"] - }) => Effect.Effect -} - -export const makeQuestionHandler = (ops: QuestionOps) => - HttpApiBuilder.group( - questionApi, - "question", - Effect.fn("QuestionHttpApi.handlers")(function* (handlers) { - const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest)) - - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return decode(yield* ops.list()) - }) - - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: string } - payload: Schema.Schema.Type - }) { - yield* ops.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - return handlers.handle("list", list).handle("reply", reply) - }), - ) diff --git a/packages/server/src/definition/api.ts b/packages/server/src/definition/api.ts deleted file mode 100644 index e2f70196da..0000000000 --- a/packages/server/src/definition/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { questionApi } from "./question.js" - -export const api = HttpApi.make("opencode") - .addHttpApi(questionApi) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) diff --git a/packages/server/src/definition/index.ts b/packages/server/src/definition/index.ts deleted file mode 100644 index e9a52dc930..0000000000 --- a/packages/server/src/definition/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { api } from "./api.js" -export { questionApi, QuestionReply, QuestionRequest } from "./question.js" diff --git a/packages/server/src/definition/question.ts b/packages/server/src/definition/question.ts deleted file mode 100644 index 0d161e013d..0000000000 --- a/packages/server/src/definition/question.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/experimental/httpapi/question" - -// Temporary transport-local schemas until canonical question schemas move into packages/core. -export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" }) -export const SessionID = Schema.String.annotate({ identifier: "SessionID" }) -export const MessageID = Schema.String.annotate({ identifier: "MessageID" }) - -export class QuestionOption extends Schema.Class("QuestionOption")({ - label: Schema.String.annotate({ - description: "Display text (1-5 words, concise)", - }), - description: Schema.String.annotate({ - description: "Explanation of choice", - }), -}) {} - -const base = { - question: Schema.String.annotate({ - description: "Complete question", - }), - header: Schema.String.annotate({ - description: "Very short label (max 30 chars)", - }), - options: Schema.Array(QuestionOption).annotate({ - description: "Available choices", - }), - multiple: Schema.optional(Schema.Boolean).annotate({ - description: "Allow selecting multiple choices", - }), -} - -export class QuestionInfo extends Schema.Class("QuestionInfo")({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}) {} - -export class QuestionTool extends Schema.Class("QuestionTool")({ - messageID: MessageID, - callID: Schema.String, -}) {} - -export class QuestionRequest extends Schema.Class("QuestionRequest")({ - id: QuestionID, - sessionID: SessionID, - questions: Schema.Array(QuestionInfo).annotate({ - description: "Questions to ask", - }), - tool: Schema.optional(QuestionTool), -}) {} - -export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) - -export class QuestionReply extends Schema.Class("QuestionReply")({ - answers: Schema.Array(QuestionAnswer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}) {} - -export const questionApi = HttpApi.make("question").add( - HttpApiGroup.make("question") - .add( - HttpApiEndpoint.get("list", root, { - success: Schema.Array(QuestionRequest), - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.list", - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - }), - ), - HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { - params: { requestID: QuestionID }, - payload: QuestionReply, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.reply", - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "question", - description: "Experimental HttpApi question routes.", - }), - ), -) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts deleted file mode 100644 index 67b82a0be5..0000000000 --- a/packages/server/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { openapi } from "./openapi.js" -export { makeQuestionHandler } from "./api/question.js" -export { api } from "./definition/api.js" -export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js" -export type { OpenApiSpec, ServerApi } from "./types.js" -export type { QuestionOps } from "./api/question.js" diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts deleted file mode 100644 index dda870d2b6..0000000000 --- a/packages/server/src/openapi.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OpenApi } from "effect/unstable/httpapi" -import { api } from "./definition/api.js" -import type { OpenApiSpec } from "./types.js" - -export const openapi = (): OpenApiSpec => OpenApi.fromApi(api) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts deleted file mode 100644 index 9e89fe74c2..0000000000 --- a/packages/server/src/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -export type ServerApi = HttpApi.HttpApi - -export type OpenApiSpec = OpenApi.OpenAPISpec diff --git a/packages/server/sst-env.d.ts b/packages/server/sst-env.d.ts deleted file mode 100644 index 64441936d7..0000000000 --- a/packages/server/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json deleted file mode 100644 index eac2af3845..0000000000 --- a/packages/server/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig.json", - "compilerOptions": { - "target": "ES2022", - "rootDir": "src", - "outDir": "dist", - "module": "nodenext", - "declaration": true, - "moduleResolution": "nodenext", - "lib": ["es2022", "dom", "dom.iterable"], - "strict": true, - "skipLibCheck": true - }, - "include": ["src"] -} From 64cc4623b54a45c6d399110dbe4e147ef050dc8c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 02:08:47 +0000 Subject: [PATCH 16/75] chore: generate --- packages/sdk/openapi.json | 48 +++++++-------------------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c59e1ab910..f63d12490c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12699,16 +12699,8 @@ "type": "object", "properties": { "type": { - "anyOf": [ - { - "type": "string", - "const": "oauth" - }, - { - "type": "string", - "const": "api" - } - ] + "type": "string", + "enum": ["oauth", "api"] }, "label": { "type": "string" @@ -12740,16 +12732,8 @@ "type": "string" }, "op": { - "anyOf": [ - { - "type": "string", - "const": "eq" - }, - { - "type": "string", - "const": "neq" - } - ] + "type": "string", + "enum": ["eq", "neq"] }, "value": { "type": "string" @@ -12798,16 +12782,8 @@ "type": "string" }, "op": { - "anyOf": [ - { - "type": "string", - "const": "eq" - }, - { - "type": "string", - "const": "neq" - } - ] + "type": "string", + "enum": ["eq", "neq"] }, "value": { "type": "string" @@ -12831,16 +12807,8 @@ "type": "string" }, "method": { - "anyOf": [ - { - "type": "string", - "const": "auto" - }, - { - "type": "string", - "const": "code" - } - ] + "type": "string", + "enum": ["auto", "code"] }, "instructions": { "type": "string" From a1dbfb5967c564bd082c84a6fb8510208edde12f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:13:33 -0400 Subject: [PATCH 17/75] feat: unwrap uaccount namespace to flat exports + barrel (#22698) --- packages/opencode/src/account/account.ts | 454 +++++++++++++++++++++++ packages/opencode/src/account/index.ts | 438 +--------------------- 2 files changed, 457 insertions(+), 435 deletions(-) create mode 100644 packages/opencode/src/account/account.ts diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts new file mode 100644 index 0000000000..657c61b1e5 --- /dev/null +++ b/packages/opencode/src/account/account.ts @@ -0,0 +1,454 @@ +import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" + +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { normalizeServerUrl } from "./url" +import { + type AccountError, + AccessToken, + AccountID, + DeviceCode, + Info, + RefreshToken, + AccountServiceError, + AccountTransportError, + Login, + Org, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, + UserCode, +} from "./schema" + +export { + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccountTransportError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Info, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" + +export type AccountOrgs = { + account: Info + orgs: readonly Org[] +} + +export type ActiveOrg = { + account: Info + org: Org +} + +class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), +}) {} + +const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), +) + +class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, +}) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } +} + +const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) + +class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, +}) {} + +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, +}) {} + +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} + +const clientId = "opencode-cli" +const eagerRefreshThreshold = Duration.minutes(5) +const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold) + +const isTokenFresh = (tokenExpiry: number | null, now: number) => + tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs + +const mapAccountServiceError = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message))) + +const accountErrorFromCause = (cause: unknown, message: string): AccountError => { + if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) { + return cause + } + + if (HttpClientError.isHttpClientError(cause)) { + switch (cause.reason._tag) { + case "TransportError": { + return AccountTransportError.fromHttpClientError(cause.reason) + } + default: { + return new AccountServiceError({ message, cause }) + } + } + } + + return new AccountServiceError({ message, cause }) +} + +export interface Interface { + readonly active: () => Effect.Effect, AccountError> + readonly activeOrg: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect + readonly config: ( + accountID: AccountID, + orgID: OrgID, + ) => Effect.Effect>, AccountError> + readonly token: (accountID: AccountID) => Effect.Effect, AccountError> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Account") {} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const repo = yield* AccountRepo + const http = yield* HttpClient.HttpClient + const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeEffectOk = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) + + yield* repo.persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + }) + + return parsed.access_token + }) + + const refreshTokenCache = yield* Cache.make({ + capacity: Number.POSITIVE_INFINITY, + timeToLive: Duration.zero, + lookup: Effect.fnUntraced(function* (accountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) { + return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" })) + } + + const account = maybeAccount.value + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(account.token_expiry, now)) { + return account.access_token + } + + return yield* refreshToken(account) + }), + }) + + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(row.token_expiry, now)) { + return row.access_token + } + + return yield* Cache.get(refreshTokenCache, row.id) + }) + + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) return Option.none() + + const account = maybeAccount.value + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const token = Effect.fn("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), + ) + + const activeOrg = Effect.fn("Account.activeOrg")(function* () { + const activeAccount = yield* repo.active() + if (Option.isNone(activeAccount)) return Option.none() + + const account = activeAccount.value + if (!account.active_org_id) return Option.none() + + const accountOrgs = yield* orgs(account.id) + const org = accountOrgs.find((item) => item.id === account.active_org_id) + if (!org) return Option.none() + + return Option.some({ account, org }) + }) + + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { + const accounts = yield* repo.list() + return yield* Effect.forEach( + accounts, + (account) => + orgs(account.id).pipe( + Effect.catch(() => Effect.succeed([] as readonly Org[])), + Effect.map((orgs) => ({ account, orgs })), + ), + { concurrency: 3 }, + ) + }) + + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + + const { account, accessToken } = resolved.value + + return yield* fetchOrgs(account.url, accessToken) + }) + + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + + const { account, accessToken } = resolved.value + + const response = yield* executeRead( + HttpClientRequest.get(`${account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeaders({ "x-org-id": orgID }), + ), + ) + + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) + + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return Option.some(parsed.config) + }) + + const login = Effect.fn("Account.login")(function* (server: string) { + const normalizedServer = normalizeServerUrl(server) + const response = yield* executeEffectOk( + HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return new Login({ + code: parsed.device_code, + user: parsed.user_code, + url: `${normalizedServer}${parsed.verification_uri_complete}`, + server: normalizedServer, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + + const poll = Effect.fn("Account.poll")(function* (input: Login) { + const response = yield* executeEffect( + HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + const accessToken = parsed.access_token + + const user = fetchUser(input.server, accessToken) + const orgs = fetchOrgs(input.server, accessToken) + + const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) + + // TODO: When there are multiple orgs, let the user choose + const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() + + const now = yield* Clock.currentTimeMillis + const expiry = now + Duration.toMillis(parsed.expires_in) + const refreshToken = parsed.refresh_token + + yield* repo.persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken, + refreshToken, + expiry, + orgID: firstOrgID, + }) + + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ + active: repo.active, + activeOrg, + list: repo.list, + orgsByAccount, + remove: repo.remove, + use: repo.use, + orgs, + config, + token, + login, + poll, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 4c875caa6b..84152466a4 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,37 +1,4 @@ -import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" -import { - FetchHttpClient, - HttpClient, - HttpClientError, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http" - -import { withTransientReadRetry } from "@/util/effect-http-client" -import { AccountRepo, type AccountRow } from "./repo" -import { normalizeServerUrl } from "./url" -import { - type AccountError, - AccessToken, - AccountID, - DeviceCode, - Info, - RefreshToken, - AccountServiceError, - AccountTransportError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - UserCode, -} from "./schema" - +export * as Account from "./account" export { AccountID, type AccountError, @@ -52,405 +19,6 @@ export { PollExpired, PollDenied, PollError, - PollResult, + type PollResult, } from "./schema" - -export type AccountOrgs = { - account: Info - orgs: readonly Org[] -} - -export type ActiveOrg = { - account: Info - org: Org -} - -class RemoteConfig extends Schema.Class("RemoteConfig")({ - config: Schema.Record(Schema.String, Schema.Json), -}) {} - -const DurationFromSeconds = Schema.Number.pipe( - Schema.decodeTo(Schema.Duration, { - decode: SchemaGetter.transform((n) => Duration.seconds(n)), - encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), - }), -) - -class TokenRefresh extends Schema.Class("TokenRefresh")({ - access_token: AccessToken, - refresh_token: RefreshToken, - expires_in: DurationFromSeconds, -}) {} - -class DeviceAuth extends Schema.Class("DeviceAuth")({ - device_code: DeviceCode, - user_code: UserCode, - verification_uri_complete: Schema.String, - expires_in: DurationFromSeconds, - interval: DurationFromSeconds, -}) {} - -class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ - access_token: AccessToken, - refresh_token: RefreshToken, - token_type: Schema.Literal("Bearer"), - expires_in: DurationFromSeconds, -}) {} - -class DeviceTokenError extends Schema.Class("DeviceTokenError")({ - error: Schema.String, - error_description: Schema.String, -}) { - toPollResult(): PollResult { - if (this.error === "authorization_pending") return new PollPending() - if (this.error === "slow_down") return new PollSlow() - if (this.error === "expired_token") return new PollExpired() - if (this.error === "access_denied") return new PollDenied() - return new PollError({ cause: this.error }) - } -} - -const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) - -class User extends Schema.Class("User")({ - id: AccountID, - email: Schema.String, -}) {} - -class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} - -class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ - grant_type: Schema.String, - device_code: DeviceCode, - client_id: Schema.String, -}) {} - -class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ - grant_type: Schema.String, - refresh_token: RefreshToken, - client_id: Schema.String, -}) {} - -const clientId = "opencode-cli" -const eagerRefreshThreshold = Duration.minutes(5) -const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold) - -const isTokenFresh = (tokenExpiry: number | null, now: number) => - tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs - -const mapAccountServiceError = - (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message))) - -const accountErrorFromCause = (cause: unknown, message: string): AccountError => { - if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) { - return cause - } - - if (HttpClientError.isHttpClientError(cause)) { - switch (cause.reason._tag) { - case "TransportError": { - return AccountTransportError.fromHttpClientError(cause.reason) - } - default: { - return new AccountServiceError({ message, cause }) - } - } - } - - return new AccountServiceError({ message, cause }) -} - -export namespace Account { - export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly activeOrg: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect - readonly config: ( - accountID: AccountID, - orgID: OrgID, - ) => Effect.Effect>, AccountError> - readonly token: (accountID: AccountID) => Effect.Effect, AccountError> - readonly login: (url: string) => Effect.Effect - readonly poll: (input: Login) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Account") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const repo = yield* AccountRepo - const http = yield* HttpClient.HttpClient - const httpRead = withTransientReadRetry(http) - const httpOk = HttpClient.filterStatusOk(http) - const httpReadOk = HttpClient.filterStatusOk(httpRead) - - const executeRead = (request: HttpClientRequest.HttpClientRequest) => - httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => - httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeEffectOk = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => httpOk.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const executeEffect = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => http.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - - const response = yield* executeEffectOk( - HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( - new TokenRefreshRequest({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) - - yield* repo.persistToken({ - accountID: row.id, - accessToken: parsed.access_token, - refreshToken: parsed.refresh_token, - expiry, - }) - - return parsed.access_token - }) - - const refreshTokenCache = yield* Cache.make({ - capacity: Number.POSITIVE_INFINITY, - timeToLive: Duration.zero, - lookup: Effect.fnUntraced(function* (accountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) { - return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" })) - } - - const account = maybeAccount.value - const now = yield* Clock.currentTimeMillis - if (isTokenFresh(account.token_expiry, now)) { - return account.access_token - } - - return yield* refreshToken(account) - }), - }) - - const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - if (isTokenFresh(row.token_expiry, now)) { - return row.access_token - } - - return yield* Cache.get(refreshTokenCache, row.id) - }) - - const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) return Option.none() - - const account = maybeAccount.value - const accessToken = yield* resolveToken(account) - return Option.some({ account, accessToken }) - }) - - const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/user`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const token = Effect.fn("Account.token")((accountID: AccountID) => - resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), - ) - - const activeOrg = Effect.fn("Account.activeOrg")(function* () { - const activeAccount = yield* repo.active() - if (Option.isNone(activeAccount)) return Option.none() - - const account = activeAccount.value - if (!account.active_org_id) return Option.none() - - const accountOrgs = yield* orgs(account.id) - const org = accountOrgs.find((item) => item.id === account.active_org_id) - if (!org) return Option.none() - - return Option.some({ account, org }) - }) - - const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { - const accounts = yield* repo.list() - return yield* Effect.forEach( - accounts, - (account) => - orgs(account.id).pipe( - Effect.catch(() => Effect.succeed([] as readonly Org[])), - Effect.map((orgs) => ({ account, orgs })), - ), - { concurrency: 3 }, - ) - }) - - const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return [] - - const { account, accessToken } = resolved.value - - return yield* fetchOrgs(account.url, accessToken) - }) - - const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return Option.none() - - const { account, accessToken } = resolved.value - - const response = yield* executeRead( - HttpClientRequest.get(`${account.url}/api/config`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeaders({ "x-org-id": orgID }), - ), - ) - - if (response.status === 404) return Option.none() - - const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) - - const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return Option.some(parsed.config) - }) - - const login = Effect.fn("Account.login")(function* (server: string) { - const normalizedServer = normalizeServerUrl(server) - const response = yield* executeEffectOk( - HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return new Login({ - code: parsed.device_code, - user: parsed.user_code, - url: `${normalizedServer}${parsed.verification_uri_complete}`, - server: normalizedServer, - expiry: parsed.expires_in, - interval: parsed.interval, - }) - }) - - const poll = Effect.fn("Account.poll")(function* (input: Login) { - const response = yield* executeEffect( - HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( - new DeviceTokenRequest({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: input.code, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - if (parsed instanceof DeviceTokenError) return parsed.toPollResult() - const accessToken = parsed.access_token - - const user = fetchUser(input.server, accessToken) - const orgs = fetchOrgs(input.server, accessToken) - - const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) - - // TODO: When there are multiple orgs, let the user choose - const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() - - const now = yield* Clock.currentTimeMillis - const expiry = now + Duration.toMillis(parsed.expires_in) - const refreshToken = parsed.refresh_token - - yield* repo.persistAccount({ - id: account.id, - email: account.email, - url: input.server, - accessToken, - refreshToken, - expiry, - orgID: firstOrgID, - }) - - return new PollSuccess({ email: account.email }) - }) - - return Service.of({ - active: repo.active, - activeOrg, - list: repo.list, - orgsByAccount, - remove: repo.remove, - use: repo.use, - orgs, - config, - token, - login, - poll, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) -} +export type { AccountOrgs, ActiveOrg } from "./account" From 710c81984aa38618ca7106b9521100a9964ae51d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:13:56 -0400 Subject: [PATCH 18/75] feat: unwrap uauth namespace to flat exports + barrel (#22699) --- packages/opencode/src/auth/auth.ts | 89 +++++++++++++++++++++++++++ packages/opencode/src/auth/index.ts | 93 +---------------------------- 2 files changed, 91 insertions(+), 91 deletions(-) create mode 100644 packages/opencode/src/auth/auth.ts diff --git a/packages/opencode/src/auth/auth.ts b/packages/opencode/src/auth/auth.ts new file mode 100644 index 0000000000..fb9d2b1495 --- /dev/null +++ b/packages/opencode/src/auth/auth.ts @@ -0,0 +1,89 @@ +import path from "path" +import { Effect, Layer, Record, Result, Schema, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { Global } from "../global" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) + +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export const Info = Object.assign(_Info, { zod: zod(_Info) }) +export type Info = Schema.Schema.Type + +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(function* () { + const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* fsys + .writeJson(file, { ...data, [norm]: info }, 0o600) + .pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + const remove = Effect.fn("Auth.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + return Service.of({ get, all, set, remove }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b287ce551e..9174745fd8 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,91 +1,2 @@ -import path from "path" -import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { Global } from "../global" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -const file = path.join(Global.Path.data, "auth.json") - -const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause }) - -export namespace Auth { - export class Oauth extends Schema.Class("OAuth")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: Schema.Number, - accountId: Schema.optional(Schema.String), - enterpriseUrl: Schema.optional(Schema.String), - }) {} - - export class Api extends Schema.Class("ApiAuth")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), - }) {} - - export class WellKnown extends Schema.Class("WellKnownAuth")({ - type: Schema.Literal("wellknown"), - key: Schema.String, - token: Schema.String, - }) {} - - const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) - export const Info = Object.assign(_Info, { zod: zod(_Info) }) - export type Info = Schema.Schema.Type - - export class AuthError extends Schema.TaggedErrorClass()("AuthError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), - }) {} - - export interface Interface { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Auth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const decode = Schema.decodeUnknownOption(Info) - - const all = Effect.fn("Auth.all")(function* () { - const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record - return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) - }) - - const get = Effect.fn("Auth.get")(function* (providerID: string) { - return (yield* all())[providerID] - }) - - const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - if (norm !== key) delete data[key] - delete data[norm + "/"] - yield* fsys - .writeJson(file, { ...data, [norm]: info }, 0o600) - .pipe(Effect.mapError(fail("Failed to write auth data"))) - }) - - const remove = Effect.fn("Auth.remove")(function* (key: string) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - delete data[key] - delete data[norm] - yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) - }) - - return Service.of({ get, all, set, remove }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) -} +export * as Auth from "./auth" +export { OAUTH_DUMMY_KEY } from "./auth" From c6286d1bb94d208ba66a97d16445978bba7c5c6b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:14 -0400 Subject: [PATCH 19/75] feat: unwrap uenv namespace to flat exports + barrel (#22701) --- packages/opencode/src/env/env.ts | 35 +++++++++++++++++++++++++++ packages/opencode/src/env/index.ts | 38 +----------------------------- 2 files changed, 36 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/env/env.ts diff --git a/packages/opencode/src/env/env.ts b/packages/opencode/src/env/env.ts new file mode 100644 index 0000000000..0ffd5ebdc3 --- /dev/null +++ b/packages/opencode/src/env/env.ts @@ -0,0 +1,35 @@ +import { Context, Effect, Layer } from "effect" +import { InstanceState } from "@/effect/instance-state" + +type State = Record + +export interface Interface { + readonly get: (key: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly set: (key: string, value: string) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Env") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) + + const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key])) + const all = Effect.fn("Env.all")(() => InstanceState.get(state)) + const set = Effect.fn("Env.set")(function* (key: string, value: string) { + const env = yield* InstanceState.get(state) + env[key] = value + }) + const remove = Effect.fn("Env.remove")(function* (key: string) { + const env = yield* InstanceState.get(state) + delete env[key] + }) + + return Service.of({ get, all, set, remove }) + }), +) + +export const defaultLayer = layer diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index b9efb68520..c589edbfdd 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -1,37 +1 @@ -import { Context, Effect, Layer } from "effect" -import { InstanceState } from "@/effect/instance-state" - -export namespace Env { - type State = Record - - export interface Interface { - readonly get: (key: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly set: (key: string, value: string) => Effect.Effect - readonly remove: (key: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Env") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) - - const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key])) - const all = Effect.fn("Env.all")(() => InstanceState.get(state)) - const set = Effect.fn("Env.set")(function* (key: string, value: string) { - const env = yield* InstanceState.get(state) - env[key] = value - }) - const remove = Effect.fn("Env.remove")(function* (key: string) { - const env = yield* InstanceState.get(state) - delete env[key] - }) - - return Service.of({ get, all, set, remove }) - }), - ) - - export const defaultLayer = layer -} +export * as Env from "./env" From 426815a829fc56ec39121542ea2a741d93b162e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:18 -0400 Subject: [PATCH 20/75] feat: unwrap ucommand namespace to flat exports + barrel (#22700) --- packages/opencode/src/command/command.ts | 186 ++++++++++++++++++++++ packages/opencode/src/command/index.ts | 189 +---------------------- 2 files changed, 187 insertions(+), 188 deletions(-) create mode 100644 packages/opencode/src/command/command.ts diff --git a/packages/opencode/src/command/command.ts b/packages/opencode/src/command/command.ts new file mode 100644 index 0000000000..fe9005edb2 --- /dev/null +++ b/packages/opencode/src/command/command.ts @@ -0,0 +1,186 @@ +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { SessionID, MessageID } from "@/session/schema" +import { Effect, Layer, Context } from "effect" +import z from "zod" +import { Config } from "../config" +import { MCP } from "../mcp" +import { Skill } from "../skill" +import PROMPT_INITIALIZE from "./template/initialize.txt" +import PROMPT_REVIEW from "./template/review.txt" + +type State = { + commands: Record +} + +export const Event = { + Executed: BusEvent.define( + "command.executed", + z.object({ + name: z.string(), + sessionID: SessionID.zod, + arguments: z.string(), + messageID: MessageID.zod, + }), + ), +} + +export const Info = z + .object({ + name: z.string(), + description: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), + // workaround for zod not supporting async functions natively so we use getters + // https://zod.dev/v4/changelog?id=zfunction + template: z.promise(z.string()).or(z.string()), + subtask: z.boolean().optional(), + hints: z.array(z.string()), + }) + .meta({ + ref: "Command", + }) + +// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it +export type Info = Omit, "template"> & { template: Promise | string } + +export function hints(template: string) { + const result: string[] = [] + const numbered = template.match(/\$\d+/g) + if (numbered) { + for (const match of [...new Set(numbered)].sort()) result.push(match) + } + if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") + return result +} + +export const Default = { + INIT: "init", + REVIEW: "review", +} as const + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Command") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const mcp = yield* MCP.Service + const skill = yield* Skill.Service + + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { + const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() + const commands: Record = {} + + commands[Default.INIT] = { + name: Default.INIT, + description: "guided AGENTS.md setup", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + } + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", ctx.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + } + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + source: "command", + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return bridge.promise( + mcp + .getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ) + .pipe( + Effect.map( + (template) => + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ), + ), + ) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + + for (const item of yield* skill.all()) { + if (commands[item.name]) continue + commands[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + return item.content + }, + hints: [], + } + } + + return { + commands, + } + }) + + const state = yield* InstanceState.make((ctx) => init(ctx)) + + const get = Effect.fn("Command.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.commands[name] + }) + + const list = Effect.fn("Command.list")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.commands) + }) + + return Service.of({ get, list }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(Skill.defaultLayer), +) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 539ae0dac6..2e530360c5 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,188 +1 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" -import { EffectBridge } from "@/effect/bridge" -import type { InstanceContext } from "@/project/instance" -import { SessionID, MessageID } from "@/session/schema" -import { Effect, Layer, Context } from "effect" -import z from "zod" -import { Config } from "../config" -import { MCP } from "../mcp" -import { Skill } from "../skill" -import PROMPT_INITIALIZE from "./template/initialize.txt" -import PROMPT_REVIEW from "./template/review.txt" - -export namespace Command { - type State = { - commands: Record - } - - export const Event = { - Executed: BusEvent.define( - "command.executed", - z.object({ - name: z.string(), - sessionID: SessionID.zod, - arguments: z.string(), - messageID: MessageID.zod, - }), - ), - } - - export const Info = z - .object({ - name: z.string(), - description: z.string().optional(), - agent: z.string().optional(), - model: z.string().optional(), - source: z.enum(["command", "mcp", "skill"]).optional(), - // workaround for zod not supporting async functions natively so we use getters - // https://zod.dev/v4/changelog?id=zfunction - template: z.promise(z.string()).or(z.string()), - subtask: z.boolean().optional(), - hints: z.array(z.string()), - }) - .meta({ - ref: "Command", - }) - - // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it - export type Info = Omit, "template"> & { template: Promise | string } - - export function hints(template: string) { - const result: string[] = [] - const numbered = template.match(/\$\d+/g) - if (numbered) { - for (const match of [...new Set(numbered)].sort()) result.push(match) - } - if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") - return result - } - - export const Default = { - INIT: "init", - REVIEW: "review", - } as const - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Command") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const mcp = yield* MCP.Service - const skill = yield* Skill.Service - - const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { - const cfg = yield* config.get() - const bridge = yield* EffectBridge.make() - const commands: Record = {} - - commands[Default.INIT] = { - name: Default.INIT, - description: "guided AGENTS.md setup", - source: "command", - get template() { - return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) - }, - hints: hints(PROMPT_INITIALIZE), - } - commands[Default.REVIEW] = { - name: Default.REVIEW, - description: "review changes [commit|branch|pr], defaults to uncommitted", - source: "command", - get template() { - return PROMPT_REVIEW.replace("${path}", ctx.worktree) - }, - subtask: true, - hints: hints(PROMPT_REVIEW), - } - - for (const [name, command] of Object.entries(cfg.command ?? {})) { - commands[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - source: "command", - get template() { - return command.template - }, - subtask: command.subtask, - hints: hints(command.template), - } - } - - for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { - commands[name] = { - name, - source: "mcp", - description: prompt.description, - get template() { - return bridge.promise( - mcp - .getPrompt( - prompt.client, - prompt.name, - prompt.arguments - ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) - : {}, - ) - .pipe( - Effect.map( - (template) => - template?.messages - .map((message) => (message.content.type === "text" ? message.content.text : "")) - .join("\n") || "", - ), - ), - ) - }, - hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], - } - } - - for (const item of yield* skill.all()) { - if (commands[item.name]) continue - commands[item.name] = { - name: item.name, - description: item.description, - source: "skill", - get template() { - return item.content - }, - hints: [], - } - } - - return { - commands, - } - }) - - const state = yield* InstanceState.make((ctx) => init(ctx)) - - const get = Effect.fn("Command.get")(function* (name: string) { - const s = yield* InstanceState.get(state) - return s.commands[name] - }) - - const list = Effect.fn("Command.list")(function* () { - const s = yield* InstanceState.get(state) - return Object.values(s.commands) - }) - - return Service.of({ get, list }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Skill.defaultLayer), - ) -} +export * as Command from "./command" From 360d8dd940887ad2f01f57e3bd01d0e5a4d4b0c7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:34 -0400 Subject: [PATCH 21/75] feat: unwrap uinstallation namespace to flat exports + barrel (#22707) --- packages/opencode/src/installation/index.ts | 341 +----------------- .../opencode/src/installation/installation.ts | 338 +++++++++++++++++ 2 files changed, 339 insertions(+), 340 deletions(-) create mode 100644 packages/opencode/src/installation/installation.ts diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 29f9bf1be2..4e48fcd6a0 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,340 +1 @@ -import { Effect, Layer, Schema, Context, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { withTransientReadRetry } from "@/util/effect-http-client" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import path from "path" -import z from "zod" -import { BusEvent } from "@/bus/bus-event" -import { Flag } from "../flag/flag" -import { Log } from "../util/log" -import { CHANNEL as channel, VERSION as version } from "./meta" - -import semver from "semver" - -export namespace Installation { - const log = Log.create({ service: "installation" }) - - export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" - - export type ReleaseType = "patch" | "minor" | "major" - - export const Event = { - Updated: BusEvent.define( - "installation.updated", - z.object({ - version: z.string(), - }), - ), - UpdateAvailable: BusEvent.define( - "installation.update-available", - z.object({ - version: z.string(), - }), - ), - } - - export function getReleaseType(current: string, latest: string): ReleaseType { - const currMajor = semver.major(current) - const currMinor = semver.minor(current) - const newMajor = semver.major(latest) - const newMinor = semver.minor(latest) - - if (newMajor > currMajor) return "major" - if (newMinor > currMinor) return "minor" - return "patch" - } - - export const Info = z - .object({ - version: z.string(), - latest: z.string(), - }) - .meta({ - ref: "InstallationInfo", - }) - export type Info = z.infer - - export const VERSION = version - export const CHANNEL = channel - export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` - - export function isPreview() { - return CHANNEL !== "latest" - } - - export function isLocal() { - return CHANNEL === "local" - } - - export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { - stderr: Schema.String, - }) {} - - // Response schemas for external version APIs - const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) - const NpmPackage = Schema.Struct({ version: Schema.String }) - const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) - const BrewInfoV2 = Schema.Struct({ - formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), - }) - const ChocoPackage = Schema.Struct({ - d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), - }) - const ScoopManifest = NpmPackage - - export interface Interface { - readonly info: () => Effect.Effect - readonly method: () => Effect.Effect - readonly latest: (method?: Method) => Effect.Effect - readonly upgrade: (method: Method, target: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Installation") {} - - export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const text = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { - const proc = ChildProcess.make(cmd[0], cmd.slice(1), { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) - yield* handle.exitCode - return out - }, - Effect.scoped, - Effect.catch(() => Effect.succeed("")), - ) - - const run = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { - const proc = ChildProcess.make(cmd[0], cmd.slice(1), { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, stdout, stderr } - }, - Effect.scoped, - Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), - ) - - const getBrewFormula = Effect.fnUntraced(function* () { - const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) - if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" - const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) - if (coreFormula.includes("opencode")) return "opencode" - return "opencode" - }) - - const upgradeCurl = Effect.fnUntraced( - function* (target: string) { - const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) - const body = yield* response.text - const bodyBytes = new TextEncoder().encode(body) - const proc = ChildProcess.make("bash", [], { - stdin: Stream.make(bodyBytes), - env: { VERSION: target }, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, stdout, stderr } - }, - Effect.scoped, - Effect.orDie, - ) - - const methodImpl = Effect.fn("Installation.method")(function* () { - if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method - if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method - const exec = process.execPath.toLowerCase() - - const checks: Array<{ name: Method; command: () => Effect.Effect }> = [ - { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, - { name: "yarn", command: () => text(["yarn", "global", "list"]) }, - { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, - { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, - { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, - { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, - { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, - ] - - checks.sort((a, b) => { - const aMatches = exec.includes(a.name) - const bMatches = exec.includes(b.name) - if (aMatches && !bMatches) return -1 - if (!aMatches && bMatches) return 1 - return 0 - }) - - for (const check of checks) { - const output = yield* check.command() - const installedName = - check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" - if (output.includes(installedName)) { - return check.name - } - } - - return "unknown" as Method - }) - - const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) { - const detectedMethod = installMethod || (yield* methodImpl()) - - if (detectedMethod === "brew") { - const formula = yield* getBrewFormula() - if (formula.includes("/")) { - const infoJson = yield* text(["brew", "info", "--json=v2", formula]) - const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) - return info.formulae[0].versions.stable - } - const response = yield* httpOk.execute( - HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( - HttpClientRequest.acceptJson, - ), - ) - const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) - return data.versions.stable - } - - if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { - const r = (yield* text(["npm", "config", "get", "registry"])).trim() - const reg = r || "https://registry.npmjs.org" - const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg - const channel = CHANNEL - const response = yield* httpOk.execute( - HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), - ) - const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) - return data.version - } - - if (detectedMethod === "choco") { - const response = yield* httpOk.execute( - HttpClientRequest.get( - "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", - ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), - ) - const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) - return data.d.results[0].Version - } - - if (detectedMethod === "scoop") { - const response = yield* httpOk.execute( - HttpClientRequest.get( - "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", - ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), - ) - const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) - return data.version - } - - const response = yield* httpOk.execute( - HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( - HttpClientRequest.acceptJson, - ), - ) - const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) - return data.tag_name.replace(/^v/, "") - }, Effect.orDie) - - const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { - let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined - switch (m) { - case "curl": - result = yield* upgradeCurl(target) - break - case "npm": - result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) - break - case "pnpm": - result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) - break - case "bun": - result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) - break - case "brew": { - const formula = yield* getBrewFormula() - const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } - if (formula.includes("/")) { - const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) - if (tap.code !== 0) { - result = tap - break - } - const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) - const dir = repo.trim() - if (dir) { - const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) - if (pull.code !== 0) { - result = pull - break - } - } - } - result = yield* run(["brew", "upgrade", formula], { env }) - break - } - case "choco": - result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) - break - case "scoop": - result = yield* run(["scoop", "install", `opencode@${target}`]) - break - default: - return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` }) - } - if (!result || result.code !== 0) { - const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" - return yield* new UpgradeFailedError({ stderr }) - } - log.info("upgraded", { - method: m, - target, - stdout: result.stdout, - stderr: result.stderr, - }) - yield* text([process.execPath, "--version"]) - }) - - return Service.of({ - info: Effect.fn("Installation.info")(function* () { - return { - version: VERSION, - latest: yield* latestImpl(), - } - }), - method: methodImpl, - latest: latestImpl, - upgrade: upgradeImpl, - }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ) -} +export * as Installation from "./installation" diff --git a/packages/opencode/src/installation/installation.ts b/packages/opencode/src/installation/installation.ts new file mode 100644 index 0000000000..898af9269c --- /dev/null +++ b/packages/opencode/src/installation/installation.ts @@ -0,0 +1,338 @@ +import { Effect, Layer, Schema, Context, Stream } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Flag } from "../flag/flag" +import { Log } from "../util/log" +import { CHANNEL as channel, VERSION as version } from "./meta" + +import semver from "semver" + +const log = Log.create({ service: "installation" }) + +export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" + +export type ReleaseType = "patch" | "minor" | "major" + +export const Event = { + Updated: BusEvent.define( + "installation.updated", + z.object({ + version: z.string(), + }), + ), + UpdateAvailable: BusEvent.define( + "installation.update-available", + z.object({ + version: z.string(), + }), + ), +} + +export function getReleaseType(current: string, latest: string): ReleaseType { + const currMajor = semver.major(current) + const currMinor = semver.minor(current) + const newMajor = semver.major(latest) + const newMinor = semver.minor(latest) + + if (newMajor > currMajor) return "major" + if (newMinor > currMinor) return "minor" + return "patch" +} + +export const Info = z + .object({ + version: z.string(), + latest: z.string(), + }) + .meta({ + ref: "InstallationInfo", + }) +export type Info = z.infer + +export const VERSION = version +export const CHANNEL = channel +export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` + +export function isPreview() { + return CHANNEL !== "latest" +} + +export function isLocal() { + return CHANNEL === "local" +} + +export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { + stderr: Schema.String, +}) {} + +// Response schemas for external version APIs +const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) +const NpmPackage = Schema.Struct({ version: Schema.String }) +const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) +const BrewInfoV2 = Schema.Struct({ + formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), +}) +const ChocoPackage = Schema.Struct({ + d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), +}) +const ScoopManifest = NpmPackage + +export interface Interface { + readonly info: () => Effect.Effect + readonly method: () => Effect.Effect + readonly latest: (method?: Method) => Effect.Effect + readonly upgrade: (method: Method, target: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Installation") {} + +export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const text = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + yield* handle.exitCode + return out + }, + Effect.scoped, + Effect.catch(() => Effect.succeed("")), + ) + + const run = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, stdout, stderr } + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), + ) + + const getBrewFormula = Effect.fnUntraced(function* () { + const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) + if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" + const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) + if (coreFormula.includes("opencode")) return "opencode" + return "opencode" + }) + + const upgradeCurl = Effect.fnUntraced( + function* (target: string) { + const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) + const body = yield* response.text + const bodyBytes = new TextEncoder().encode(body) + const proc = ChildProcess.make("bash", [], { + stdin: Stream.make(bodyBytes), + env: { VERSION: target }, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, stdout, stderr } + }, + Effect.scoped, + Effect.orDie, + ) + + const methodImpl = Effect.fn("Installation.method")(function* () { + if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method + if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method + const exec = process.execPath.toLowerCase() + + const checks: Array<{ name: Method; command: () => Effect.Effect }> = [ + { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, + { name: "yarn", command: () => text(["yarn", "global", "list"]) }, + { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, + { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, + { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, + { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, + { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, + ] + + checks.sort((a, b) => { + const aMatches = exec.includes(a.name) + const bMatches = exec.includes(b.name) + if (aMatches && !bMatches) return -1 + if (!aMatches && bMatches) return 1 + return 0 + }) + + for (const check of checks) { + const output = yield* check.command() + const installedName = + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" + if (output.includes(installedName)) { + return check.name + } + } + + return "unknown" as Method + }) + + const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) { + const detectedMethod = installMethod || (yield* methodImpl()) + + if (detectedMethod === "brew") { + const formula = yield* getBrewFormula() + if (formula.includes("/")) { + const infoJson = yield* text(["brew", "info", "--json=v2", formula]) + const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) + return info.formulae[0].versions.stable + } + const response = yield* httpOk.execute( + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) + return data.versions.stable + } + + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { + const r = (yield* text(["npm", "config", "get", "registry"])).trim() + const reg = r || "https://registry.npmjs.org" + const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg + const channel = CHANNEL + const response = yield* httpOk.execute( + HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), + ) + const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) + return data.version + } + + if (detectedMethod === "choco") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) + return data.d.results[0].Version + } + + if (detectedMethod === "scoop") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) + return data.version + } + + const response = yield* httpOk.execute( + HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) + return data.tag_name.replace(/^v/, "") + }, Effect.orDie) + + const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { + let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined + switch (m) { + case "curl": + result = yield* upgradeCurl(target) + break + case "npm": + result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) + break + case "pnpm": + result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) + break + case "bun": + result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) + break + case "brew": { + const formula = yield* getBrewFormula() + const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } + if (formula.includes("/")) { + const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) + if (tap.code !== 0) { + result = tap + break + } + const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) + const dir = repo.trim() + if (dir) { + const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) + if (pull.code !== 0) { + result = pull + break + } + } + } + result = yield* run(["brew", "upgrade", formula], { env }) + break + } + case "choco": + result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) + break + case "scoop": + result = yield* run(["scoop", "install", `opencode@${target}`]) + break + default: + return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` }) + } + if (!result || result.code !== 0) { + const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" + return yield* new UpgradeFailedError({ stderr }) + } + log.info("upgraded", { + method: m, + target, + stdout: result.stdout, + stderr: result.stderr, + }) + yield* text([process.execPath, "--version"]) + }) + + return Service.of({ + info: Effect.fn("Installation.info")(function* () { + return { + version: VERSION, + latest: yield* latestImpl(), + } + }), + method: methodImpl, + latest: latestImpl, + upgrade: upgradeImpl, + }) + }), + ) + +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), +) From 26cdbc20b2f889d27d5e84c6b87774c61ec87f99 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:37 -0400 Subject: [PATCH 22/75] feat: unwrap ufile namespace to flat exports + barrel (#22702) --- packages/opencode/src/file/file.ts | 654 +++++++++++++++++++++++++++ packages/opencode/src/file/index.ts | 657 +--------------------------- 2 files changed, 655 insertions(+), 656 deletions(-) create mode 100644 packages/opencode/src/file/file.ts diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts new file mode 100644 index 0000000000..657fe9a583 --- /dev/null +++ b/packages/opencode/src/file/file.ts @@ -0,0 +1,654 @@ +import { BusEvent } from "@/bus/bus-event" +import { InstanceState } from "@/effect/instance-state" + +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Git } from "@/git" +import { Effect, Layer, Context } from "effect" +import * as Stream from "effect/Stream" +import { formatPatch, structuredPatch } from "diff" +import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" +import { Global } from "../global" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { Protected } from "./protected" +import { Ripgrep } from "./ripgrep" + +export const Info = z + .object({ + path: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .meta({ + ref: "File", + }) + +export type Info = z.infer + +export const Node = z + .object({ + name: z.string(), + path: z.string(), + absolute: z.string(), + type: z.enum(["file", "directory"]), + ignored: z.boolean(), + }) + .meta({ + ref: "FileNode", + }) +export type Node = z.infer + +export const Content = z + .object({ + type: z.enum(["text", "binary"]), + content: z.string(), + diff: z.string().optional(), + patch: z + .object({ + oldFileName: z.string(), + newFileName: z.string(), + oldHeader: z.string().optional(), + newHeader: z.string().optional(), + hunks: z.array( + z.object({ + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + lines: z.array(z.string()), + }), + ), + index: z.string().optional(), + }) + .optional(), + encoding: z.literal("base64").optional(), + mimeType: z.string().optional(), + }) + .meta({ + ref: "FileContent", + }) +export type Content = z.infer + +export const Event = { + Edited: BusEvent.define( + "file.edited", + z.object({ + file: z.string(), + }), + ), +} + +const log = Log.create({ service: "file" }) + +const binary = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", +]) + +const image = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", +]) + +const text = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", +]) + +const textName = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", +]) + +const mime: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", +} + +type Entry = { files: string[]; dirs: string[] } + +const ext = (file: string) => path.extname(file).toLowerCase().slice(1) +const name = (file: string) => path.basename(file).toLowerCase() +const isImageByExtension = (file: string) => image.has(ext(file)) +const isTextByExtension = (file: string) => text.has(ext(file)) +const isTextByName = (file: string) => textName.has(name(file)) +const isBinaryByExtension = (file: string) => binary.has(ext(file)) +const isImage = (mimeType: string) => mimeType.startsWith("image/") +const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) + +function shouldEncode(mimeType: string) { + const type = mimeType.toLowerCase() + log.debug("shouldEncode", { type }) + if (!type) return false + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + const top = type.split("/", 2)[0] + return ["image", "audio", "video", "font", "model", "multipart"].includes(top) +} + +const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) +} + +const sortHiddenLast = (items: string[], prefer: boolean) => { + if (prefer) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + if (hidden(item)) hiddenItems.push(item) + else visible.push(item) + } + return [...visible, ...hiddenItems] +} + +interface State { + cache: Entry +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/File") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const appFs = yield* AppFileSystem.Service + const rg = yield* Ripgrep.Service + const git = yield* Git.Service + + const state = yield* InstanceState.make( + Effect.fn("File.state")(() => + Effect.succeed({ + cache: { files: [], dirs: [] } as Entry, + }), + ), + ) + + const scan = Effect.fn("File.scan")(function* () { + if (Instance.directory === path.parse(Instance.directory).root) return + const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const next: Entry = { files: [], dirs: [] } + + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => [])) + + for (const entry of top) { + if (entry.type !== "directory") continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(Instance.directory, entry.name) + const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => [])) + for (const child of children) { + if (child.type !== "directory") continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + next.dirs = Array.from(dirs).toSorted() + } else { + const files = yield* rg.files({ cwd: Instance.directory }).pipe( + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + const seen = new Set() + for (const file of files) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (seen.has(dir)) continue + seen.add(dir) + next.dirs.push(dir + "/") + } + } + } + + const s = yield* InstanceState.get(state) + s.cache = next + }) + + let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) + + const ensure = Effect.fn("File.ensure")(function* () { + yield* cachedScan + cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) + }) + + const gitText = Effect.fnUntraced(function* (args: string[]) { + return (yield* git.run(args, { cwd: Instance.directory })).text() + }) + + const init = Effect.fn("File.init")(function* () { + yield* ensure() + }) + + const status = Effect.fn("File.status")(function* () { + if (Instance.project.vcs !== "git") return [] + + const diffOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--numstat", + "HEAD", + ]) + + const changed: Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) + } + } + + const untrackedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ]) + + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + const content = yield* appFs + .readFileString(path.join(Instance.directory, file)) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) continue + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) + } + } + + const deletedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ]) + + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) + } + } + + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + return { + ...item, + path: path.relative(Instance.directory, full), + } + }) + }) + + const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { + using _ = log.time("read", { file }) + const full = path.join(Instance.directory, file) + + if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory") + + if (isImageByExtension(file)) { + const exists = yield* appFs.existsSafe(full) + if (exists) { + const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + return { + type: "text" as const, + content: Buffer.from(bytes).toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64" as const, + } + } + return { type: "text" as const, content: "" } + } + + const knownText = isTextByExtension(file) || isTextByName(file) + + if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" } + + const exists = yield* appFs.existsSafe(full) + if (!exists) return { type: "text" as const, content: "" } + + const mimeType = AppFileSystem.mimeType(full) + const encode = knownText ? false : shouldEncode(mimeType) + + if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType } + + if (encode) { + const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + return { + type: "text" as const, + content: Buffer.from(bytes).toString("base64"), + mimeType, + encoding: "base64" as const, + } + } + + const content = yield* appFs.readFileString(full).pipe( + Effect.map((s) => s.trim()), + Effect.catch(() => Effect.succeed("")), + ) + + if (Instance.project.vcs === "git") { + let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) + if (!diff.trim()) { + diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) + } + if (diff.trim()) { + const original = yield* git.show(Instance.directory, "HEAD", file) + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { type: "text" as const, content, patch, diff: formatPatch(patch) } + } + return { type: "text" as const, content } + } + + return { type: "text" as const, content } + }) + + const list = Effect.fn("File.list")(function* (dir?: string) { + const exclude = [".git", ".DS_Store"] + let ignored = (_: string) => false + if (Instance.project.vcs === "git") { + const ig = ignore() + const gitignore = path.join(Instance.project.worktree, ".gitignore") + const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed(""))) + if (gitignoreText) ig.add(gitignoreText) + const ignoreFile = path.join(Instance.project.worktree, ".ignore") + const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed(""))) + if (ignoreText) ig.add(ignoreText) + ignored = ig.ignores.bind(ig) + } + + const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory + if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory") + + const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => [])) + + const nodes: Node[] = [] + for (const entry of entries) { + if (exclude.includes(entry.name)) continue + const absolute = path.join(resolved, entry.name) + const file = path.relative(Instance.directory, absolute) + const type = entry.type === "directory" ? "directory" : "file" + nodes.push({ + name: entry.name, + path: file, + absolute, + type, + ignored: ignored(type === "directory" ? file + "/" : file), + }) + } + return nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + }) + + const search = Effect.fn("File.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + yield* ensure() + const { cache } = yield* InstanceState.get(state) + + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const preferHidden = query.startsWith(".") || query.includes("/.") + + if (!query) { + if (kind === "file") return cache.files.slice(0, limit) + return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit) + } + + const items = + kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) + const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output + }) + + log.info("init") + return Service.of({ init, status, read, list, search }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 909f1e61d2..b65ac9d686 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,656 +1 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" - -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Git } from "@/git" -import { Effect, Layer, Context } from "effect" -import * as Stream from "effect/Stream" -import { formatPatch, structuredPatch } from "diff" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import path from "path" -import z from "zod" -import { Global } from "../global" -import { Instance } from "../project/instance" -import { Log } from "../util/log" -import { Protected } from "./protected" -import { Ripgrep } from "./ripgrep" - -export namespace File { - export const Info = z - .object({ - path: z.string(), - added: z.number().int(), - removed: z.number().int(), - status: z.enum(["added", "deleted", "modified"]), - }) - .meta({ - ref: "File", - }) - - export type Info = z.infer - - export const Node = z - .object({ - name: z.string(), - path: z.string(), - absolute: z.string(), - type: z.enum(["file", "directory"]), - ignored: z.boolean(), - }) - .meta({ - ref: "FileNode", - }) - export type Node = z.infer - - export const Content = z - .object({ - type: z.enum(["text", "binary"]), - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - encoding: z.literal("base64").optional(), - mimeType: z.string().optional(), - }) - .meta({ - ref: "FileContent", - }) - export type Content = z.infer - - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), - } - - const log = Log.create({ service: "file" }) - - const binary = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - ]) - - const image = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", - ]) - - const text = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", - ]) - - const textName = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", - ]) - - const mime: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - - type Entry = { files: string[]; dirs: string[] } - - const ext = (file: string) => path.extname(file).toLowerCase().slice(1) - const name = (file: string) => path.basename(file).toLowerCase() - const isImageByExtension = (file: string) => image.has(ext(file)) - const isTextByExtension = (file: string) => text.has(ext(file)) - const isTextByName = (file: string) => textName.has(name(file)) - const isBinaryByExtension = (file: string) => binary.has(ext(file)) - const isImage = (mimeType: string) => mimeType.startsWith("image/") - const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) - - function shouldEncode(mimeType: string) { - const type = mimeType.toLowerCase() - log.debug("shouldEncode", { type }) - if (!type) return false - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - const top = type.split("/", 2)[0] - return ["image", "audio", "video", "font", "model", "multipart"].includes(top) - } - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) - } - - const sortHiddenLast = (items: string[], prefer: boolean) => { - if (prefer) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - if (hidden(item)) hiddenItems.push(item) - else visible.push(item) - } - return [...visible, ...hiddenItems] - } - - interface State { - cache: Entry - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/File") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const appFs = yield* AppFileSystem.Service - const rg = yield* Ripgrep.Service - const git = yield* Git.Service - - const state = yield* InstanceState.make( - Effect.fn("File.state")(() => - Effect.succeed({ - cache: { files: [], dirs: [] } as Entry, - }), - ), - ) - - const scan = Effect.fn("File.scan")(function* () { - if (Instance.directory === path.parse(Instance.directory).root) return - const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" - const next: Entry = { files: [], dirs: [] } - - if (isGlobalHome) { - const dirs = new Set() - const protectedNames = Protected.names() - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => [])) - - for (const entry of top) { - if (entry.type !== "directory") continue - if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(Instance.directory, entry.name) - const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => [])) - for (const child of children) { - if (child.type !== "directory") continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } - - next.dirs = Array.from(dirs).toSorted() - } else { - const files = yield* rg.files({ cwd: Instance.directory }).pipe( - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - const seen = new Set() - for (const file of files) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (seen.has(dir)) continue - seen.add(dir) - next.dirs.push(dir + "/") - } - } - } - - const s = yield* InstanceState.get(state) - s.cache = next - }) - - let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) - - const ensure = Effect.fn("File.ensure")(function* () { - yield* cachedScan - cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) - }) - - const gitText = Effect.fnUntraced(function* (args: string[]) { - return (yield* git.run(args, { cwd: Instance.directory })).text() - }) - - const init = Effect.fn("File.init")(function* () { - yield* ensure() - }) - - const status = Effect.fn("File.status")(function* () { - if (Instance.project.vcs !== "git") return [] - - const diffOutput = yield* gitText([ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--numstat", - "HEAD", - ]) - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } - } - - const untrackedOutput = yield* gitText([ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ]) - - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - const content = yield* appFs - .readFileString(path.join(Instance.directory, file)) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - if (content === undefined) continue - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } - } - - const deletedOutput = yield* gitText([ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ]) - - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } - } - - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) - return { - ...item, - path: path.relative(Instance.directory, full), - } - }) - }) - - const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { - using _ = log.time("read", { file }) - const full = path.join(Instance.directory, file) - - if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory") - - if (isImageByExtension(file)) { - const exists = yield* appFs.existsSafe(full) - if (exists) { - const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - return { - type: "text" as const, - content: Buffer.from(bytes).toString("base64"), - mimeType: getImageMimeType(file), - encoding: "base64" as const, - } - } - return { type: "text" as const, content: "" } - } - - const knownText = isTextByExtension(file) || isTextByName(file) - - if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" } - - const exists = yield* appFs.existsSafe(full) - if (!exists) return { type: "text" as const, content: "" } - - const mimeType = AppFileSystem.mimeType(full) - const encode = knownText ? false : shouldEncode(mimeType) - - if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType } - - if (encode) { - const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - return { - type: "text" as const, - content: Buffer.from(bytes).toString("base64"), - mimeType, - encoding: "base64" as const, - } - } - - const content = yield* appFs.readFileString(full).pipe( - Effect.map((s) => s.trim()), - Effect.catch(() => Effect.succeed("")), - ) - - if (Instance.project.vcs === "git") { - let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) - if (!diff.trim()) { - diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) - } - if (diff.trim()) { - const original = yield* git.show(Instance.directory, "HEAD", file) - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { type: "text" as const, content, patch, diff: formatPatch(patch) } - } - return { type: "text" as const, content } - } - - return { type: "text" as const, content } - }) - - const list = Effect.fn("File.list")(function* (dir?: string) { - const exclude = [".git", ".DS_Store"] - let ignored = (_: string) => false - if (Instance.project.vcs === "git") { - const ig = ignore() - const gitignore = path.join(Instance.project.worktree, ".gitignore") - const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed(""))) - if (gitignoreText) ig.add(gitignoreText) - const ignoreFile = path.join(Instance.project.worktree, ".ignore") - const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed(""))) - if (ignoreText) ig.add(ignoreText) - ignored = ig.ignores.bind(ig) - } - - const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory") - - const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => [])) - - const nodes: File.Node[] = [] - for (const entry of entries) { - if (exclude.includes(entry.name)) continue - const absolute = path.join(resolved, entry.name) - const file = path.relative(Instance.directory, absolute) - const type = entry.type === "directory" ? "directory" : "file" - nodes.push({ - name: entry.name, - path: file, - absolute, - type, - ignored: ignored(type === "directory" ? file + "/" : file), - }) - } - return nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "directory" ? -1 : 1 - return a.name.localeCompare(b.name) - }) - }) - - const search = Effect.fn("File.search")(function* (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) { - yield* ensure() - const { cache } = yield* InstanceState.get(state) - - const query = input.query.trim() - const limit = input.limit ?? 100 - const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) - - const preferHidden = query.startsWith(".") || query.includes("/.") - - if (!query) { - if (kind === "file") return cache.files.slice(0, limit) - return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit) - } - - const items = - kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) - const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) - return output - }) - - log.info("init") - return Service.of({ init, status, read, list, search }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Git.defaultLayer), - ) -} +export * as File from "./file" From d22b5f026d38562550a9394aff9dbe9839b09812 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:44 -0400 Subject: [PATCH 23/75] feat: unwrap unpm namespace to flat exports + barrel (#22708) --- packages/opencode/src/npm/index.ts | 189 +---------------------------- packages/opencode/src/npm/npm.ts | 186 ++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 188 deletions(-) create mode 100644 packages/opencode/src/npm/npm.ts diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index e648fd899c..856ed2a2c6 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,188 +1 @@ -import semver from "semver" -import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Global } from "../global" -import { Log } from "../util/log" -import path from "path" -import { readdir, rm } from "fs/promises" -import { Filesystem } from "@/util/filesystem" -import { Flock } from "@opencode-ai/shared/util/flock" -import { Arborist } from "@npmcli/arborist" - -export namespace Npm { - const log = Log.create({ service: "npm" }) - const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined - - export const InstallFailedError = NamedError.create( - "NpmInstallFailedError", - z.object({ - pkg: z.string(), - }), - ) - - export function sanitize(pkg: string) { - if (!illegal) return pkg - return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") - } - - function directory(pkg: string) { - return path.join(Global.Path.cache, "packages", sanitize(pkg)) - } - - function resolveEntryPoint(name: string, dir: string) { - let entrypoint: string | undefined - try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - } catch {} - const result = { - directory: dir, - entrypoint, - } - return result - } - - export async function outdated(pkg: string, cachedVersion: string): Promise { - const response = await fetch(`https://registry.npmjs.org/${pkg}`) - if (!response.ok) { - log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion }) - return false - } - - const data = (await response.json()) as { "dist-tags"?: { latest?: string } } - const latestVersion = data?.["dist-tags"]?.latest - if (!latestVersion) { - log.warn("No latest version found, using cached", { pkg, cachedVersion }) - return false - } - - const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion, cachedVersion) - - return semver.lt(cachedVersion, latestVersion) - } - - export async function add(pkg: string) { - const dir = directory(pkg) - await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`) - log.info("installing package", { - pkg, - }) - - const arborist = new Arborist({ - path: dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - const tree = await arborist.loadVirtual().catch(() => {}) - if (tree) { - const first = tree.edgesOut.values().next().value?.to - if (first) { - return resolveEntryPoint(first.name, first.path) - } - } - - const result = await arborist - .reify({ - add: [pkg], - save: true, - saveType: "prod", - }) - .catch((cause) => { - throw new InstallFailedError( - { pkg }, - { - cause, - }, - ) - }) - - const first = result.edgesOut.values().next().value?.to - if (!first) throw new InstallFailedError({ pkg }) - return resolveEntryPoint(first.name, first.path) - } - - export async function install(dir: string) { - await using _ = await Flock.acquire(`npm-install:${dir}`) - log.info("checking dependencies", { dir }) - - const reify = async () => { - const arb = new Arborist({ - path: dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - await arb.reify().catch(() => {}) - } - - if (!(await Filesystem.exists(path.join(dir, "node_modules")))) { - log.info("node_modules missing, reifying") - await reify() - return - } - - const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) - const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({})) - - const declared = new Set([ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.devDependencies || {}), - ...Object.keys(pkg.peerDependencies || {}), - ...Object.keys(pkg.optionalDependencies || {}), - ]) - - const root = lock.packages?.[""] || {} - const locked = new Set([ - ...Object.keys(root.dependencies || {}), - ...Object.keys(root.devDependencies || {}), - ...Object.keys(root.peerDependencies || {}), - ...Object.keys(root.optionalDependencies || {}), - ]) - - for (const name of declared) { - if (!locked.has(name)) { - log.info("dependency not in lock file, reifying", { name }) - await reify() - return - } - } - - log.info("dependencies in sync") - } - - export async function which(pkg: string) { - const dir = directory(pkg) - const binDir = path.join(dir, "node_modules", ".bin") - - const pick = async () => { - const files = await readdir(binDir).catch(() => []) - if (files.length === 0) return undefined - if (files.length === 1) return files[0] - // Multiple binaries — resolve from package.json bin field like npx does - const pkgJson = await Filesystem.readJson<{ bin?: string | Record }>( - path.join(dir, "node_modules", pkg, "package.json"), - ).catch(() => undefined) - if (pkgJson?.bin) { - const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = pkgJson.bin - if (typeof bin === "string") return unscoped - const keys = Object.keys(bin) - if (keys.length === 1) return keys[0] - return bin[unscoped] ? unscoped : keys[0] - } - return files[0] - } - - const bin = await pick() - if (bin) return path.join(binDir, bin) - - await rm(path.join(dir, "package-lock.json"), { force: true }) - await add(pkg) - const resolved = await pick() - if (!resolved) return - return path.join(binDir, resolved) - } -} +export * as Npm from "./npm" diff --git a/packages/opencode/src/npm/npm.ts b/packages/opencode/src/npm/npm.ts new file mode 100644 index 0000000000..f905130719 --- /dev/null +++ b/packages/opencode/src/npm/npm.ts @@ -0,0 +1,186 @@ +import semver from "semver" +import z from "zod" +import { NamedError } from "@opencode-ai/shared/util/error" +import { Global } from "../global" +import { Log } from "../util/log" +import path from "path" +import { readdir, rm } from "fs/promises" +import { Filesystem } from "@/util/filesystem" +import { Flock } from "@opencode-ai/shared/util/flock" +import { Arborist } from "@npmcli/arborist" + +const log = Log.create({ service: "npm" }) +const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined + +export const InstallFailedError = NamedError.create( + "NpmInstallFailedError", + z.object({ + pkg: z.string(), + }), +) + +export function sanitize(pkg: string) { + if (!illegal) return pkg + return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") +} + +function directory(pkg: string) { + return path.join(Global.Path.cache, "packages", sanitize(pkg)) +} + +function resolveEntryPoint(name: string, dir: string) { + let entrypoint: string | undefined + try { + entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) + } catch {} + const result = { + directory: dir, + entrypoint, + } + return result +} + +export async function outdated(pkg: string, cachedVersion: string): Promise { + const response = await fetch(`https://registry.npmjs.org/${pkg}`) + if (!response.ok) { + log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion }) + return false + } + + const data = (await response.json()) as { "dist-tags"?: { latest?: string } } + const latestVersion = data?.["dist-tags"]?.latest + if (!latestVersion) { + log.warn("No latest version found, using cached", { pkg, cachedVersion }) + return false + } + + const range = /[\s^~*xX<>|=]/.test(cachedVersion) + if (range) return !semver.satisfies(latestVersion, cachedVersion) + + return semver.lt(cachedVersion, latestVersion) +} + +export async function add(pkg: string) { + const dir = directory(pkg) + await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`) + log.info("installing package", { + pkg, + }) + + const arborist = new Arborist({ + path: dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + const tree = await arborist.loadVirtual().catch(() => {}) + if (tree) { + const first = tree.edgesOut.values().next().value?.to + if (first) { + return resolveEntryPoint(first.name, first.path) + } + } + + const result = await arborist + .reify({ + add: [pkg], + save: true, + saveType: "prod", + }) + .catch((cause) => { + throw new InstallFailedError( + { pkg }, + { + cause, + }, + ) + }) + + const first = result.edgesOut.values().next().value?.to + if (!first) throw new InstallFailedError({ pkg }) + return resolveEntryPoint(first.name, first.path) +} + +export async function install(dir: string) { + await using _ = await Flock.acquire(`npm-install:${dir}`) + log.info("checking dependencies", { dir }) + + const reify = async () => { + const arb = new Arborist({ + path: dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + await arb.reify().catch(() => {}) + } + + if (!(await Filesystem.exists(path.join(dir, "node_modules")))) { + log.info("node_modules missing, reifying") + await reify() + return + } + + const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) + const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({})) + + const declared = new Set([ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + ...Object.keys(pkg.optionalDependencies || {}), + ]) + + const root = lock.packages?.[""] || {} + const locked = new Set([ + ...Object.keys(root.dependencies || {}), + ...Object.keys(root.devDependencies || {}), + ...Object.keys(root.peerDependencies || {}), + ...Object.keys(root.optionalDependencies || {}), + ]) + + for (const name of declared) { + if (!locked.has(name)) { + log.info("dependency not in lock file, reifying", { name }) + await reify() + return + } + } + + log.info("dependencies in sync") +} + +export async function which(pkg: string) { + const dir = directory(pkg) + const binDir = path.join(dir, "node_modules", ".bin") + + const pick = async () => { + const files = await readdir(binDir).catch(() => []) + if (files.length === 0) return undefined + if (files.length === 1) return files[0] + // Multiple binaries — resolve from package.json bin field like npx does + const pkgJson = await Filesystem.readJson<{ bin?: string | Record }>( + path.join(dir, "node_modules", pkg, "package.json"), + ).catch(() => undefined) + if (pkgJson?.bin) { + const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg + const bin = pkgJson.bin + if (typeof bin === "string") return unscoped + const keys = Object.keys(bin) + if (keys.length === 1) return keys[0] + return bin[unscoped] ? unscoped : keys[0] + } + return files[0] + } + + const bin = await pick() + if (bin) return path.join(binDir, bin) + + await rm(path.join(dir, "package-lock.json"), { force: true }) + await add(pkg) + const resolved = await pick() + if (!resolved) return + return path.join(binDir, resolved) +} From 47577ae8574dd64b3b0773ce9239d6d103fea8d3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:14:59 -0400 Subject: [PATCH 24/75] feat: unwrap upermission namespace to flat exports + barrel (#22710) --- packages/opencode/src/permission/index.ts | 326 +----------------- .../opencode/src/permission/permission.ts | 323 +++++++++++++++++ 2 files changed, 324 insertions(+), 325 deletions(-) create mode 100644 packages/opencode/src/permission/permission.ts diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 0100485492..7d8a2fff82 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,325 +1 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { Config } from "@/config" -import { InstanceState } from "@/effect/instance-state" -import { ProjectID } from "@/project/schema" -import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@/session/session.sql" -import { Database, eq } from "@/storage/db" -import { zod } from "@/util/effect-zod" -import { Log } from "@/util/log" -import { withStatics } from "@/util/schema" -import { Wildcard } from "@/util/wildcard" -import { Deferred, Effect, Layer, Schema, Context } from "effect" -import os from "os" -import { evaluate as evalRule } from "./evaluate" -import { PermissionID } from "./schema" - -export namespace Permission { - const log = Log.create({ service: "permission" }) - - export const Action = Schema.Literals(["allow", "deny", "ask"]) - .annotate({ identifier: "PermissionAction" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) - export type Action = Schema.Schema.Type - - export class Rule extends Schema.Class("PermissionRule")({ - permission: Schema.String, - pattern: Schema.String, - action: Action, - }) { - static readonly zod = zod(this) - } - - export const Ruleset = Schema.mutable(Schema.Array(Rule)) - .annotate({ identifier: "PermissionRuleset" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) - export type Ruleset = Schema.Schema.Type - - export class Request extends Schema.Class("PermissionRequest")({ - id: PermissionID, - sessionID: SessionID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.optional( - Schema.Struct({ - messageID: MessageID, - callID: Schema.String, - }), - ), - }) { - static readonly zod = zod(this) - } - - export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) - export type Reply = Schema.Schema.Type - - const reply = { - reply: Reply, - message: Schema.optional(Schema.String), - } - - export const ReplyBody = Schema.Struct(reply) - .annotate({ identifier: "PermissionReplyBody" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) - export type ReplyBody = Schema.Schema.Type - - export class Approval extends Schema.Class("PermissionApproval")({ - projectID: ProjectID, - patterns: Schema.Array(Schema.String), - }) { - static readonly zod = zod(this) - } - - export const Event = { - Asked: BusEvent.define("permission.asked", Request.zod), - Replied: BusEvent.define( - "permission.replied", - zod( - Schema.Struct({ - sessionID: SessionID, - requestID: PermissionID, - reply: Reply, - }), - ), - ), - } - - export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } - - export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { - feedback: Schema.String, - }) { - override get message() { - return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` - } - } - - export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { - ruleset: Schema.Any, - }) { - override get message() { - return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` - } - } - - export type Error = DeniedError | RejectedError | CorrectedError - - export const AskInput = Schema.Struct({ - ...Request.fields, - id: Schema.optional(PermissionID), - ruleset: Ruleset, - }) - .annotate({ identifier: "PermissionAskInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) - export type AskInput = Schema.Schema.Type - - export const ReplyInput = Schema.Struct({ - requestID: PermissionID, - ...reply, - }) - .annotate({ identifier: "PermissionReplyInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) - export type ReplyInput = Schema.Schema.Type - - export interface Interface { - readonly ask: (input: AskInput) => Effect.Effect - readonly reply: (input: ReplyInput) => Effect.Effect - readonly list: () => Effect.Effect> - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - interface State { - pending: Map - approved: Ruleset - } - - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) - return evalRule(permission, pattern, ...rulesets) - } - - export class Service extends Context.Service()("@opencode/Permission") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const state = yield* InstanceState.make( - Effect.fn("Permission.state")(function* (ctx) { - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), - ) - const state = { - pending: new Map(), - approved: row?.data ?? [], - } - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - for (const item of state.pending.values()) { - yield* Deferred.fail(item.deferred, new RejectedError()) - } - state.pending.clear() - }), - ) - - return state - }), - ) - - const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { - const { approved, pending } = yield* InstanceState.get(state) - const { ruleset, ...request } = input - let needsAsk = false - - for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, approved) - log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") { - return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), - }) - } - if (rule.action === "allow") continue - needsAsk = true - } - - if (!needsAsk) return - - const id = request.id ?? PermissionID.ascending() - const info = Schema.decodeUnknownSync(Request)({ - id, - ...request, - }) - log.info("asking", { id, permission: info.permission, patterns: info.patterns }) - - const deferred = yield* Deferred.make() - pending.set(id, { info, deferred }) - yield* bus.publish(Event.Asked, info) - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { - const { approved, pending } = yield* InstanceState.get(state) - const existing = pending.get(input.requestID) - if (!existing) return - - pending.delete(input.requestID) - yield* bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - reply: input.reply, - }) - - if (input.reply === "reject") { - yield* Deferred.fail( - existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), - ) - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - pending.delete(id) - yield* bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "reject", - }) - yield* Deferred.fail(item.deferred, new RejectedError()) - } - return - } - - yield* Deferred.succeed(existing.deferred, undefined) - if (input.reply === "once") return - - for (const pattern of existing.info.always) { - approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) - } - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - const ok = item.info.patterns.every( - (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", - ) - if (!ok) continue - pending.delete(id) - yield* bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "always", - }) - yield* Deferred.succeed(item.deferred, undefined) - } - }) - - const list = Effect.fn("Permission.list")(function* () { - const pending = (yield* InstanceState.get(state)).pending - return Array.from(pending.values(), (item) => item.info) - }) - - return Service.of({ ask, reply, list }) - }), - ) - - function expand(pattern: string): string { - if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) - if (pattern === "~") return os.homedir() - if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) - if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) - return pattern - } - - export function fromConfig(permission: Config.Permission) { - const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { - if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) - continue - } - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), - ) - } - return ruleset - } - - export function merge(...rulesets: Ruleset[]): Ruleset { - return rulesets.flat() - } - - const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] - - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - if (!rule) continue - if (rule.pattern === "*" && rule.action === "deny") result.add(tool) - } - return result - } - - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) -} +export * as Permission from "./permission" diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts new file mode 100644 index 0000000000..a5f6ded329 --- /dev/null +++ b/packages/opencode/src/permission/permission.ts @@ -0,0 +1,323 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config" +import { InstanceState } from "@/effect/instance-state" +import { ProjectID } from "@/project/schema" +import { MessageID, SessionID } from "@/session/schema" +import { PermissionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" +import { zod } from "@/util/effect-zod" +import { Log } from "@/util/log" +import { withStatics } from "@/util/schema" +import { Wildcard } from "@/util/wildcard" +import { Deferred, Effect, Layer, Schema, Context } from "effect" +import os from "os" +import { evaluate as evalRule } from "./evaluate" +import { PermissionID } from "./schema" + +const log = Log.create({ service: "permission" }) + +export const Action = Schema.Literals(["allow", "deny", "ask"]) + .annotate({ identifier: "PermissionAction" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Action = Schema.Schema.Type + +export class Rule extends Schema.Class("PermissionRule")({ + permission: Schema.String, + pattern: Schema.String, + action: Action, +}) { + static readonly zod = zod(this) +} + +export const Ruleset = Schema.mutable(Schema.Array(Rule)) + .annotate({ identifier: "PermissionRuleset" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Ruleset = Schema.Schema.Type + +export class Request extends Schema.Class("PermissionRequest")({ + id: PermissionID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional( + Schema.Struct({ + messageID: MessageID, + callID: Schema.String, + }), + ), +}) { + static readonly zod = zod(this) +} + +export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Reply = Schema.Schema.Type + +const reply = { + reply: Reply, + message: Schema.optional(Schema.String), +} + +export const ReplyBody = Schema.Struct(reply) + .annotate({ identifier: "PermissionReplyBody" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ReplyBody = Schema.Schema.Type + +export class Approval extends Schema.Class("PermissionApproval")({ + projectID: ProjectID, + patterns: Schema.Array(Schema.String), +}) { + static readonly zod = zod(this) +} + +export const Event = { + Asked: BusEvent.define("permission.asked", Request.zod), + Replied: BusEvent.define( + "permission.replied", + zod( + Schema.Struct({ + sessionID: SessionID, + requestID: PermissionID, + reply: Reply, + }), + ), + ), +} + +export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } +} + +export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, +}) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } +} + +export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, +}) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } +} + +export type Error = DeniedError | RejectedError | CorrectedError + +export const AskInput = Schema.Struct({ + ...Request.fields, + id: Schema.optional(PermissionID), + ruleset: Ruleset, +}) + .annotate({ identifier: "PermissionAskInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AskInput = Schema.Schema.Type + +export const ReplyInput = Schema.Struct({ + requestID: PermissionID, + ...reply, +}) + .annotate({ identifier: "PermissionReplyInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ReplyInput = Schema.Schema.Type + +export interface Interface { + readonly ask: (input: AskInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly list: () => Effect.Effect> +} + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +interface State { + pending: Map + approved: Ruleset +} + +export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) +} + +export class Service extends Context.Service()("@opencode/Permission") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = yield* InstanceState.make( + Effect.fn("Permission.state")(function* (ctx) { + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), + ) + const state = { + pending: new Map(), + approved: row?.data ?? [], + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { + const { approved, pending } = yield* InstanceState.get(state) + const { ruleset, ...request } = input + let needsAsk = false + + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, approved) + log.info("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + needsAsk = true + } + + if (!needsAsk) return + + const id = request.id ?? PermissionID.ascending() + const info = Schema.decodeUnknownSync(Request)({ + id, + ...request, + }) + log.info("asking", { id, permission: info.permission, patterns: info.patterns }) + + const deferred = yield* Deferred.make() + pending.set(id, { info, deferred }) + yield* bus.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { + const { approved, pending } = yield* InstanceState.get(state) + const existing = pending.get(input.requestID) + if (!existing) return + + pending.delete(input.requestID) + yield* bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + pending.delete(id) + yield* bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", + ) + if (!ok) continue + pending.delete(id) + yield* bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + }) + + const list = Effect.fn("Permission.list")(function* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (item) => item.info) + }) + + return Service.of({ ask, reply, list }) + }), +) + +function expand(pattern: string): string { + if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) + if (pattern === "~") return os.homedir() + if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + return pattern +} + +export function fromConfig(permission: Config.Permission) { + const ruleset: Ruleset = [] + for (const [key, value] of Object.entries(permission)) { + if (typeof value === "string") { + ruleset.push({ permission: key, action: value, pattern: "*" }) + continue + } + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ) + } + return ruleset +} + +export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() +} + +const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] + +export function disabled(tools: string[], ruleset: Ruleset): Set { + const result = new Set() + for (const tool of tools) { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + if (!rule) continue + if (rule.pattern === "*" && rule.action === "deny") result.add(tool) + } + return result +} + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) From 18538e359b22ff52231766b6880d70a9bdf9a063 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:17 -0400 Subject: [PATCH 25/75] feat: unwrap usession namespace to flat exports + barrel (#22713) --- packages/opencode/src/session/index.ts | 819 +------------------- packages/opencode/src/session/projectors.ts | 2 +- packages/opencode/src/session/session.ts | 816 +++++++++++++++++++ 3 files changed, 818 insertions(+), 819 deletions(-) create mode 100644 packages/opencode/src/session/session.ts diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 585b9a135d..1b79fd01a4 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,818 +1 @@ -import { Slug } from "@opencode-ai/shared/util/slug" -import path from "path" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Decimal } from "decimal.js" -import z from "zod" -import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { Flag } from "../flag/flag" -import { Installation } from "../installation" - -import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" -import { SyncEvent } from "../sync" -import type { SQL } from "../storage/db" -import { PartTable, SessionTable } from "./session.sql" -import { ProjectTable } from "../project/project.sql" -import { Storage } from "@/storage/storage" -import { Log } from "../util/log" -import { updateSchema } from "../util/update-schema" -import { MessageV2 } from "./message-v2" -import { Instance } from "../project/instance" -import { InstanceState } from "@/effect/instance-state" -import { Snapshot } from "@/snapshot" -import { ProjectID } from "../project/schema" -import { WorkspaceID } from "../control-plane/schema" -import { SessionID, MessageID, PartID } from "./schema" - -import type { Provider } from "@/provider" -import { Permission } from "@/permission" -import { Global } from "@/global" -import { Effect, Layer, Option, Context } from "effect" - -export namespace Session { - const log = Log.create({ service: "session" }) - - const parentTitlePrefix = "New session - " - const childTitlePrefix = "Child session - " - - function createDefaultTitle(isChild = false) { - return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() - } - - export function isDefaultTitle(title: string) { - return new RegExp( - `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, - ).test(title) - } - - type SessionRow = typeof SessionTable.$inferSelect - - export function fromRow(row: SessionRow): Info { - const summary = - row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null - ? { - additions: row.summary_additions ?? 0, - deletions: row.summary_deletions ?? 0, - files: row.summary_files ?? 0, - diffs: row.summary_diffs ?? undefined, - } - : undefined - const share = row.share_url ? { url: row.share_url } : undefined - const revert = row.revert ?? undefined - return { - id: row.id, - slug: row.slug, - projectID: row.project_id, - workspaceID: row.workspace_id ?? undefined, - directory: row.directory, - parentID: row.parent_id ?? undefined, - title: row.title, - version: row.version, - summary, - share, - revert, - permission: row.permission ?? undefined, - time: { - created: row.time_created, - updated: row.time_updated, - compacting: row.time_compacting ?? undefined, - archived: row.time_archived ?? undefined, - }, - } - } - - export function toRow(info: Info) { - return { - id: info.id, - project_id: info.projectID, - workspace_id: info.workspaceID, - parent_id: info.parentID, - slug: info.slug, - directory: info.directory, - title: info.title, - version: info.version, - share_url: info.share?.url, - summary_additions: info.summary?.additions, - summary_deletions: info.summary?.deletions, - summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, - revert: info.revert ?? null, - permission: info.permission, - time_created: info.time.created, - time_updated: info.time.updated, - time_compacting: info.time.compacting, - time_archived: info.time.archived, - } - } - - function getForkedTitle(title: string): string { - const match = title.match(/^(.+) \(fork #(\d+)\)$/) - if (match) { - const base = match[1] - const num = parseInt(match[2], 10) - return `${base} (fork #${num + 1})` - } - return `${title} (fork #1)` - } - - export const Info = z - .object({ - id: SessionID.zod, - slug: z.string(), - projectID: ProjectID.zod, - workspaceID: WorkspaceID.zod.optional(), - directory: z.string(), - parentID: SessionID.zod.optional(), - summary: z - .object({ - additions: z.number(), - deletions: z.number(), - files: z.number(), - diffs: Snapshot.FileDiff.array().optional(), - }) - .optional(), - share: z - .object({ - url: z.string(), - }) - .optional(), - title: z.string(), - version: z.string(), - time: z.object({ - created: z.number(), - updated: z.number(), - compacting: z.number().optional(), - archived: z.number().optional(), - }), - permission: Permission.Ruleset.zod.optional(), - revert: z - .object({ - messageID: MessageID.zod, - partID: PartID.zod.optional(), - snapshot: z.string().optional(), - diff: z.string().optional(), - }) - .optional(), - }) - .meta({ - ref: "Session", - }) - export type Info = z.output - - export const ProjectInfo = z - .object({ - id: ProjectID.zod, - name: z.string().optional(), - worktree: z.string(), - }) - .meta({ - ref: "ProjectSummary", - }) - export type ProjectInfo = z.output - - export const GlobalInfo = Info.extend({ - project: ProjectInfo.nullable(), - }).meta({ - ref: "GlobalSession", - }) - export type GlobalInfo = z.output - - export const CreateInput = z - .object({ - parentID: SessionID.zod.optional(), - title: z.string().optional(), - permission: Info.shape.permission, - workspaceID: WorkspaceID.zod.optional(), - }) - .optional() - export type CreateInput = z.output - - export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }) - export const GetInput = SessionID.zod - export const ChildrenInput = SessionID.zod - export const RemoveInput = SessionID.zod - export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) - export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) - export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }) - export const SetRevertInput = z.object({ - sessionID: SessionID.zod, - revert: Info.shape.revert, - summary: Info.shape.summary, - }) - export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) - - export const Event = { - Created: SyncEvent.define({ - type: "session.created", - version: 1, - aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), - }), - Updated: SyncEvent.define({ - type: "session.updated", - version: 1, - aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: updateSchema(Info).extend({ - share: updateSchema(Info.shape.share.unwrap()).optional(), - time: updateSchema(Info.shape.time).optional(), - }), - }), - busSchema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), - }), - Deleted: SyncEvent.define({ - type: "session.deleted", - version: 1, - aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), - }), - Diff: BusEvent.define( - "session.diff", - z.object({ - sessionID: SessionID.zod, - diff: Snapshot.FileDiff.array(), - }), - ), - Error: BusEvent.define( - "session.error", - z.object({ - sessionID: SessionID.zod.optional(), - error: MessageV2.Assistant.shape.error, - }), - ), - } - - export function plan(input: { slug: string; time: { created: number } }) { - const base = Instance.project.vcs - ? path.join(Instance.worktree, ".opencode", "plans") - : path.join(Global.Path.data, "plans") - return path.join(base, [input.time.created, input.slug].join("-") + ".md") - } - - export const getUsage = (input: { - model: Provider.Model - usage: LanguageModelUsage - metadata?: ProviderMetadata - }) => { - const safe = (value: number) => { - if (!Number.isFinite(value)) return 0 - return value - } - const inputTokens = safe(input.usage.inputTokens ?? 0) - const outputTokens = safe(input.usage.outputTokens ?? 0) - const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) - - const cacheReadInputTokens = safe( - input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, - ) - const cacheWriteInputTokens = safe( - (input.usage.inputTokenDetails?.cacheWriteTokens ?? - input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // google-vertex-anthropic returns metadata under "vertex" key - // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') - input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - // @ts-expect-error - input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? - 0) as number, - ) - - // AI SDK v6 normalized inputTokens to include cached tokens across all providers - // (including Anthropic/Bedrock which previously excluded them). Always subtract cache - // tokens to get the non-cached input count for separate cost calculation. - const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) - - const total = input.usage.totalTokens - - const tokens = { - total, - input: adjustedInputTokens, - output: safe(outputTokens - reasoningTokens), - reasoning: reasoningTokens, - cache: { - write: cacheWriteInputTokens, - read: cacheReadInputTokens, - }, - } - - const costInfo = - input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 - ? input.model.cost.experimentalOver200K - : input.model.cost - return { - cost: safe( - new Decimal(0) - .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) - .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) - // TODO: update models.dev to have better pricing model, for now: - // charge reasoning tokens at the same rate as output tokens - .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) - .toNumber(), - ), - tokens, - } - } - - export class BusyError extends Error { - constructor(public readonly sessionID: string) { - super(`Session ${sessionID} is busy`) - } - } - - export interface Interface { - readonly create: (input?: { - parentID?: SessionID - title?: string - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect - readonly touch: (sessionID: SessionID) => Effect.Effect - readonly get: (id: SessionID) => Effect.Effect - readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect - readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect - readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect - readonly setRevert: (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) => Effect.Effect - readonly clearRevert: (sessionID: SessionID) => Effect.Effect - readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect - readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect - readonly children: (parentID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect - readonly updateMessage: (msg: T) => Effect.Effect - readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly removePart: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) => Effect.Effect - readonly getPart: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) => Effect.Effect - readonly updatePart: (part: T) => Effect.Effect - readonly updatePartDelta: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) => Effect.Effect - /** Finds the first message matching the predicate, searching newest-first. */ - readonly findMessage: ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) => Effect.Effect> - } - - export class Service extends Context.Service()("@opencode/Session") {} - - type Patch = z.infer["info"] - - const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const storage = yield* Storage.Service - - const createNext = Effect.fn("Session.createNext")(function* (input: { - id?: SessionID - title?: string - parentID?: SessionID - workspaceID?: WorkspaceID - directory: string - permission?: Permission.Ruleset - }) { - const ctx = yield* InstanceState.context - const result: Info = { - id: SessionID.descending(input.id), - slug: Slug.create(), - version: Installation.VERSION, - projectID: ctx.project.id, - directory: input.directory, - workspaceID: input.workspaceID, - parentID: input.parentID, - title: input.title ?? createDefaultTitle(!!input.parentID), - permission: input.permission, - time: { - created: Date.now(), - updated: Date.now(), - }, - } - log.info("created", result) - - yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) - - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // This only exist for backwards compatibility. We should not be - // manually publishing this event; it is a sync event now - yield* bus.publish(Event.Updated, { - sessionID: result.id, - info: result, - }) - } - - return result - }) - - const get = Effect.fn("Session.get")(function* (id: SessionID) { - const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) - return fromRow(row) - }) - - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const rows = yield* db((d) => - d - .select() - .from(SessionTable) - .where(and(eq(SessionTable.parent_id, parentID))) - .all(), - ) - return rows.map(fromRow) - }) - - const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { - try { - const session = yield* get(sessionID) - const kids = yield* children(sessionID) - for (const child of kids) { - yield* remove(child.id) - } - - // `remove` needs to work in all cases, such as a broken - // sessions that run cleanup. In certain cases these will - // run without any instance state, so we need to turn off - // publishing of events in that case - const hasInstance = yield* InstanceState.directory.pipe( - Effect.as(true), - Effect.catchCause(() => Effect.succeed(false)), - ) - - yield* Effect.sync(() => { - SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - SyncEvent.remove(sessionID) - }) - } catch (e) { - log.error(e) - } - }) - - const updateMessage = (msg: T): Effect.Effect => - Effect.gen(function* () { - yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })) - return msg - }).pipe(Effect.withSpan("Session.updateMessage")) - - const updatePart = (part: T): Effect.Effect => - Effect.gen(function* () { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }), - ) - return part - }).pipe(Effect.withSpan("Session.updatePart")) - - const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { - const row = Database.use((db) => - db - .select() - .from(PartTable) - .where( - and( - eq(PartTable.session_id, input.sessionID), - eq(PartTable.message_id, input.messageID), - eq(PartTable.id, input.partID), - ), - ) - .get(), - ) - if (!row) return - return { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - }) - - const create = Effect.fn("Session.create")(function* (input?: { - parentID?: SessionID - title?: string - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) { - const directory = yield* InstanceState.directory - return yield* createNext({ - parentID: input?.parentID, - directory, - title: input?.title, - permission: input?.permission, - workspaceID: input?.workspaceID, - }) - }) - - const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const directory = yield* InstanceState.directory - const original = yield* get(input.sessionID) - const title = getForkedTitle(original.title) - const session = yield* createNext({ - directory, - workspaceID: original.workspaceID, - title, - }) - const msgs = yield* messages({ sessionID: input.sessionID }) - const idMap = new Map() - - for (const msg of msgs) { - if (input.messageID && msg.info.id >= input.messageID) break - const newID = MessageID.ascending() - idMap.set(msg.info.id, newID) - - const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined - const cloned = yield* updateMessage({ - ...msg.info, - sessionID: session.id, - id: newID, - ...(parentID && { parentID }), - }) - - for (const part of msg.parts) { - yield* updatePart({ - ...part, - id: PartID.ascending(), - messageID: cloned.id, - sessionID: session.id, - }) - } - } - return session - }) - - const patch = (sessionID: SessionID, info: Patch) => - Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info })) - - const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() } }) - }) - - const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { - yield* patch(input.sessionID, { title: input.title }) - }) - - const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { - yield* patch(input.sessionID, { time: { archived: input.time } }) - }) - - const setPermission = Effect.fn("Session.setPermission")(function* (input: { - sessionID: SessionID - permission: Permission.Ruleset - }) { - yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) - }) - - const setRevert = Effect.fn("Session.setRevert")(function* (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) - }) - - const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) - }) - - const setSummary = Effect.fn("Session.setSummary")(function* (input: { - sessionID: SessionID - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) - }) - - const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* storage - .read(["session_diff", sessionID]) - .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) - }) - - const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { - if (input.limit) { - return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items - } - return Array.from(MessageV2.stream(input.sessionID)).reverse() - }) - - const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { - sessionID: SessionID - messageID: MessageID - }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }), - ) - return input.messageID - }) - - const removePart = Effect.fn("Session.removePart")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }), - ) - return input.partID - }) - - const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) { - yield* bus.publish(MessageV2.Event.PartDelta, input) - }) - - /** Finds the first message matching the predicate, searching newest-first. */ - const findMessage = Effect.fn("Session.findMessage")(function* ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) { - for (const item of MessageV2.stream(sessionID)) { - if (predicate(item)) return Option.some(item) - } - return Option.none() - }) - - return Service.of({ - create, - fork, - touch, - get, - setTitle, - setArchived, - setPermission, - setRevert, - clearRevert, - setSummary, - diff, - messages, - children, - remove, - updateMessage, - removeMessage, - removePart, - updatePart, - getPart, - updatePartDelta, - findMessage, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) - - export function* list(input?: { - directory?: string - workspaceID?: WorkspaceID - roots?: boolean - start?: number - search?: string - limit?: number - }) { - const project = Instance.project - const conditions = [eq(SessionTable.project_id, project.id)] - - if (input?.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - } - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - - const limit = input?.limit ?? 100 - - const rows = Database.use((db) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all(), - ) - for (const row of rows) { - yield fromRow(row) - } - } - - export function* listGlobal(input?: { - directory?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean - }) { - const conditions: SQL[] = [] - - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.cursor) { - conditions.push(lt(SessionTable.time_updated, input.cursor)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - if (!input?.archived) { - conditions.push(isNull(SessionTable.time_archived)) - } - - const limit = input?.limit ?? 100 - - const rows = Database.use((db) => { - const query = - conditions.length > 0 - ? db - .select() - .from(SessionTable) - .where(and(...conditions)) - : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() - }) - - const ids = [...new Set(rows.map((row) => row.project_id))] - const projects = new Map() - - if (ids.length > 0) { - const items = Database.use((db) => - db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) - .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) - .all(), - ) - for (const item of items) { - projects.set(item.id, { - id: item.id, - name: item.name ?? undefined, - worktree: item.worktree, - }) - } - } - - for (const row of rows) { - const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } - } - } -} +export * as Session from "./session" diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a1b2e401d0..bc083105c2 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,6 +1,6 @@ import { NotFoundError, eq, and } from "../storage/db" import { SyncEvent } from "@/sync" -import { Session } from "./index" +import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { Log } from "../util/log" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts new file mode 100644 index 0000000000..369a2085ff --- /dev/null +++ b/packages/opencode/src/session/session.ts @@ -0,0 +1,816 @@ +import { Slug } from "@opencode-ai/shared/util/slug" +import path from "path" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Decimal } from "decimal.js" +import z from "zod" +import { type ProviderMetadata, type LanguageModelUsage } from "ai" +import { Flag } from "../flag/flag" +import { Installation } from "../installation" + +import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { SyncEvent } from "../sync" +import type { SQL } from "../storage/db" +import { PartTable, SessionTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" +import { Storage } from "@/storage/storage" +import { Log } from "../util/log" +import { updateSchema } from "../util/update-schema" +import { MessageV2 } from "./message-v2" +import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" +import { Snapshot } from "@/snapshot" +import { ProjectID } from "../project/schema" +import { WorkspaceID } from "../control-plane/schema" +import { SessionID, MessageID, PartID } from "./schema" + +import type { Provider } from "@/provider" +import { Permission } from "@/permission" +import { Global } from "@/global" +import { Effect, Layer, Option, Context } from "effect" + +const log = Log.create({ service: "session" }) + +const parentTitlePrefix = "New session - " +const childTitlePrefix = "Child session - " + +function createDefaultTitle(isChild = false) { + return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() +} + +export function isDefaultTitle(title: string) { + return new RegExp( + `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, + ).test(title) +} + +type SessionRow = typeof SessionTable.$inferSelect + +export function fromRow(row: SessionRow): Info { + const summary = + row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null + ? { + additions: row.summary_additions ?? 0, + deletions: row.summary_deletions ?? 0, + files: row.summary_files ?? 0, + diffs: row.summary_diffs ?? undefined, + } + : undefined + const share = row.share_url ? { url: row.share_url } : undefined + const revert = row.revert ?? undefined + return { + id: row.id, + slug: row.slug, + projectID: row.project_id, + workspaceID: row.workspace_id ?? undefined, + directory: row.directory, + parentID: row.parent_id ?? undefined, + title: row.title, + version: row.version, + summary, + share, + revert, + permission: row.permission ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + compacting: row.time_compacting ?? undefined, + archived: row.time_archived ?? undefined, + }, + } +} + +export function toRow(info: Info) { + return { + id: info.id, + project_id: info.projectID, + workspace_id: info.workspaceID, + parent_id: info.parentID, + slug: info.slug, + directory: info.directory, + title: info.title, + version: info.version, + share_url: info.share?.url, + summary_additions: info.summary?.additions, + summary_deletions: info.summary?.deletions, + summary_files: info.summary?.files, + summary_diffs: info.summary?.diffs, + revert: info.revert ?? null, + permission: info.permission, + time_created: info.time.created, + time_updated: info.time.updated, + time_compacting: info.time.compacting, + time_archived: info.time.archived, + } +} + +function getForkedTitle(title: string): string { + const match = title.match(/^(.+) \(fork #(\d+)\)$/) + if (match) { + const base = match[1] + const num = parseInt(match[2], 10) + return `${base} (fork #${num + 1})` + } + return `${title} (fork #1)` +} + +export const Info = z + .object({ + id: SessionID.zod, + slug: z.string(), + projectID: ProjectID.zod, + workspaceID: WorkspaceID.zod.optional(), + directory: z.string(), + parentID: SessionID.zod.optional(), + summary: z + .object({ + additions: z.number(), + deletions: z.number(), + files: z.number(), + diffs: Snapshot.FileDiff.array().optional(), + }) + .optional(), + share: z + .object({ + url: z.string(), + }) + .optional(), + title: z.string(), + version: z.string(), + time: z.object({ + created: z.number(), + updated: z.number(), + compacting: z.number().optional(), + archived: z.number().optional(), + }), + permission: Permission.Ruleset.zod.optional(), + revert: z + .object({ + messageID: MessageID.zod, + partID: PartID.zod.optional(), + snapshot: z.string().optional(), + diff: z.string().optional(), + }) + .optional(), + }) + .meta({ + ref: "Session", + }) +export type Info = z.output + +export const ProjectInfo = z + .object({ + id: ProjectID.zod, + name: z.string().optional(), + worktree: z.string(), + }) + .meta({ + ref: "ProjectSummary", + }) +export type ProjectInfo = z.output + +export const GlobalInfo = Info.extend({ + project: ProjectInfo.nullable(), +}).meta({ + ref: "GlobalSession", +}) +export type GlobalInfo = z.output + +export const CreateInput = z + .object({ + parentID: SessionID.zod.optional(), + title: z.string().optional(), + permission: Info.shape.permission, + workspaceID: WorkspaceID.zod.optional(), + }) + .optional() +export type CreateInput = z.output + +export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }) +export const GetInput = SessionID.zod +export const ChildrenInput = SessionID.zod +export const RemoveInput = SessionID.zod +export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) +export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) +export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }) +export const SetRevertInput = z.object({ + sessionID: SessionID.zod, + revert: Info.shape.revert, + summary: Info.shape.summary, +}) +export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) + +export const Event = { + Created: SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: Info, + }), + }), + Updated: SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: updateSchema(Info).extend({ + share: updateSchema(Info.shape.share.unwrap()).optional(), + time: updateSchema(Info.shape.time).optional(), + }), + }), + busSchema: z.object({ + sessionID: SessionID.zod, + info: Info, + }), + }), + Deleted: SyncEvent.define({ + type: "session.deleted", + version: 1, + aggregate: "sessionID", + schema: z.object({ + sessionID: SessionID.zod, + info: Info, + }), + }), + Diff: BusEvent.define( + "session.diff", + z.object({ + sessionID: SessionID.zod, + diff: Snapshot.FileDiff.array(), + }), + ), + Error: BusEvent.define( + "session.error", + z.object({ + sessionID: SessionID.zod.optional(), + error: MessageV2.Assistant.shape.error, + }), + ), +} + +export function plan(input: { slug: string; time: { created: number } }) { + const base = Instance.project.vcs + ? path.join(Instance.worktree, ".opencode", "plans") + : path.join(Global.Path.data, "plans") + return path.join(base, [input.time.created, input.slug].join("-") + ".md") +} + +export const getUsage = (input: { + model: Provider.Model + usage: LanguageModelUsage + metadata?: ProviderMetadata +}) => { + const safe = (value: number) => { + if (!Number.isFinite(value)) return 0 + return value + } + const inputTokens = safe(input.usage.inputTokens ?? 0) + const outputTokens = safe(input.usage.outputTokens ?? 0) + const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) + + const cacheReadInputTokens = safe( + input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, + ) + const cacheWriteInputTokens = safe( + (input.usage.inputTokenDetails?.cacheWriteTokens ?? + input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + // google-vertex-anthropic returns metadata under "vertex" key + // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') + input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? + // @ts-expect-error + input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? + // @ts-expect-error + input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + 0) as number, + ) + + // AI SDK v6 normalized inputTokens to include cached tokens across all providers + // (including Anthropic/Bedrock which previously excluded them). Always subtract cache + // tokens to get the non-cached input count for separate cost calculation. + const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) + + const total = input.usage.totalTokens + + const tokens = { + total, + input: adjustedInputTokens, + output: safe(outputTokens - reasoningTokens), + reasoning: reasoningTokens, + cache: { + write: cacheWriteInputTokens, + read: cacheReadInputTokens, + }, + } + + const costInfo = + input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + ? input.model.cost.experimentalOver200K + : input.model.cost + return { + cost: safe( + new Decimal(0) + .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) + .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) + // TODO: update models.dev to have better pricing model, for now: + // charge reasoning tokens at the same rate as output tokens + .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) + .toNumber(), + ), + tokens, + } +} + +export class BusyError extends Error { + constructor(public readonly sessionID: string) { + super(`Session ${sessionID} is busy`) + } +} + +export interface Interface { + readonly create: (input?: { + parentID?: SessionID + title?: string + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly touch: (sessionID: SessionID) => Effect.Effect + readonly get: (id: SessionID) => Effect.Effect + readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect + readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect + readonly setRevert: (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) => Effect.Effect + readonly clearRevert: (sessionID: SessionID) => Effect.Effect + readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect + readonly diff: (sessionID: SessionID) => Effect.Effect + readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly children: (parentID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect + readonly updateMessage: (msg: T) => Effect.Effect + readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly removePart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect + readonly updatePart: (part: T) => Effect.Effect + readonly updatePartDelta: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) => Effect.Effect + /** Finds the first message matching the predicate, searching newest-first. */ + readonly findMessage: ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/Session") {} + +type Patch = z.infer["info"] + +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const storage = yield* Storage.Service + + const createNext = Effect.fn("Session.createNext")(function* (input: { + id?: SessionID + title?: string + parentID?: SessionID + workspaceID?: WorkspaceID + directory: string + permission?: Permission.Ruleset + }) { + const ctx = yield* InstanceState.context + const result: Info = { + id: SessionID.descending(input.id), + slug: Slug.create(), + version: Installation.VERSION, + projectID: ctx.project.id, + directory: input.directory, + workspaceID: input.workspaceID, + parentID: input.parentID, + title: input.title ?? createDefaultTitle(!!input.parentID), + permission: input.permission, + time: { + created: Date.now(), + updated: Date.now(), + }, + } + log.info("created", result) + + yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) + + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // This only exist for backwards compatibility. We should not be + // manually publishing this event; it is a sync event now + yield* bus.publish(Event.Updated, { + sessionID: result.id, + info: result, + }) + } + + return result + }) + + const get = Effect.fn("Session.get")(function* (id: SessionID) { + const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) + return fromRow(row) + }) + + const children = Effect.fn("Session.children")(function* (parentID: SessionID) { + const rows = yield* db((d) => + d + .select() + .from(SessionTable) + .where(and(eq(SessionTable.parent_id, parentID))) + .all(), + ) + return rows.map(fromRow) + }) + + const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { + try { + const session = yield* get(sessionID) + const kids = yield* children(sessionID) + for (const child of kids) { + yield* remove(child.id) + } + + // `remove` needs to work in all cases, such as a broken + // sessions that run cleanup. In certain cases these will + // run without any instance state, so we need to turn off + // publishing of events in that case + const hasInstance = yield* InstanceState.directory.pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ) + + yield* Effect.sync(() => { + SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + SyncEvent.remove(sessionID) + }) + } catch (e) { + log.error(e) + } + }) + + const updateMessage = (msg: T): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })) + return msg + }).pipe(Effect.withSpan("Session.updateMessage")) + + const updatePart = (part: T): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => + SyncEvent.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }), + ) + return part + }).pipe(Effect.withSpan("Session.updatePart")) + + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + + const create = Effect.fn("Session.create")(function* (input?: { + parentID?: SessionID + title?: string + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) { + const directory = yield* InstanceState.directory + return yield* createNext({ + parentID: input?.parentID, + directory, + title: input?.title, + permission: input?.permission, + workspaceID: input?.workspaceID, + }) + }) + + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const directory = yield* InstanceState.directory + const original = yield* get(input.sessionID) + const title = getForkedTitle(original.title) + const session = yield* createNext({ + directory, + workspaceID: original.workspaceID, + title, + }) + const msgs = yield* messages({ sessionID: input.sessionID }) + const idMap = new Map() + + for (const msg of msgs) { + if (input.messageID && msg.info.id >= input.messageID) break + const newID = MessageID.ascending() + idMap.set(msg.info.id, newID) + + const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined + const cloned = yield* updateMessage({ + ...msg.info, + sessionID: session.id, + id: newID, + ...(parentID && { parentID }), + }) + + for (const part of msg.parts) { + yield* updatePart({ + ...part, + id: PartID.ascending(), + messageID: cloned.id, + sessionID: session.id, + }) + } + } + return session + }) + + const patch = (sessionID: SessionID, info: Patch) => + Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info })) + + const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() } }) + }) + + const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { + yield* patch(input.sessionID, { title: input.title }) + }) + + const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { + yield* patch(input.sessionID, { time: { archived: input.time } }) + }) + + const setPermission = Effect.fn("Session.setPermission")(function* (input: { + sessionID: SessionID + permission: Permission.Ruleset + }) { + yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) + }) + + const setRevert = Effect.fn("Session.setRevert")(function* (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) + }) + + const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) + }) + + const setSummary = Effect.fn("Session.setSummary")(function* (input: { + sessionID: SessionID + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) + }) + + const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { + return yield* storage + .read(["session_diff", sessionID]) + .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + }) + + const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { + if (input.limit) { + return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items + } + return Array.from(MessageV2.stream(input.sessionID)).reverse() + }) + + const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + yield* Effect.sync(() => + SyncEvent.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, + }), + ) + return input.messageID + }) + + const removePart = Effect.fn("Session.removePart")(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) { + yield* Effect.sync(() => + SyncEvent.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }), + ) + return input.partID + }) + + const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) { + yield* bus.publish(MessageV2.Event.PartDelta, input) + }) + + /** Finds the first message matching the predicate, searching newest-first. */ + const findMessage = Effect.fn("Session.findMessage")(function* ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) { + for (const item of MessageV2.stream(sessionID)) { + if (predicate(item)) return Option.some(item) + } + return Option.none() + }) + + return Service.of({ + create, + fork, + touch, + get, + setTitle, + setArchived, + setPermission, + setRevert, + clearRevert, + setSummary, + diff, + messages, + children, + remove, + updateMessage, + removeMessage, + removePart, + updatePart, + getPart, + updatePartDelta, + findMessage, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) + +export function* list(input?: { + directory?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + limit?: number +}) { + const project = Instance.project + const conditions = [eq(SessionTable.project_id, project.id)] + + if (input?.workspaceID) { + conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + } + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => + db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all(), + ) + for (const row of rows) { + yield fromRow(row) + } +} + +export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean +}) { + const conditions: SQL[] = [] + + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + if (!input?.archived) { + conditions.push(isNull(SessionTable.time_archived)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + for (const row of rows) { + const project = projects.get(row.project_id) ?? null + yield { ...fromRow(row), project } + } +} From 5ae91aa81047d3fa7e50e9e2d260835f100409c7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:19 -0400 Subject: [PATCH 26/75] feat: unwrap uplugin namespace to flat exports + barrel (#22711) --- packages/opencode/src/plugin/index.ts | 290 +----------------- packages/opencode/src/plugin/plugin.ts | 287 +++++++++++++++++ .../test/plugin/auth-override.test.ts | 2 +- 3 files changed, 289 insertions(+), 290 deletions(-) create mode 100644 packages/opencode/src/plugin/plugin.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f31e0b9ff2..20f38c41c2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,289 +1 @@ -import type { - Hooks, - PluginInput, - Plugin as PluginInstance, - PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, -} from "@opencode-ai/plugin" -import { Config } from "../config" -import { Bus } from "../bus" -import { Log } from "../util/log" -import { createOpencodeClient } from "@opencode-ai/sdk" -import { Flag } from "../flag/flag" -import { CodexAuthPlugin } from "./codex" -import { Session } from "../session" -import { NamedError } from "@opencode-ai/shared/util/error" -import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" -import { PoeAuthPlugin } from "opencode-poe-auth" -import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { InstanceState } from "@/effect/instance-state" -import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" -import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" - -export namespace Plugin { - const log = Log.create({ service: "plugin" }) - - type State = { - hooks: Hooks[] - } - - // Hook names that follow the (input, output) => Promise trigger pattern - type TriggerName = { - [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never - }[keyof Hooks] - - export interface Interface { - readonly trigger: < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >( - name: Name, - input: Input, - output: Output, - ) => Effect.Effect - readonly list: () => Effect.Effect - readonly init: () => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Plugin") {} - - // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [ - CodexAuthPlugin, - CopilotAuthPlugin, - GitlabAuthPlugin, - PoeAuthPlugin, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - ] - - function isServerPlugin(value: unknown): value is PluginInstance { - return typeof value === "function" - } - - function getServerPlugin(value: unknown) { - if (isServerPlugin(value)) return value - if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin(value.server)) return - return value.server - } - - function getLegacyPlugins(mod: Record) { - const seen = new Set() - const result: PluginInstance[] = [] - - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") - result.push(plugin) - } - - return result - } - - async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") - if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - hooks.push(await (plugin as PluginModule).server(input, load.options)) - return - } - - for (const server of getLegacyPlugins(load.mod)) { - hooks.push(await server(input, load.options)) - } - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("Plugin.state")(function* (ctx) { - const hooks: Hooks[] = [] - const bridge = yield* EffectBridge.make() - - function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) - } - - const { Server } = yield* Effect.promise(() => import("../server/server")) - - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: ctx.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), - }) - const cfg = yield* config.get() - const input: PluginInput = { - client, - project: ctx.project, - worktree: ctx.worktree, - directory: ctx.directory, - experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) - }, - }, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, - } - - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = yield* Effect.tryPromise({ - try: () => plugin(input), - catch: (err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) - }, - }).pipe(Effect.option) - if (init._tag === "Some") hooks.push(init.value) - } - - const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) - } - if (plugins.length) yield* config.waitForDependencies() - - const loaded = yield* Effect.promise(() => - PluginLoader.loadExternal({ - items: plugins, - kind: "server", - report: { - start(candidate) { - log.info("loading plugin", { path: candidate.plan.spec }) - }, - missing(candidate, _retry, message) { - log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) - }, - error(candidate, _retry, stage, error, resolved) { - const spec = candidate.plan.spec - const cause = error instanceof Error ? (error.cause ?? error) : error - const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - - if (stage === "install") { - const parsed = parsePluginSpecifier(spec) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) - return - } - - if (stage === "compatibility") { - log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(`Plugin ${spec} skipped: ${message}`) - return - } - - if (stage === "entry") { - log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - return - } - - log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - }, - }, - }), - ) - for (const load of loaded) { - if (!load) continue - - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks), - catch: (err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) - return message - }, - }).pipe( - Effect.catch(() => { - // TODO: make proper events for this - // bus.publish(Session.Event.Error, { - // error: new NamedError.Unknown({ - // message: `Failed to load plugin ${load.spec}: ${message}`, - // }).toObject(), - // }) - return Effect.void - }), - ) - } - - // Notify plugins of current config - for (const hook of hooks) { - yield* Effect.tryPromise({ - try: () => Promise.resolve((hook as any).config?.(cfg)), - catch: (err) => { - log.error("plugin config hook failed", { error: err }) - }, - }).pipe(Effect.ignore) - } - - // Subscribe to bus events, fiber interrupted when scope closes - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - - return { hooks } - }), - ) - - const trigger = Effect.fn("Plugin.trigger")(function* < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >(name: Name, input: Input, output: Output) { - if (!name) return output - const s = yield* InstanceState.get(state) - for (const hook of s.hooks) { - const fn = hook[name] as any - if (!fn) continue - yield* Effect.promise(async () => fn(input, output)) - } - return output - }) - - const list = Effect.fn("Plugin.list")(function* () { - const s = yield* InstanceState.get(state) - return s.hooks - }) - - const init = Effect.fn("Plugin.init")(function* () { - yield* InstanceState.get(state) - }) - - return Service.of({ trigger, list, init }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) -} +export * as Plugin from "./plugin" diff --git a/packages/opencode/src/plugin/plugin.ts b/packages/opencode/src/plugin/plugin.ts new file mode 100644 index 0000000000..537794138a --- /dev/null +++ b/packages/opencode/src/plugin/plugin.ts @@ -0,0 +1,287 @@ +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, +} from "@opencode-ai/plugin" +import { Config } from "../config" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" +import { Session } from "../session" +import { NamedError } from "@opencode-ai/shared/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" + +const log = Log.create({ service: "plugin" }) + +type State = { + hooks: Hooks[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +// Built-in plugins that are directly imported (not installed from npm) +const INTERNAL_PLUGINS: PluginInstance[] = [ + CodexAuthPlugin, + CopilotAuthPlugin, + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, +] + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) throw new TypeError("Plugin export is not a function") + result.push(plugin) + } + + return result +} + +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + hooks.push(await (plugin as PluginModule).server(input, load.options)) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + hooks.push(await server(input, load.options)) + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: ctx.directory, + headers: Flag.OPENCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => (await Server.Default()).app.fetch(...args), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: (err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }, + }).pipe(Effect.option) + if (init._tag === "Some") hooks.push(init.value) + } + + const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks), + catch: (err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + return message + }, + }).pipe( + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: (err) => { + log.error("plugin config hook failed", { error: err }) + }, + }).pipe(Effect.ignore) + } + + // Subscribe to bus events, fiber interrupted when scope closes + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const hook of hooks) { + hook["event"]?.({ event: input as any }) + } + }), + ), + Effect.forkScoped, + ) + + return { hooks } + }), + ) + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + for (const hook of s.hooks) { + const fn = hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + }) + + return Service.of({ trigger, list, init }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 36a02058ea..0c619c2edc 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -63,7 +63,7 @@ describe("plugin.auth-override", () => { }, 30000) // Increased timeout for plugin installation }) -const file = path.join(import.meta.dir, "../../src/plugin/index.ts") +const file = path.join(import.meta.dir, "../../src/plugin/plugin.ts") describe("plugin.config-hook-error-isolation", () => { test("config hooks are individually error-isolated in the layer factory", async () => { From d7a072dd464e09a3b5ef21943cf126f374884c4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:20 -0400 Subject: [PATCH 27/75] feat: unwrap usnapshot namespace to flat exports + barrel (#22715) --- packages/opencode/src/snapshot/index.ts | 780 +-------------------- packages/opencode/src/snapshot/snapshot.ts | 777 ++++++++++++++++++++ 2 files changed, 778 insertions(+), 779 deletions(-) create mode 100644 packages/opencode/src/snapshot/snapshot.ts diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 83963e3511..49eafe4450 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,779 +1 @@ -import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { formatPatch, structuredPatch } from "diff" -import path from "path" -import z from "zod" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Hash } from "@opencode-ai/shared/util/hash" -import { Config } from "../config" -import { Global } from "../global" -import { Log } from "../util/log" - -export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer - - export const FileDiff = z - .object({ - file: z.string(), - patch: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "SnapshotFileDiff", - }) - export type FileDiff = z.infer - - const log = Log.create({ service: "snapshot" }) - const prune = "7.days" - const limit = 2 * 1024 * 1024 - const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] - const cfg = ["-c", "core.autocrlf=false", ...core] - const quote = [...cfg, "-c", "core.quotepath=false"] - interface GitResult { - readonly code: ChildProcessSpawner.ExitCode - readonly text: string - readonly stderr: string - } - - type State = Omit - - export interface Interface { - readonly init: () => Effect.Effect - readonly cleanup: () => Effect.Effect - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Snapshot") {} - - export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service - > = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const config = yield* Config.Service - const locks = new Map() - - const lock = (key: string) => { - const hit = locks.get(key) - if (hit) return hit - - const next = Semaphore.makeUnsafe(1) - locks.set(key, next) - return next - } - - const state = yield* InstanceState.make( - Effect.fn("Snapshot.state")(function* (ctx) { - const state = { - directory: ctx.directory, - worktree: ctx.worktree, - gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), - vcs: ctx.project.vcs, - } - - const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - - const enc = new TextEncoder() - const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) - - const git = Effect.fnUntraced( - function* ( - cmd: string[], - opts?: { cwd?: string; env?: Record; stdin?: ChildProcess.CommandInput }, - ) { - const proc = ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - stdin: opts?.stdin, - }) - const handle = yield* spawner.spawn(proc) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ) - - const ignore = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return new Set() - const check = yield* git( - [ - ...quote, - "--git-dir", - path.join(state.worktree, ".git"), - "--work-tree", - state.worktree, - "check-ignore", - "--no-index", - "--stdin", - "-z", - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (check.code !== 0 && check.code !== 1) return new Set() - return new Set(check.text.split("\0").filter(Boolean)) - }) - - const drop = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - yield* git( - [ - ...cfg, - ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - }) - - const stage = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - const result = yield* git( - [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (result.code === 0) return - log.warn("failed to add snapshot files", { - exitCode: result.code, - stderr: result.stderr, - }) - }) - - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) - - const enabled = Effect.fnUntraced(function* () { - if (state.vcs !== "git") return false - return (yield* config.get()).snapshot !== false - }) - - const excludes = Effect.fnUntraced(function* () { - const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: state.worktree, - }) - const file = result.text.trim() - if (!file) return - if (!(yield* exists(file))) return - return file - }) - - const sync = Effect.fnUntraced(function* (list: string[] = []) { - const file = yield* excludes() - const target = path.join(state.gitdir, "info", "exclude") - const text = [ - file ? (yield* read(file)).trimEnd() : "", - ...list.map((item) => `/${item.replaceAll("\\", "/")}`), - ] - .filter(Boolean) - .join("\n") - yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) - yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) - }) - - const add = Effect.fnUntraced(function* () { - yield* sync() - const [diff, other] = yield* Effect.all( - [ - git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { - cwd: state.directory, - }), - git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { - cwd: state.directory, - }), - ], - { concurrency: 2 }, - ) - if (diff.code !== 0 || other.code !== 0) { - log.warn("failed to list snapshot files", { - diffCode: diff.code, - diffStderr: diff.stderr, - otherCode: other.code, - otherStderr: other.stderr, - }) - return - } - - const tracked = diff.text.split("\0").filter(Boolean) - const untracked = other.text.split("\0").filter(Boolean) - const all = Array.from(new Set([...tracked, ...untracked])) - if (!all.length) return - - // Resolve source-repo ignore rules against the exact candidate set. - // --no-index keeps this pattern-based even when a path is already tracked. - const ignored = yield* ignore(all) - - // Remove newly-ignored files from snapshot index to prevent re-adding - if (ignored.size > 0) { - const ignoredFiles = Array.from(ignored) - log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) - yield* drop(ignoredFiles) - } - - const allow = all.filter((item) => !ignored.has(item)) - if (!allow.length) return - - const large = new Set( - (yield* Effect.all( - allow.map((item) => - fs - .stat(path.join(state.directory, item)) - .pipe(Effect.catch(() => Effect.void)) - .pipe( - Effect.map((stat) => { - if (!stat || stat.type !== "File") return - const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size - return size > limit ? item : undefined - }), - ), - ), - { concurrency: 8 }, - )).filter((item): item is string => Boolean(item)), - ) - const block = new Set(untracked.filter((item) => large.has(item))) - yield* sync(Array.from(block)) - // Stage only the allowed candidate paths so snapshot updates stay scoped. - yield* stage(allow.filter((item) => !block.has(item))) - }) - - const cleanup = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - if (!(yield* exists(state.gitdir))) return - const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }) - return - } - log.info("cleanup", { prune }) - }), - ) - }) - - const track = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - const existed = yield* exists(state.gitdir) - yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, - }) - yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) - log.info("initialized") - } - yield* add() - const result = yield* git(args(["write-tree"]), { cwd: state.directory }) - const hash = result.text.trim() - log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) - return hash - }), - ) - }) - - const patch = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git( - [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], - { - cwd: state.directory, - }, - ) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - const files = result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - - // Hide ignored-file removals from the user-facing patch output. - const ignored = yield* ignore(files) - - return { - hash, - files: files - .filter((item) => !ignored.has(item)) - .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), - } - }), - ) - }) - - const restore = Effect.fnUntraced(function* (snapshot: string) { - return yield* locked( - Effect.gen(function* () { - log.info("restore", { commit: snapshot }) - const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { - cwd: state.worktree, - }) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }) - }), - ) - }) - - const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) { - return yield* locked( - Effect.gen(function* () { - const ops: { hash: string; file: string; rel: string }[] = [] - const seen = new Set() - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) - ops.push({ - hash: item.hash, - file, - rel: path.relative(state.worktree, file).replaceAll("\\", "/"), - }) - } - } - - const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { - log.info("reverting", { file: op.file, hash: op.hash }) - const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { - cwd: state.worktree, - }) - if (result.code === 0) return - const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { - cwd: state.worktree, - }) - if (tree.code === 0 && tree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) - return - } - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - }) - - const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) - - for (let i = 0; i < ops.length; ) { - const first = ops[i]! - const run = [first] - let j = i + 1 - // Only batch adjacent files when their paths cannot affect each other. - while (j < ops.length && run.length < 100) { - const next = ops[j]! - if (next.hash !== first.hash) break - if (run.some((item) => clash(item.rel, next.rel))) break - run.push(next) - j += 1 - } - - if (run.length === 1) { - yield* single(first) - i = j - continue - } - - const tree = yield* git( - [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], - { - cwd: state.worktree, - }, - ) - - if (tree.code !== 0) { - log.info("batched ls-tree failed, falling back to single-file revert", { - hash: first.hash, - files: run.length, - }) - for (const op of run) { - yield* single(op) - } - i = j - continue - } - - const have = new Set( - tree.text - .trim() - .split("\n") - .map((item) => item.trim()) - .filter(Boolean), - ) - const list = run.filter((item) => have.has(item.rel)) - if (list.length) { - log.info("reverting", { hash: first.hash, files: list.length }) - const result = yield* git( - [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], - { - cwd: state.worktree, - }, - ) - if (result.code !== 0) { - log.info("batched checkout failed, falling back to single-file revert", { - hash: first.hash, - files: list.length, - }) - for (const op of run) { - yield* single(op) - } - i = j - continue - } - } - - for (const op of run) { - if (have.has(op.rel)) continue - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - } - - i = j - } - }), - ) - }) - - const diff = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { - cwd: state.worktree, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }) - return "" - } - return result.text.trim() - }), - ) - }) - - const diffFull = Effect.fnUntraced(function* (from: string, to: string) { - return yield* locked( - Effect.gen(function* () { - type Row = { - file: string - status: "added" | "deleted" | "modified" - binary: boolean - additions: number - deletions: number - } - - type Ref = { - file: string - side: "before" | "after" - ref: string - } - - const show = Effect.fnUntraced(function* (row: Row) { - if (row.binary) return ["", ""] - if (row.status === "added") { - return [ - "", - yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), - ] - } - if (row.status === "deleted") { - return [ - yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), - "", - ] - } - return yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - }) - - const load = Effect.fnUntraced( - function* (rows: Row[]) { - const refs = rows.flatMap((row) => { - if (row.binary) return [] - if (row.status === "added") - return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] - if (row.status === "deleted") { - return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] - } - return [ - { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, - { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, - ] - }) - if (!refs.length) return new Map() - - const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { - cwd: state.directory, - extendEnv: true, - stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), - }) - const handle = yield* spawner.spawn(proc) - const [out, err] = yield* Effect.all( - [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - if (code !== 0) { - log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { - stderr: err, - refs: refs.length, - }) - return - } - - const fail = (msg: string, extra?: Record) => { - log.info(msg, { ...extra, refs: refs.length }) - return undefined - } - - const map = new Map() - const dec = new TextDecoder() - let i = 0 - for (const ref of refs) { - let end = i - while (end < out.length && out[end] !== 10) end += 1 - if (end >= out.length) { - return fail( - "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", - ) - } - - const head = dec.decode(out.slice(i, end)) - i = end + 1 - const hit = map.get(ref.file) ?? { before: "", after: "" } - if (head.endsWith(" missing")) { - map.set(ref.file, hit) - continue - } - - const match = head.match(/^[0-9a-f]+ blob (\d+)$/) - if (!match) { - return fail( - "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const size = Number(match[1]) - if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { - return fail( - "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const text = dec.decode(out.slice(i, i + size)) - if (ref.side === "before") hit.before = text - if (ref.side === "after") hit.after = text - map.set(ref.file, hit) - i += size + 1 - } - - if (i !== out.length) { - return fail( - "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", - ) - } - - return map - }, - Effect.scoped, - Effect.catch(() => - Effect.succeed | undefined>(undefined), - ), - ) - - const result: Snapshot.FileDiff[] = [] - const status = new Map() - - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], - { cwd: state.directory }, - ) - - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") - } - - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { - cwd: state.directory, - }, - ) - - const rows = numstat.text - .trim() - .split("\n") - .filter(Boolean) - .flatMap((line) => { - const [adds, dels, file] = line.split("\t") - if (!file) return [] - const binary = adds === "-" && dels === "-" - const additions = binary ? 0 : parseInt(adds) - const deletions = binary ? 0 : parseInt(dels) - return [ - { - file, - status: status.get(file) ?? "modified", - binary, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - } satisfies Row, - ] - }) - - // Hide ignored-file removals from the user-facing diff output. - const ignored = yield* ignore(rows.map((r) => r.file)) - if (ignored.size > 0) { - const filtered = rows.filter((r) => !ignored.has(r.file)) - rows.length = 0 - rows.push(...filtered) - } - - const step = 100 - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - - for (let i = 0; i < rows.length; i += step) { - const run = rows.slice(i, i + step) - const text = yield* load(run) - - for (const row of run) { - const hit = text?.get(row.file) ?? { before: "", after: "" } - const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) - result.push({ - file: row.file, - patch: row.binary ? "" : patch(row.file, before, after), - additions: row.additions, - deletions: row.deletions, - status: row.status, - }) - } - } - - return result - }), - ) - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return { cleanup, track, patch, restore, revert, diff, diffFull } - }), - ) - - return Service.of({ - init: Effect.fn("Snapshot.init")(function* () { - yield* InstanceState.get(state) - }), - cleanup: Effect.fn("Snapshot.cleanup")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.cleanup()) - }), - track: Effect.fn("Snapshot.track")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.track()) - }), - patch: Effect.fn("Snapshot.patch")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) - }), - restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { - return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) - }), - revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { - return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) - }), - diff: Effect.fn("Snapshot.diff")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) - }), - diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) - }), - }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Config.defaultLayer), - ) -} +export * as Snapshot from "./snapshot" diff --git a/packages/opencode/src/snapshot/snapshot.ts b/packages/opencode/src/snapshot/snapshot.ts new file mode 100644 index 0000000000..32c637a216 --- /dev/null +++ b/packages/opencode/src/snapshot/snapshot.ts @@ -0,0 +1,777 @@ +import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { formatPatch, structuredPatch } from "diff" +import path from "path" +import z from "zod" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { InstanceState } from "@/effect/instance-state" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Hash } from "@opencode-ai/shared/util/hash" +import { Config } from "../config" +import { Global } from "../global" +import { Log } from "../util/log" + +export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), +}) +export type Patch = z.infer + +export const FileDiff = z + .object({ + file: z.string(), + patch: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "SnapshotFileDiff", + }) +export type FileDiff = z.infer + +const log = Log.create({ service: "snapshot" }) +const prune = "7.days" +const limit = 2 * 1024 * 1024 +const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] +const cfg = ["-c", "core.autocrlf=false", ...core] +const quote = [...cfg, "-c", "core.quotepath=false"] +interface GitResult { + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string +} + +type State = Omit + +export interface Interface { + readonly init: () => Effect.Effect + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Snapshot") {} + +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const config = yield* Config.Service + const locks = new Map() + + const lock = (key: string) => { + const hit = locks.get(key) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(key, next) + return next + } + + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), + vcs: ctx.project.vcs, + } + + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + + const enc = new TextEncoder() + const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) + + const git = Effect.fnUntraced( + function* ( + cmd: string[], + opts?: { cwd?: string; env?: Record; stdin?: ChildProcess.CommandInput }, + ) { + const proc = ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + stdin: opts?.stdin, + }) + const handle = yield* spawner.spawn(proc) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) + + const ignore = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return new Set() + const check = yield* git( + [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--stdin", + "-z", + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (check.code !== 0 && check.code !== 1) return new Set() + return new Set(check.text.split("\0").filter(Boolean)) + }) + + const drop = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + yield* git( + [ + ...cfg, + ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + }) + + const stage = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + const result = yield* git( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (result.code === 0) return + log.warn("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* config.get()).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* (list: string[] = []) { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + const text = [ + file ? (yield* read(file)).trimEnd() : "", + ...list.map((item) => `/${item.replaceAll("\\", "/")}`), + ] + .filter(Boolean) + .join("\n") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + const [diff, other] = yield* Effect.all( + [ + git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { + cwd: state.directory, + }), + git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { + cwd: state.directory, + }), + ], + { concurrency: 2 }, + ) + if (diff.code !== 0 || other.code !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diff.code, + diffStderr: diff.stderr, + otherCode: other.code, + otherStderr: other.stderr, + }) + return + } + + const tracked = diff.text.split("\0").filter(Boolean) + const untracked = other.text.split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...untracked])) + if (!all.length) return + + // Resolve source-repo ignore rules against the exact candidate set. + // --no-index keeps this pattern-based even when a path is already tracked. + const ignored = yield* ignore(all) + + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* drop(ignoredFiles) + } + + const allow = all.filter((item) => !ignored.has(item)) + if (!allow.length) return + + const large = new Set( + (yield* Effect.all( + allow.map((item) => + fs + .stat(path.join(state.directory, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)), + ) + const block = new Set(untracked.filter((item) => large.has(item))) + yield* sync(Array.from(block)) + // Stage only the allowed candidate paths so snapshot updates stay scoped. + yield* stage(allow.filter((item) => !block.has(item))) + }) + + const cleanup = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) + }), + ) + }) + + const track = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash + }), + ) + }) + + const patch = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + + // Hide ignored-file removals from the user-facing patch output. + const ignored = yield* ignore(files) + + return { + hash, + files: files + .filter((item) => !ignored.has(item)) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + }), + ) + }) + + const restore = Effect.fnUntraced(function* (snapshot: string) { + return yield* locked( + Effect.gen(function* () { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { + cwd: state.worktree, + }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }), + ) + }) + + const revert = Effect.fnUntraced(function* (patches: Patch[]) { + return yield* locked( + Effect.gen(function* () { + const ops: { hash: string; file: string; rel: string }[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ + hash: item.hash, + file, + rel: path.relative(state.worktree, file).replaceAll("\\", "/"), + }) + } + } + + const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { + cwd: state.worktree, + }) + if (result.code === 0) return + const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + }) + + const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + + for (let i = 0; i < ops.length; ) { + const first = ops[i]! + const run = [first] + let j = i + 1 + // Only batch adjacent files when their paths cannot affect each other. + while (j < ops.length && run.length < 100) { + const next = ops[j]! + if (next.hash !== first.hash) break + if (run.some((item) => clash(item.rel, next.rel))) break + run.push(next) + j += 1 + } + + if (run.length === 1) { + yield* single(first) + i = j + continue + } + + const tree = yield* git( + [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], + { + cwd: state.worktree, + }, + ) + + if (tree.code !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { + hash: first.hash, + files: run.length, + }) + for (const op of run) { + yield* single(op) + } + i = j + continue + } + + const have = new Set( + tree.text + .trim() + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), + ) + const list = run.filter((item) => have.has(item.rel)) + if (list.length) { + log.info("reverting", { hash: first.hash, files: list.length }) + const result = yield* git( + [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + { + cwd: state.worktree, + }, + ) + if (result.code !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { + hash: first.hash, + files: list.length, + }) + for (const op of run) { + yield* single(op) + } + i = j + continue + } + } + + for (const op of run) { + if (have.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + } + + i = j + } + }), + ) + }) + + const diff = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }), + ) + }) + + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + return yield* locked( + Effect.gen(function* () { + type Row = { + file: string + status: "added" | "deleted" | "modified" + binary: boolean + additions: number + deletions: number + } + + type Ref = { + file: string + side: "before" | "after" + ref: string + } + + const show = Effect.fnUntraced(function* (row: Row) { + if (row.binary) return ["", ""] + if (row.status === "added") { + return [ + "", + yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + ] + } + if (row.status === "deleted") { + return [ + yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + "", + ] + } + return yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) + }) + + const load = Effect.fnUntraced( + function* (rows: Row[]) { + const refs = rows.flatMap((row) => { + if (row.binary) return [] + if (row.status === "added") + return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] + if (row.status === "deleted") { + return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] + } + return [ + { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, + { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, + ] + }) + if (!refs.length) return new Map() + + const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { + cwd: state.directory, + extendEnv: true, + stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), + }) + const handle = yield* spawner.spawn(proc) + const [out, err] = yield* Effect.all( + [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + if (code !== 0) { + log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { + stderr: err, + refs: refs.length, + }) + return + } + + const fail = (msg: string, extra?: Record) => { + log.info(msg, { ...extra, refs: refs.length }) + return undefined + } + + const map = new Map() + const dec = new TextDecoder() + let i = 0 + for (const ref of refs) { + let end = i + while (end < out.length && out[end] !== 10) end += 1 + if (end >= out.length) { + return fail( + "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", + ) + } + + const head = dec.decode(out.slice(i, end)) + i = end + 1 + const hit = map.get(ref.file) ?? { before: "", after: "" } + if (head.endsWith(" missing")) { + map.set(ref.file, hit) + continue + } + + const match = head.match(/^[0-9a-f]+ blob (\d+)$/) + if (!match) { + return fail( + "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const size = Number(match[1]) + if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { + return fail( + "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const text = dec.decode(out.slice(i, i + size)) + if (ref.side === "before") hit.before = text + if (ref.side === "after") hit.after = text + map.set(ref.file, hit) + i += size + 1 + } + + if (i !== out.length) { + return fail( + "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", + ) + } + + return map + }, + Effect.scoped, + Effect.catch(() => + Effect.succeed | undefined>(undefined), + ), + ) + + const result: FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") + } + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + const rows = numstat.text + .trim() + .split("\n") + .filter(Boolean) + .flatMap((line) => { + const [adds, dels, file] = line.split("\t") + if (!file) return [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + return [ + { + file, + status: status.get(file) ?? "modified", + binary, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Row, + ] + }) + + // Hide ignored-file removals from the user-facing diff output. + const ignored = yield* ignore(rows.map((r) => r.file)) + if (ignored.size > 0) { + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + + const step = 100 + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + + for (let i = 0; i < rows.length; i += step) { + const run = rows.slice(i, i + step) + const text = yield* load(run) + + for (const row of run) { + const hit = text?.get(row.file) ?? { before: "", after: "" } + const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) + result.push({ + file: row.file, + patch: row.binary ? "" : patch(row.file, before, after), + additions: row.additions, + deletions: row.deletions, + status: row.status, + }) + } + } + + return result + }), + ) + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) + + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Config.defaultLayer), +) From dc16488bd78ccecb10613cf8dbe017be4c9b2408 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:21 -0400 Subject: [PATCH 28/75] feat: unwrap uide namespace to flat exports + barrel (#22706) --- packages/opencode/src/ide/ide.ts | 71 ++++++++++++++++++++++++++++ packages/opencode/src/ide/index.ts | 74 +----------------------------- 2 files changed, 72 insertions(+), 73 deletions(-) create mode 100644 packages/opencode/src/ide/ide.ts diff --git a/packages/opencode/src/ide/ide.ts b/packages/opencode/src/ide/ide.ts new file mode 100644 index 0000000000..cbced9c3d8 --- /dev/null +++ b/packages/opencode/src/ide/ide.ts @@ -0,0 +1,71 @@ +import { BusEvent } from "@/bus/bus-event" +import z from "zod" +import { NamedError } from "@opencode-ai/shared/util/error" +import { Log } from "../util/log" +import { Process } from "@/util/process" + +const SUPPORTED_IDES = [ + { name: "Windsurf" as const, cmd: "windsurf" }, + { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" }, + { name: "Visual Studio Code" as const, cmd: "code" }, + { name: "Cursor" as const, cmd: "cursor" }, + { name: "VSCodium" as const, cmd: "codium" }, +] + +const log = Log.create({ service: "ide" }) + +export const Event = { + Installed: BusEvent.define( + "ide.installed", + z.object({ + ide: z.string(), + }), + ), +} + +export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) + +export const InstallFailedError = NamedError.create( + "InstallFailedError", + z.object({ + stderr: z.string(), + }), +) + +export function ide() { + if (process.env["TERM_PROGRAM"] === "vscode") { + const v = process.env["GIT_ASKPASS"] + for (const ide of SUPPORTED_IDES) { + if (v?.includes(ide.name)) return ide.name + } + } + return "unknown" +} + +export function alreadyInstalled() { + return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders" +} + +export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) { + const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd + if (!cmd) throw new Error(`Unknown IDE: ${ide}`) + + const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], { + nothrow: true, + }) + const stdout = p.stdout.toString() + const stderr = p.stderr.toString() + + log.info("installed", { + ide, + stdout, + stderr, + }) + + if (p.code !== 0) { + throw new InstallFailedError({ stderr }) + } + if (stdout.includes("already installed")) { + throw new AlreadyInstalledError({}) + } +} diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 24ba53f82e..9716ecbc74 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,73 +1 @@ -import { BusEvent } from "@/bus/bus-event" -import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Log } from "../util/log" -import { Process } from "@/util/process" - -const SUPPORTED_IDES = [ - { name: "Windsurf" as const, cmd: "windsurf" }, - { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" }, - { name: "Visual Studio Code" as const, cmd: "code" }, - { name: "Cursor" as const, cmd: "cursor" }, - { name: "VSCodium" as const, cmd: "codium" }, -] - -export namespace Ide { - const log = Log.create({ service: "ide" }) - - export const Event = { - Installed: BusEvent.define( - "ide.installed", - z.object({ - ide: z.string(), - }), - ), - } - - export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) - - export const InstallFailedError = NamedError.create( - "InstallFailedError", - z.object({ - stderr: z.string(), - }), - ) - - export function ide() { - if (process.env["TERM_PROGRAM"] === "vscode") { - const v = process.env["GIT_ASKPASS"] - for (const ide of SUPPORTED_IDES) { - if (v?.includes(ide.name)) return ide.name - } - } - return "unknown" - } - - export function alreadyInstalled() { - return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders" - } - - export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) { - const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd - if (!cmd) throw new Error(`Unknown IDE: ${ide}`) - - const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], { - nothrow: true, - }) - const stdout = p.stdout.toString() - const stderr = p.stderr.toString() - - log.info("installed", { - ide, - stdout, - stderr, - }) - - if (p.code !== 0) { - throw new InstallFailedError({ stderr }) - } - if (stdout.includes("already installed")) { - throw new AlreadyInstalledError({}) - } - } -} +export * as Ide from "./ide" From f7edffc11aeceeab1000026cfcd7063671a1e7bb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:36 -0400 Subject: [PATCH 29/75] feat: unwrap uglobal namespace to flat exports + barrel (#22705) --- packages/opencode/src/global/global.ts | 56 ++++++++++++++++++++++++ packages/opencode/src/global/index.ts | 59 +------------------------- 2 files changed, 57 insertions(+), 58 deletions(-) create mode 100644 packages/opencode/src/global/global.ts diff --git a/packages/opencode/src/global/global.ts b/packages/opencode/src/global/global.ts new file mode 100644 index 0000000000..1bbb5968c9 --- /dev/null +++ b/packages/opencode/src/global/global.ts @@ -0,0 +1,56 @@ +import fs from "fs/promises" +import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" +import path from "path" +import os from "os" +import { Filesystem } from "../util/filesystem" +import { Flock } from "@opencode-ai/shared/util/flock" + +const app = "opencode" + +const data = path.join(xdgData!, app) +const cache = path.join(xdgCache!, app) +const config = path.join(xdgConfig!, app) +const state = path.join(xdgState!, app) + +export const Path = { + // Allow override via OPENCODE_TEST_HOME for test isolation + get home() { + return process.env.OPENCODE_TEST_HOME || os.homedir() + }, + data, + bin: path.join(cache, "bin"), + log: path.join(data, "log"), + cache, + config, + state, +} + +// Initialize Flock with global state path +Flock.setGlobal({ state }) + +await Promise.all([ + fs.mkdir(Path.data, { recursive: true }), + fs.mkdir(Path.config, { recursive: true }), + fs.mkdir(Path.state, { recursive: true }), + fs.mkdir(Path.log, { recursive: true }), + fs.mkdir(Path.bin, { recursive: true }), +]) + +const CACHE_VERSION = "21" + +const version = await Filesystem.readText(path.join(Path.cache, "version")).catch(() => "0") + +if (version !== CACHE_VERSION) { + try { + const contents = await fs.readdir(Path.cache) + await Promise.all( + contents.map((item) => + fs.rm(path.join(Path.cache, item), { + recursive: true, + force: true, + }), + ), + ) + } catch {} + await Filesystem.write(path.join(Path.cache, "version"), CACHE_VERSION) +} diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index df46397816..9262bf2a93 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -1,58 +1 @@ -import fs from "fs/promises" -import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" -import path from "path" -import os from "os" -import { Filesystem } from "../util/filesystem" -import { Flock } from "@opencode-ai/shared/util/flock" - -const app = "opencode" - -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) - -export namespace Global { - export const Path = { - // Allow override via OPENCODE_TEST_HOME for test isolation - get home() { - return process.env.OPENCODE_TEST_HOME || os.homedir() - }, - data, - bin: path.join(cache, "bin"), - log: path.join(data, "log"), - cache, - config, - state, - } -} - -// Initialize Flock with global state path -Flock.setGlobal({ state }) - -await Promise.all([ - fs.mkdir(Global.Path.data, { recursive: true }), - fs.mkdir(Global.Path.config, { recursive: true }), - fs.mkdir(Global.Path.state, { recursive: true }), - fs.mkdir(Global.Path.log, { recursive: true }), - fs.mkdir(Global.Path.bin, { recursive: true }), -]) - -const CACHE_VERSION = "21" - -const version = await Filesystem.readText(path.join(Global.Path.cache, "version")).catch(() => "0") - -if (version !== CACHE_VERSION) { - try { - const contents = await fs.readdir(Global.Path.cache) - await Promise.all( - contents.map((item) => - fs.rm(path.join(Global.Path.cache, item), { - recursive: true, - force: true, - }), - ), - ) - } catch {} - await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION) -} +export * as Global from "./global" From a653a4b8871e5d58c56f588e4cd3b2001f8bc6a1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:46 -0400 Subject: [PATCH 30/75] feat: unwrap usync namespace to flat exports + barrel (#22716) --- packages/opencode/src/sync/index.ts | 283 +---------------------- packages/opencode/src/sync/sync-event.ts | 280 ++++++++++++++++++++++ 2 files changed, 281 insertions(+), 282 deletions(-) create mode 100644 packages/opencode/src/sync/sync-event.ts diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index e89d57e181..a6dec180bd 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,282 +1 @@ -import z from "zod" -import type { ZodObject } from "zod" -import { Database, eq } from "@/storage/db" -import { GlobalBus } from "@/bus/global" -import { Bus as ProjectBus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { Instance } from "@/project/instance" -import { EventSequenceTable, EventTable } from "./event.sql" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { EventID } from "./schema" -import { Flag } from "@/flag/flag" - -export namespace SyncEvent { - export type Definition = { - type: string - version: number - aggregate: string - schema: z.ZodObject - - // This is temporary and only exists for compatibility with bus - // event definitions - properties: z.ZodObject - } - - export type Event = { - id: string - seq: number - aggregateID: string - data: z.infer - } - - export type SerializedEvent = Event & { type: string } - - type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void - - export const registry = new Map() - let projectors: Map | undefined - const versions = new Map() - let frozen = false - let convertEvent: (type: string, event: Event["data"]) => Promise> | Record - - export function reset() { - frozen = false - projectors = undefined - convertEvent = (_, data) => data - } - - export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) { - projectors = new Map(input.projectors) - - // Install all the latest event defs to the bus. We only ever emit - // latest versions from code, and keep around old versions for - // replaying. Replaying does not go through the bus, and it - // simplifies the bus to only use unversioned latest events - for (let [type, version] of versions.entries()) { - let def = registry.get(versionedType(type, version))! - - BusEvent.define(def.type, def.properties || def.schema) - } - - // Freeze the system so it clearly errors if events are defined - // after `init` which would cause bugs - frozen = true - convertEvent = input.convertEvent || ((_, data) => data) - } - - export function versionedType(type: A): A - export function versionedType(type: A, version: B): `${A}/${B}` - export function versionedType(type: string, version?: number) { - return version ? `${type}.${version}` : type - } - - export function define< - Type extends string, - Agg extends string, - Schema extends ZodObject>>, - BusSchema extends ZodObject = Schema, - >(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { - if (frozen) { - throw new Error("Error defining sync event: sync system has been frozen") - } - - const def = { - type: input.type, - version: input.version, - aggregate: input.aggregate, - schema: input.schema, - properties: input.busSchema ? input.busSchema : input.schema, - } - - versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) - - registry.set(versionedType(def.type, def.version), def) - - return def - } - - export function project( - def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, - ): [Definition, ProjectorFunc] { - return [def, func as ProjectorFunc] - } - - function process(def: Def, event: Event, options: { publish: boolean }) { - if (projectors == null) { - throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") - } - - const projector = projectors.get(def) - if (!projector) { - throw new Error(`Projector not found for event: ${def.type}`) - } - - // idempotent: need to ignore any events already logged - - Database.transaction((tx) => { - projector(tx, event.data) - - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - tx.insert(EventSequenceTable) - .values({ - aggregate_id: event.aggregateID, - seq: event.seq, - }) - .onConflictDoUpdate({ - target: EventSequenceTable.aggregate_id, - set: { seq: event.seq }, - }) - .run() - tx.insert(EventTable) - .values({ - id: event.id, - seq: event.seq, - aggregate_id: event.aggregateID, - type: versionedType(def.type, def.version), - data: event.data as Record, - }) - .run() - } - - Database.effect(() => { - if (options?.publish) { - const result = convertEvent(def.type, event.data) - if (result instanceof Promise) { - result.then((data) => { - ProjectBus.publish({ type: def.type, properties: def.schema }, data) - }) - } else { - ProjectBus.publish({ type: def.type, properties: def.schema }, result) - } - - GlobalBus.emit("event", { - directory: Instance.directory, - project: Instance.project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "sync", - name: versionedType(def.type, def.version), - ...event, - }, - }) - } - }) - }) - } - - // TODO: - // - // * Support applying multiple events at one time. One transaction, - // and it validets all the sequence ids - // * when loading events from db, apply zod validation to ensure shape - - export function replay(event: SerializedEvent, options?: { publish: boolean }) { - const def = registry.get(event.type) - if (!def) { - throw new Error(`Unknown event type: ${event.type}`) - } - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) - .get(), - ) - - const latest = row?.seq ?? -1 - if (event.seq <= latest) { - return - } - - const expected = latest + 1 - if (event.seq !== expected) { - throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) - } - - process(def, event, { publish: !!options?.publish }) - } - - export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { - const source = events[0]?.aggregateID - if (!source) return - if (events.some((item) => item.aggregateID !== source)) { - throw new Error("Replay events must belong to the same session") - } - const start = events[0].seq - for (const [i, item] of events.entries()) { - const seq = start + i - if (item.seq !== seq) { - throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) - } - } - for (const item of events) { - replay(item, options) - } - return source - } - - export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { - const agg = (data as Record)[def.aggregate] - // This should never happen: we've enforced it via typescript in - // the definition - if (agg == null) { - throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) - } - - if (def.version !== versions.get(def.type)) { - throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) - } - - const { publish = true } = options || {} - - // Note that this is an "immediate" transaction which is critical. - // We need to make sure we can safely read and write with nothing - // else changing the data from under us - Database.transaction( - (tx) => { - const id = EventID.ascending() - const row = tx - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, agg)) - .get() - const seq = row?.seq != null ? row.seq + 1 : 0 - - const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish }) - }, - { - behavior: "immediate", - }, - ) - } - - export function remove(aggregateID: string) { - Database.transaction((tx) => { - tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() - tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() - }) - } - - export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal("sync"), - name: z.literal(type), - id: z.string(), - seq: z.number(), - aggregateID: z.literal(def.aggregate), - data: def.schema, - }) - .meta({ - ref: "SyncEvent" + "." + def.type, - }) - }) - .toArray() - } -} +export * as SyncEvent from "./sync-event" diff --git a/packages/opencode/src/sync/sync-event.ts b/packages/opencode/src/sync/sync-event.ts new file mode 100644 index 0000000000..2b1eb09810 --- /dev/null +++ b/packages/opencode/src/sync/sync-event.ts @@ -0,0 +1,280 @@ +import z from "zod" +import type { ZodObject } from "zod" +import { Database, eq } from "@/storage/db" +import { GlobalBus } from "@/bus/global" +import { Bus as ProjectBus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Instance } from "@/project/instance" +import { EventSequenceTable, EventTable } from "./event.sql" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { EventID } from "./schema" +import { Flag } from "@/flag/flag" + +export type Definition = { + type: string + version: number + aggregate: string + schema: z.ZodObject + + // This is temporary and only exists for compatibility with bus + // event definitions + properties: z.ZodObject +} + +export type Event = { + id: string + seq: number + aggregateID: string + data: z.infer +} + +export type SerializedEvent = Event & { type: string } + +type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void + +export const registry = new Map() +let projectors: Map | undefined +const versions = new Map() +let frozen = false +let convertEvent: (type: string, event: Event["data"]) => Promise> | Record + +export function reset() { + frozen = false + projectors = undefined + convertEvent = (_, data) => data +} + +export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) { + projectors = new Map(input.projectors) + + // Install all the latest event defs to the bus. We only ever emit + // latest versions from code, and keep around old versions for + // replaying. Replaying does not go through the bus, and it + // simplifies the bus to only use unversioned latest events + for (let [type, version] of versions.entries()) { + let def = registry.get(versionedType(type, version))! + + BusEvent.define(def.type, def.properties || def.schema) + } + + // Freeze the system so it clearly errors if events are defined + // after `init` which would cause bugs + frozen = true + convertEvent = input.convertEvent || ((_, data) => data) +} + +export function versionedType(type: A): A +export function versionedType(type: A, version: B): `${A}/${B}` +export function versionedType(type: string, version?: number) { + return version ? `${type}.${version}` : type +} + +export function define< + Type extends string, + Agg extends string, + Schema extends ZodObject>>, + BusSchema extends ZodObject = Schema, +>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { + if (frozen) { + throw new Error("Error defining sync event: sync system has been frozen") + } + + const def = { + type: input.type, + version: input.version, + aggregate: input.aggregate, + schema: input.schema, + properties: input.busSchema ? input.busSchema : input.schema, + } + + versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) + + registry.set(versionedType(def.type, def.version), def) + + return def +} + +export function project( + def: Def, + func: (db: Database.TxOrDb, data: Event["data"]) => void, +): [Definition, ProjectorFunc] { + return [def, func as ProjectorFunc] +} + +function process(def: Def, event: Event, options: { publish: boolean }) { + if (projectors == null) { + throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") + } + + const projector = projectors.get(def) + if (!projector) { + throw new Error(`Projector not found for event: ${def.type}`) + } + + // idempotent: need to ignore any events already logged + + Database.transaction((tx) => { + projector(tx, event.data) + + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + tx.insert(EventSequenceTable) + .values({ + aggregate_id: event.aggregateID, + seq: event.seq, + }) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: event.seq }, + }) + .run() + tx.insert(EventTable) + .values({ + id: event.id, + seq: event.seq, + aggregate_id: event.aggregateID, + type: versionedType(def.type, def.version), + data: event.data as Record, + }) + .run() + } + + Database.effect(() => { + if (options?.publish) { + const result = convertEvent(def.type, event.data) + if (result instanceof Promise) { + result.then((data) => { + ProjectBus.publish({ type: def.type, properties: def.schema }, data) + }) + } else { + ProjectBus.publish({ type: def.type, properties: def.schema }, result) + } + + GlobalBus.emit("event", { + directory: Instance.directory, + project: Instance.project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "sync", + name: versionedType(def.type, def.version), + ...event, + }, + }) + } + }) + }) +} + +// TODO: +// +// * Support applying multiple events at one time. One transaction, +// and it validets all the sequence ids +// * when loading events from db, apply zod validation to ensure shape + +export function replay(event: SerializedEvent, options?: { publish: boolean }) { + const def = registry.get(event.type) + if (!def) { + throw new Error(`Unknown event type: ${event.type}`) + } + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) + .get(), + ) + + const latest = row?.seq ?? -1 + if (event.seq <= latest) { + return + } + + const expected = latest + 1 + if (event.seq !== expected) { + throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) + } + + process(def, event, { publish: !!options?.publish }) +} + +export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { + const source = events[0]?.aggregateID + if (!source) return + if (events.some((item) => item.aggregateID !== source)) { + throw new Error("Replay events must belong to the same session") + } + const start = events[0].seq + for (const [i, item] of events.entries()) { + const seq = start + i + if (item.seq !== seq) { + throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) + } + } + for (const item of events) { + replay(item, options) + } + return source +} + +export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { + const agg = (data as Record)[def.aggregate] + // This should never happen: we've enforced it via typescript in + // the definition + if (agg == null) { + throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) + } + + if (def.version !== versions.get(def.type)) { + throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) + } + + const { publish = true } = options || {} + + // Note that this is an "immediate" transaction which is critical. + // We need to make sure we can safely read and write with nothing + // else changing the data from under us + Database.transaction( + (tx) => { + const id = EventID.ascending() + const row = tx + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, agg)) + .get() + const seq = row?.seq != null ? row.seq + 1 : 0 + + const event = { id, seq, aggregateID: agg, data } + process(def, event, { publish }) + }, + { + behavior: "immediate", + }, + ) +} + +export function remove(aggregateID: string) { + Database.transaction((tx) => { + tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() + }) +} + +export function payloads() { + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal("sync"), + name: z.literal(type), + id: z.string(), + seq: z.number(), + aggregateID: z.literal(def.aggregate), + data: def.schema, + }) + .meta({ + ref: "SyncEvent" + "." + def.type, + }) + }) + .toArray() +} From e3677c2ba2077d2dfdd188c31a55f8cdc1efa209 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:15:58 -0400 Subject: [PATCH 31/75] feat: unwrap upatch namespace to flat exports + barrel (#22709) --- packages/opencode/src/patch/index.ts | 681 +-------------------------- packages/opencode/src/patch/patch.ts | 678 ++++++++++++++++++++++++++ 2 files changed, 679 insertions(+), 680 deletions(-) create mode 100644 packages/opencode/src/patch/patch.ts diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index f003606c4d..cec24614d8 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,680 +1 @@ -import z from "zod" -import * as path from "path" -import * as fs from "fs/promises" -import { readFileSync } from "fs" -import { Log } from "../util/log" - -export namespace Patch { - const log = Log.create({ service: "patch" }) - - // Schema definitions - export const PatchSchema = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), - }) - - export type PatchParams = z.infer - - // Core types matching the Rust implementation - export interface ApplyPatchArgs { - patch: string - hunks: Hunk[] - workdir?: string - } - - export type Hunk = - | { type: "add"; path: string; contents: string } - | { type: "delete"; path: string } - | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } - - export interface UpdateFileChunk { - old_lines: string[] - new_lines: string[] - change_context?: string - is_end_of_file?: boolean - } - - export interface ApplyPatchAction { - changes: Map - patch: string - cwd: string - } - - export type ApplyPatchFileChange = - | { type: "add"; content: string } - | { type: "delete"; content: string } - | { type: "update"; unified_diff: string; move_path?: string; new_content: string } - - export interface AffectedPaths { - added: string[] - modified: string[] - deleted: string[] - } - - export enum ApplyPatchError { - ParseError = "ParseError", - IoError = "IoError", - ComputeReplacements = "ComputeReplacements", - ImplicitInvocation = "ImplicitInvocation", - } - - export enum MaybeApplyPatch { - Body = "Body", - ShellParseError = "ShellParseError", - PatchParseError = "PatchParseError", - NotApplyPatch = "NotApplyPatch", - } - - export enum MaybeApplyPatchVerified { - Body = "Body", - ShellParseError = "ShellParseError", - CorrectnessError = "CorrectnessError", - NotApplyPatch = "NotApplyPatch", - } - - // Parser implementation - function parsePatchHeader( - lines: string[], - startIdx: number, - ): { filePath: string; movePath?: string; nextIdx: number } | null { - const line = lines[startIdx] - - if (line.startsWith("*** Add File:")) { - const filePath = line.slice("*** Add File:".length).trim() - return filePath ? { filePath, nextIdx: startIdx + 1 } : null - } - - if (line.startsWith("*** Delete File:")) { - const filePath = line.slice("*** Delete File:".length).trim() - return filePath ? { filePath, nextIdx: startIdx + 1 } : null - } - - if (line.startsWith("*** Update File:")) { - const filePath = line.slice("*** Update File:".length).trim() - let movePath: string | undefined - let nextIdx = startIdx + 1 - - // Check for move directive - if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { - movePath = lines[nextIdx].slice("*** Move to:".length).trim() - nextIdx++ - } - - return filePath ? { filePath, movePath, nextIdx } : null - } - - return null - } - - function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { - const chunks: UpdateFileChunk[] = [] - let i = startIdx - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("@@")) { - // Parse context line - const contextLine = lines[i].substring(2).trim() - i++ - - const oldLines: string[] = [] - const newLines: string[] = [] - let isEndOfFile = false - - // Parse change lines - while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { - const changeLine = lines[i] - - if (changeLine === "*** End of File") { - isEndOfFile = true - i++ - break - } - - if (changeLine.startsWith(" ")) { - // Keep line - appears in both old and new - const content = changeLine.substring(1) - oldLines.push(content) - newLines.push(content) - } else if (changeLine.startsWith("-")) { - // Remove line - only in old - oldLines.push(changeLine.substring(1)) - } else if (changeLine.startsWith("+")) { - // Add line - only in new - newLines.push(changeLine.substring(1)) - } - - i++ - } - - chunks.push({ - old_lines: oldLines, - new_lines: newLines, - change_context: contextLine || undefined, - is_end_of_file: isEndOfFile || undefined, - }) - } else { - i++ - } - } - - return { chunks, nextIdx: i } - } - - function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { - let content = "" - let i = startIdx - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("+")) { - content += lines[i].substring(1) + "\n" - } - i++ - } - - // Remove trailing newline - if (content.endsWith("\n")) { - content = content.slice(0, -1) - } - - return { content, nextIdx: i } - } - - function stripHeredoc(input: string): string { - // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < line.trim() === beginMarker) - const endIdx = lines.findIndex((line) => line.trim() === endMarker) - - if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { - throw new Error("Invalid patch format: missing Begin/End markers") - } - - // Parse content between markers - i = beginIdx + 1 - - while (i < endIdx) { - const header = parsePatchHeader(lines, i) - if (!header) { - i++ - continue - } - - if (lines[i].startsWith("*** Add File:")) { - const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) - hunks.push({ - type: "add", - path: header.filePath, - contents: content, - }) - i = nextIdx - } else if (lines[i].startsWith("*** Delete File:")) { - hunks.push({ - type: "delete", - path: header.filePath, - }) - i = header.nextIdx - } else if (lines[i].startsWith("*** Update File:")) { - const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx) - hunks.push({ - type: "update", - path: header.filePath, - move_path: header.movePath, - chunks, - }) - i = nextIdx - } else { - i++ - } - } - - return { hunks } - } - - // Apply patch functionality - export function maybeParseApplyPatch( - argv: string[], - ): - | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } - | { type: MaybeApplyPatch.PatchParseError; error: Error } - | { type: MaybeApplyPatch.NotApplyPatch } { - const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] - - // Direct invocation: apply_patch - if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { - try { - const { hunks } = parsePatch(argv[1]) - return { - type: MaybeApplyPatch.Body, - args: { - patch: argv[1], - hunks, - }, - } - } catch (error) { - return { - type: MaybeApplyPatch.PatchParseError, - error: error as Error, - } - } - } - - // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' - if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { - // Simple extraction - in real implementation would need proper bash parsing - const script = argv[2] - const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) - - if (heredocMatch) { - const patchContent = heredocMatch[2] - try { - const { hunks } = parsePatch(patchContent) - return { - type: MaybeApplyPatch.Body, - args: { - patch: patchContent, - hunks, - }, - } - } catch (error) { - return { - type: MaybeApplyPatch.PatchParseError, - error: error as Error, - } - } - } - } - - return { type: MaybeApplyPatch.NotApplyPatch } - } - - // File content manipulation - interface ApplyPatchFileUpdate { - unified_diff: string - content: string - } - - export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { - // Read original file content - let originalContent: string - try { - originalContent = readFileSync(filePath, "utf-8") - } catch (error) { - throw new Error(`Failed to read file ${filePath}: ${error}`) - } - - let originalLines = originalContent.split("\n") - - // Drop trailing empty element for consistent line counting - if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { - originalLines.pop() - } - - const replacements = computeReplacements(originalLines, filePath, chunks) - let newLines = applyReplacements(originalLines, replacements) - - // Ensure trailing newline - if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { - newLines.push("") - } - - const newContent = newLines.join("\n") - - // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) - - return { - unified_diff: unifiedDiff, - content: newContent, - } - } - - function computeReplacements( - originalLines: string[], - filePath: string, - chunks: UpdateFileChunk[], - ): Array<[number, number, string[]]> { - const replacements: Array<[number, number, string[]]> = [] - let lineIndex = 0 - - for (const chunk of chunks) { - // Handle context-based seeking - if (chunk.change_context) { - const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex) - if (contextIdx === -1) { - throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`) - } - lineIndex = contextIdx + 1 - } - - // Handle pure addition (no old lines) - if (chunk.old_lines.length === 0) { - const insertionIdx = - originalLines.length > 0 && originalLines[originalLines.length - 1] === "" - ? originalLines.length - 1 - : originalLines.length - replacements.push([insertionIdx, 0, chunk.new_lines]) - continue - } - - // Try to match old lines in the file - let pattern = chunk.old_lines - let newSlice = chunk.new_lines - let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) - - // Retry without trailing empty line if not found - if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { - pattern = pattern.slice(0, -1) - if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { - newSlice = newSlice.slice(0, -1) - } - found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) - } - - if (found !== -1) { - replacements.push([found, pattern.length, newSlice]) - lineIndex = found + pattern.length - } else { - throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) - } - } - - // Sort replacements by index to apply in order - replacements.sort((a, b) => a[0] - b[0]) - - return replacements - } - - function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { - // Apply replacements in reverse order to avoid index shifting - const result = [...lines] - - for (let i = replacements.length - 1; i >= 0; i--) { - const [startIdx, oldLen, newSegment] = replacements[i] - - // Remove old lines - result.splice(startIdx, oldLen) - - // Insert new lines - for (let j = 0; j < newSegment.length; j++) { - result.splice(startIdx + j, 0, newSegment[j]) - } - } - - return result - } - - // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) - function normalizeUnicode(str: string): string { - return str - .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes - .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes - .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes - .replace(/\u2026/g, "...") // ellipsis - .replace(/\u00A0/g, " ") // non-breaking space - } - - type Comparator = (a: string, b: string) => boolean - - function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { - // If EOF anchor, try matching from end of file first - if (eof) { - const fromEnd = lines.length - pattern.length - if (fromEnd >= startIndex) { - let matches = true - for (let j = 0; j < pattern.length; j++) { - if (!compare(lines[fromEnd + j], pattern[j])) { - matches = false - break - } - } - if (matches) return fromEnd - } - } - - // Forward search from startIndex - for (let i = startIndex; i <= lines.length - pattern.length; i++) { - let matches = true - for (let j = 0; j < pattern.length; j++) { - if (!compare(lines[i + j], pattern[j])) { - matches = false - break - } - } - if (matches) return i - } - - return -1 - } - - function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { - if (pattern.length === 0) return -1 - - // Pass 1: exact match - const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) - if (exact !== -1) return exact - - // Pass 2: rstrip (trim trailing whitespace) - const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) - if (rstrip !== -1) return rstrip - - // Pass 3: trim (both ends) - const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) - if (trim !== -1) return trim - - // Pass 4: normalized (Unicode punctuation to ASCII) - const normalized = tryMatch( - lines, - pattern, - startIndex, - (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), - eof, - ) - return normalized - } - - function generateUnifiedDiff(oldContent: string, newContent: string): string { - const oldLines = oldContent.split("\n") - const newLines = newContent.split("\n") - - // Simple diff generation - in a real implementation you'd use a proper diff algorithm - let diff = "@@ -1 +1 @@\n" - - // Find changes (simplified approach) - const maxLen = Math.max(oldLines.length, newLines.length) - let hasChanges = false - - for (let i = 0; i < maxLen; i++) { - const oldLine = oldLines[i] || "" - const newLine = newLines[i] || "" - - if (oldLine !== newLine) { - if (oldLine) diff += `-${oldLine}\n` - if (newLine) diff += `+${newLine}\n` - hasChanges = true - } else if (oldLine) { - diff += ` ${oldLine}\n` - } - } - - return hasChanges ? diff : "" - } - - // Apply hunks to filesystem - export async function applyHunksToFiles(hunks: Hunk[]): Promise { - if (hunks.length === 0) { - throw new Error("No files were modified.") - } - - const added: string[] = [] - const modified: string[] = [] - const deleted: string[] = [] - - for (const hunk of hunks) { - switch (hunk.type) { - case "add": - // Create parent directories - const addDir = path.dirname(hunk.path) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - - await fs.writeFile(hunk.path, hunk.contents, "utf-8") - added.push(hunk.path) - log.info(`Added file: ${hunk.path}`) - break - - case "delete": - await fs.unlink(hunk.path) - deleted.push(hunk.path) - log.info(`Deleted file: ${hunk.path}`) - break - - case "update": - const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) - - if (hunk.move_path) { - // Handle file move - const moveDir = path.dirname(hunk.move_path) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - - await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") - await fs.unlink(hunk.path) - modified.push(hunk.move_path) - log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) - } else { - // Regular update - await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") - modified.push(hunk.path) - log.info(`Updated file: ${hunk.path}`) - } - break - } - } - - return { added, modified, deleted } - } - - // Main patch application function - export async function applyPatch(patchText: string): Promise { - const { hunks } = parsePatch(patchText) - return applyHunksToFiles(hunks) - } - - // Async version of maybeParseApplyPatchVerified - export async function maybeParseApplyPatchVerified( - argv: string[], - cwd: string, - ): Promise< - | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } - | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } - | { type: MaybeApplyPatchVerified.NotApplyPatch } - > { - // Detect implicit patch invocation (raw patch without apply_patch command) - if (argv.length === 1) { - try { - parsePatch(argv[0]) - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: new Error(ApplyPatchError.ImplicitInvocation), - } - } catch { - // Not a patch, continue - } - } - - const result = maybeParseApplyPatch(argv) - - switch (result.type) { - case MaybeApplyPatch.Body: - const { args } = result - const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd - const changes = new Map() - - for (const hunk of args.hunks) { - const resolvedPath = path.resolve( - effectiveCwd, - hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, - ) - - switch (hunk.type) { - case "add": - changes.set(resolvedPath, { - type: "add", - content: hunk.contents, - }) - break - - case "delete": - // For delete, we need to read the current content - const deletePath = path.resolve(effectiveCwd, hunk.path) - try { - const content = await fs.readFile(deletePath, "utf-8") - changes.set(resolvedPath, { - type: "delete", - content, - }) - } catch { - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: new Error(`Failed to read file for deletion: ${deletePath}`), - } - } - break - - case "update": - const updatePath = path.resolve(effectiveCwd, hunk.path) - try { - const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks) - changes.set(resolvedPath, { - type: "update", - unified_diff: fileUpdate.unified_diff, - move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined, - new_content: fileUpdate.content, - }) - } catch (error) { - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: error as Error, - } - } - break - } - } - - return { - type: MaybeApplyPatchVerified.Body, - action: { - changes, - patch: args.patch, - cwd: effectiveCwd, - }, - } - - case MaybeApplyPatch.PatchParseError: - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: result.error, - } - - case MaybeApplyPatch.NotApplyPatch: - return { type: MaybeApplyPatchVerified.NotApplyPatch } - } - } -} +export * as Patch from "./patch" diff --git a/packages/opencode/src/patch/patch.ts b/packages/opencode/src/patch/patch.ts new file mode 100644 index 0000000000..d36ec72c72 --- /dev/null +++ b/packages/opencode/src/patch/patch.ts @@ -0,0 +1,678 @@ +import z from "zod" +import * as path from "path" +import * as fs from "fs/promises" +import { readFileSync } from "fs" +import { Log } from "../util/log" + +const log = Log.create({ service: "patch" }) + +// Schema definitions +export const PatchSchema = z.object({ + patchText: z.string().describe("The full patch text that describes all changes to be made"), +}) + +export type PatchParams = z.infer + +// Core types matching the Rust implementation +export interface ApplyPatchArgs { + patch: string + hunks: Hunk[] + workdir?: string +} + +export type Hunk = + | { type: "add"; path: string; contents: string } + | { type: "delete"; path: string } + | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } + +export interface UpdateFileChunk { + old_lines: string[] + new_lines: string[] + change_context?: string + is_end_of_file?: boolean +} + +export interface ApplyPatchAction { + changes: Map + patch: string + cwd: string +} + +export type ApplyPatchFileChange = + | { type: "add"; content: string } + | { type: "delete"; content: string } + | { type: "update"; unified_diff: string; move_path?: string; new_content: string } + +export interface AffectedPaths { + added: string[] + modified: string[] + deleted: string[] +} + +export enum ApplyPatchError { + ParseError = "ParseError", + IoError = "IoError", + ComputeReplacements = "ComputeReplacements", + ImplicitInvocation = "ImplicitInvocation", +} + +export enum MaybeApplyPatch { + Body = "Body", + ShellParseError = "ShellParseError", + PatchParseError = "PatchParseError", + NotApplyPatch = "NotApplyPatch", +} + +export enum MaybeApplyPatchVerified { + Body = "Body", + ShellParseError = "ShellParseError", + CorrectnessError = "CorrectnessError", + NotApplyPatch = "NotApplyPatch", +} + +// Parser implementation +function parsePatchHeader( + lines: string[], + startIdx: number, +): { filePath: string; movePath?: string; nextIdx: number } | null { + const line = lines[startIdx] + + if (line.startsWith("*** Add File:")) { + const filePath = line.slice("*** Add File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Delete File:")) { + const filePath = line.slice("*** Delete File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Update File:")) { + const filePath = line.slice("*** Update File:".length).trim() + let movePath: string | undefined + let nextIdx = startIdx + 1 + + // Check for move directive + if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { + movePath = lines[nextIdx].slice("*** Move to:".length).trim() + nextIdx++ + } + + return filePath ? { filePath, movePath, nextIdx } : null + } + + return null +} + +function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { + const chunks: UpdateFileChunk[] = [] + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("@@")) { + // Parse context line + const contextLine = lines[i].substring(2).trim() + i++ + + const oldLines: string[] = [] + const newLines: string[] = [] + let isEndOfFile = false + + // Parse change lines + while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { + const changeLine = lines[i] + + if (changeLine === "*** End of File") { + isEndOfFile = true + i++ + break + } + + if (changeLine.startsWith(" ")) { + // Keep line - appears in both old and new + const content = changeLine.substring(1) + oldLines.push(content) + newLines.push(content) + } else if (changeLine.startsWith("-")) { + // Remove line - only in old + oldLines.push(changeLine.substring(1)) + } else if (changeLine.startsWith("+")) { + // Add line - only in new + newLines.push(changeLine.substring(1)) + } + + i++ + } + + chunks.push({ + old_lines: oldLines, + new_lines: newLines, + change_context: contextLine || undefined, + is_end_of_file: isEndOfFile || undefined, + }) + } else { + i++ + } + } + + return { chunks, nextIdx: i } +} + +function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { + let content = "" + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("+")) { + content += lines[i].substring(1) + "\n" + } + i++ + } + + // Remove trailing newline + if (content.endsWith("\n")) { + content = content.slice(0, -1) + } + + return { content, nextIdx: i } +} + +function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < line.trim() === beginMarker) + const endIdx = lines.findIndex((line) => line.trim() === endMarker) + + if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { + throw new Error("Invalid patch format: missing Begin/End markers") + } + + // Parse content between markers + i = beginIdx + 1 + + while (i < endIdx) { + const header = parsePatchHeader(lines, i) + if (!header) { + i++ + continue + } + + if (lines[i].startsWith("*** Add File:")) { + const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) + hunks.push({ + type: "add", + path: header.filePath, + contents: content, + }) + i = nextIdx + } else if (lines[i].startsWith("*** Delete File:")) { + hunks.push({ + type: "delete", + path: header.filePath, + }) + i = header.nextIdx + } else if (lines[i].startsWith("*** Update File:")) { + const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx) + hunks.push({ + type: "update", + path: header.filePath, + move_path: header.movePath, + chunks, + }) + i = nextIdx + } else { + i++ + } + } + + return { hunks } +} + +// Apply patch functionality +export function maybeParseApplyPatch( + argv: string[], +): + | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } + | { type: MaybeApplyPatch.PatchParseError; error: Error } + | { type: MaybeApplyPatch.NotApplyPatch } { + const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] + + // Direct invocation: apply_patch + if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { + try { + const { hunks } = parsePatch(argv[1]) + return { + type: MaybeApplyPatch.Body, + args: { + patch: argv[1], + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + + // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' + if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { + // Simple extraction - in real implementation would need proper bash parsing + const script = argv[2] + const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) + + if (heredocMatch) { + const patchContent = heredocMatch[2] + try { + const { hunks } = parsePatch(patchContent) + return { + type: MaybeApplyPatch.Body, + args: { + patch: patchContent, + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + } + + return { type: MaybeApplyPatch.NotApplyPatch } +} + +// File content manipulation +interface ApplyPatchFileUpdate { + unified_diff: string + content: string +} + +export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { + // Read original file content + let originalContent: string + try { + originalContent = readFileSync(filePath, "utf-8") + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error}`) + } + + let originalLines = originalContent.split("\n") + + // Drop trailing empty element for consistent line counting + if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { + originalLines.pop() + } + + const replacements = computeReplacements(originalLines, filePath, chunks) + let newLines = applyReplacements(originalLines, replacements) + + // Ensure trailing newline + if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { + newLines.push("") + } + + const newContent = newLines.join("\n") + + // Generate unified diff + const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + + return { + unified_diff: unifiedDiff, + content: newContent, + } +} + +function computeReplacements( + originalLines: string[], + filePath: string, + chunks: UpdateFileChunk[], +): Array<[number, number, string[]]> { + const replacements: Array<[number, number, string[]]> = [] + let lineIndex = 0 + + for (const chunk of chunks) { + // Handle context-based seeking + if (chunk.change_context) { + const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex) + if (contextIdx === -1) { + throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`) + } + lineIndex = contextIdx + 1 + } + + // Handle pure addition (no old lines) + if (chunk.old_lines.length === 0) { + const insertionIdx = + originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length + replacements.push([insertionIdx, 0, chunk.new_lines]) + continue + } + + // Try to match old lines in the file + let pattern = chunk.old_lines + let newSlice = chunk.new_lines + let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + + // Retry without trailing empty line if not found + if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1) + if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1) + } + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + } + + if (found !== -1) { + replacements.push([found, pattern.length, newSlice]) + lineIndex = found + pattern.length + } else { + throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) + } + } + + // Sort replacements by index to apply in order + replacements.sort((a, b) => a[0] - b[0]) + + return replacements +} + +function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { + // Apply replacements in reverse order to avoid index shifting + const result = [...lines] + + for (let i = replacements.length - 1; i >= 0; i--) { + const [startIdx, oldLen, newSegment] = replacements[i] + + // Remove old lines + result.splice(startIdx, oldLen) + + // Insert new lines + for (let j = 0; j < newSegment.length; j++) { + result.splice(startIdx + j, 0, newSegment[j]) + } + } + + return result +} + +// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) +function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space +} + +type Comparator = (a: string, b: string) => boolean + +function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } + + // Forward search from startIndex + for (let i = startIndex; i <= lines.length - pattern.length; i++) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[i + j], pattern[j])) { + matches = false + break + } + } + if (matches) return i + } + + return -1 +} + +function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized +} + +function generateUnifiedDiff(oldContent: string, newContent: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + // Simple diff generation - in a real implementation you'd use a proper diff algorithm + let diff = "@@ -1 +1 @@\n" + + // Find changes (simplified approach) + const maxLen = Math.max(oldLines.length, newLines.length) + let hasChanges = false + + for (let i = 0; i < maxLen; i++) { + const oldLine = oldLines[i] || "" + const newLine = newLines[i] || "" + + if (oldLine !== newLine) { + if (oldLine) diff += `-${oldLine}\n` + if (newLine) diff += `+${newLine}\n` + hasChanges = true + } else if (oldLine) { + diff += ` ${oldLine}\n` + } + } + + return hasChanges ? diff : "" +} + +// Apply hunks to filesystem +export async function applyHunksToFiles(hunks: Hunk[]): Promise { + if (hunks.length === 0) { + throw new Error("No files were modified.") + } + + const added: string[] = [] + const modified: string[] = [] + const deleted: string[] = [] + + for (const hunk of hunks) { + switch (hunk.type) { + case "add": + // Create parent directories + const addDir = path.dirname(hunk.path) + if (addDir !== "." && addDir !== "/") { + await fs.mkdir(addDir, { recursive: true }) + } + + await fs.writeFile(hunk.path, hunk.contents, "utf-8") + added.push(hunk.path) + log.info(`Added file: ${hunk.path}`) + break + + case "delete": + await fs.unlink(hunk.path) + deleted.push(hunk.path) + log.info(`Deleted file: ${hunk.path}`) + break + + case "update": + const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) + + if (hunk.move_path) { + // Handle file move + const moveDir = path.dirname(hunk.move_path) + if (moveDir !== "." && moveDir !== "/") { + await fs.mkdir(moveDir, { recursive: true }) + } + + await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") + await fs.unlink(hunk.path) + modified.push(hunk.move_path) + log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) + } else { + // Regular update + await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") + modified.push(hunk.path) + log.info(`Updated file: ${hunk.path}`) + } + break + } + } + + return { added, modified, deleted } +} + +// Main patch application function +export async function applyPatch(patchText: string): Promise { + const { hunks } = parsePatch(patchText) + return applyHunksToFiles(hunks) +} + +// Async version of maybeParseApplyPatchVerified +export async function maybeParseApplyPatchVerified( + argv: string[], + cwd: string, +): Promise< + | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } + | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } + | { type: MaybeApplyPatchVerified.NotApplyPatch } +> { + // Detect implicit patch invocation (raw patch without apply_patch command) + if (argv.length === 1) { + try { + parsePatch(argv[0]) + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(ApplyPatchError.ImplicitInvocation), + } + } catch { + // Not a patch, continue + } + } + + const result = maybeParseApplyPatch(argv) + + switch (result.type) { + case MaybeApplyPatch.Body: + const { args } = result + const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd + const changes = new Map() + + for (const hunk of args.hunks) { + const resolvedPath = path.resolve( + effectiveCwd, + hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, + ) + + switch (hunk.type) { + case "add": + changes.set(resolvedPath, { + type: "add", + content: hunk.contents, + }) + break + + case "delete": + // For delete, we need to read the current content + const deletePath = path.resolve(effectiveCwd, hunk.path) + try { + const content = await fs.readFile(deletePath, "utf-8") + changes.set(resolvedPath, { + type: "delete", + content, + }) + } catch { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(`Failed to read file for deletion: ${deletePath}`), + } + } + break + + case "update": + const updatePath = path.resolve(effectiveCwd, hunk.path) + try { + const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks) + changes.set(resolvedPath, { + type: "update", + unified_diff: fileUpdate.unified_diff, + move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined, + new_content: fileUpdate.content, + }) + } catch (error) { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: error as Error, + } + } + break + } + } + + return { + type: MaybeApplyPatchVerified.Body, + action: { + changes, + patch: args.patch, + cwd: effectiveCwd, + }, + } + + case MaybeApplyPatch.PatchParseError: + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: result.error, + } + + case MaybeApplyPatch.NotApplyPatch: + return { type: MaybeApplyPatchVerified.NotApplyPatch } + } +} From ce4e47a2e3456924b9a8306d63ab2241772d02f5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:16:01 -0400 Subject: [PATCH 32/75] feat: unwrap uformat namespace to flat exports + barrel (#22703) --- packages/opencode/src/format/format.ts | 192 ++++++++++++++++++++++++ packages/opencode/src/format/index.ts | 195 +------------------------ 2 files changed, 193 insertions(+), 194 deletions(-) create mode 100644 packages/opencode/src/format/format.ts diff --git a/packages/opencode/src/format/format.ts b/packages/opencode/src/format/format.ts new file mode 100644 index 0000000000..6df00d3db3 --- /dev/null +++ b/packages/opencode/src/format/format.ts @@ -0,0 +1,192 @@ +import { Effect, Layer, Context } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { InstanceState } from "@/effect/instance-state" +import path from "path" +import { mergeDeep } from "remeda" +import z from "zod" +import { Config } from "../config" +import { Log } from "../util/log" +import * as Formatter from "./formatter" + +const log = Log.create({ service: "format" }) + +export const Status = z + .object({ + name: z.string(), + extensions: z.string().array(), + enabled: z.boolean(), + }) + .meta({ + ref: "FormatterStatus", + }) +export type Status = z.infer + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly file: (filepath: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Format") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const state = yield* InstanceState.make( + Effect.fn("Format.state")(function* (_ctx) { + const commands: Record = {} + const formatters: Record = {} + + const cfg = yield* config.get() + + if (cfg.formatter !== false) { + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + // Ruff and uv are both the same formatter, so disabling either should disable both. + if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) { + // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. + delete formatters.ruff + delete formatters.uv + continue + } + if (item.disabled) { + delete formatters[name] + continue + } + const info = mergeDeep(formatters[name] ?? {}, { + extensions: [], + ...item, + }) + + formatters[name] = { + ...info, + name, + enabled: async () => info.command ?? false, + } + } + } else { + log.info("all formatters are disabled") + } + + async function getCommand(item: Formatter.Info) { + let cmd = commands[item.name] + if (cmd === false || cmd === undefined) { + cmd = await item.enabled() + commands[item.name] = cmd + } + return cmd + } + + async function isEnabled(item: Formatter.Info) { + const cmd = await getCommand(item) + return cmd !== false + } + + async function getFormatter(ext: string) { + const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) + const checks = await Promise.all( + matching.map(async (item) => { + log.info("checking", { name: item.name, ext }) + const cmd = await getCommand(item) + if (cmd) { + log.info("enabled", { name: item.name, ext }) + } + return { + item, + cmd, + } + }), + ) + return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) + } + + function formatFile(filepath: string) { + return Effect.gen(function* () { + log.info("formatting", { file: filepath }) + const ext = path.extname(filepath) + + for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { + if (cmd === false) continue + log.info("running", { command: cmd }) + const replaced = cmd.map((x) => x.replace("$FILE", filepath)) + const dir = yield* InstanceState.directory + const code = yield* spawner + .spawn( + ChildProcess.make(replaced[0]!, replaced.slice(1), { + cwd: dir, + env: item.environment, + extendEnv: true, + }), + ) + .pipe( + Effect.flatMap((handle) => handle.exitCode), + Effect.scoped, + Effect.catch(() => + Effect.sync(() => { + log.error("failed to format file", { + error: "spawn failed", + command: cmd, + ...item.environment, + file: filepath, + }) + return ChildProcessSpawner.ExitCode(1) + }), + ), + ) + if (code !== 0) { + log.error("failed", { + command: cmd, + ...item.environment, + }) + } + } + }) + } + + log.info("init") + + return { + formatters, + isEnabled, + formatFile, + } + }), + ) + + const init = Effect.fn("Format.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("Format.status")(function* () { + const { formatters, isEnabled } = yield* InstanceState.get(state) + const result: Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + const file = Effect.fn("Format.file")(function* (filepath: string) { + const { formatFile } = yield* InstanceState.get(state) + yield* formatFile(filepath) + }) + + return Service.of({ init, status, file }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), +) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index d65ed2944e..435c517ac7 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,194 +1 @@ -import { Effect, Layer, Context } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" -import path from "path" -import { mergeDeep } from "remeda" -import z from "zod" -import { Config } from "../config" -import { Log } from "../util/log" -import * as Formatter from "./formatter" - -export namespace Format { - const log = Log.create({ service: "format" }) - - export const Status = z - .object({ - name: z.string(), - extensions: z.string().array(), - enabled: z.boolean(), - }) - .meta({ - ref: "FormatterStatus", - }) - export type Status = z.infer - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly file: (filepath: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const state = yield* InstanceState.make( - Effect.fn("Format.state")(function* (_ctx) { - const commands: Record = {} - const formatters: Record = {} - - const cfg = yield* config.get() - - if (cfg.formatter !== false) { - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - // Ruff and uv are both the same formatter, so disabling either should disable both. - if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) { - // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. - delete formatters.ruff - delete formatters.uv - continue - } - if (item.disabled) { - delete formatters[name] - continue - } - const info = mergeDeep(formatters[name] ?? {}, { - extensions: [], - ...item, - }) - - formatters[name] = { - ...info, - name, - enabled: async () => info.command ?? false, - } - } - } else { - log.info("all formatters are disabled") - } - - async function getCommand(item: Formatter.Info) { - let cmd = commands[item.name] - if (cmd === false || cmd === undefined) { - cmd = await item.enabled() - commands[item.name] = cmd - } - return cmd - } - - async function isEnabled(item: Formatter.Info) { - const cmd = await getCommand(item) - return cmd !== false - } - - async function getFormatter(ext: string) { - const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) - const checks = await Promise.all( - matching.map(async (item) => { - log.info("checking", { name: item.name, ext }) - const cmd = await getCommand(item) - if (cmd) { - log.info("enabled", { name: item.name, ext }) - } - return { - item, - cmd, - } - }), - ) - return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) - } - - function formatFile(filepath: string) { - return Effect.gen(function* () { - log.info("formatting", { file: filepath }) - const ext = path.extname(filepath) - - for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { - if (cmd === false) continue - log.info("running", { command: cmd }) - const replaced = cmd.map((x) => x.replace("$FILE", filepath)) - const dir = yield* InstanceState.directory - const code = yield* spawner - .spawn( - ChildProcess.make(replaced[0]!, replaced.slice(1), { - cwd: dir, - env: item.environment, - extendEnv: true, - }), - ) - .pipe( - Effect.flatMap((handle) => handle.exitCode), - Effect.scoped, - Effect.catch(() => - Effect.sync(() => { - log.error("failed to format file", { - error: "spawn failed", - command: cmd, - ...item.environment, - file: filepath, - }) - return ChildProcessSpawner.ExitCode(1) - }), - ), - ) - if (code !== 0) { - log.error("failed", { - command: cmd, - ...item.environment, - }) - } - } - }) - } - - log.info("init") - - return { - formatters, - isEnabled, - formatFile, - } - }), - ) - - const init = Effect.fn("Format.init")(function* () { - yield* InstanceState.get(state) - }) - - const status = Effect.fn("Format.status")(function* () { - const { formatters, isEnabled } = yield* InstanceState.get(state) - const result: Status[] = [] - for (const formatter of Object.values(formatters)) { - const isOn = yield* Effect.promise(() => isEnabled(formatter)) - result.push({ - name: formatter.name, - extensions: formatter.extensions, - enabled: isOn, - }) - } - return result - }) - - const file = Effect.fn("Format.file")(function* (filepath: string) { - const { formatFile } = yield* InstanceState.get(state) - yield* formatFile(filepath) - }) - - return Service.of({ init, status, file }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ) -} +export * as Format from "./format" From bb90aa6cb2e9c39e43420da29927250f384e1ca0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:16:17 -0400 Subject: [PATCH 33/75] feat: unwrap uworktree namespace to flat exports + barrel (#22717) --- packages/opencode/src/worktree/index.ts | 601 +-------------------- packages/opencode/src/worktree/worktree.ts | 598 ++++++++++++++++++++ 2 files changed, 599 insertions(+), 600 deletions(-) create mode 100644 packages/opencode/src/worktree/worktree.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 14a3a0dc9b..39bf94d69b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,600 +1 @@ -import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Global } from "../global" -import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" -import { Project } from "../project/project" -import { Database, eq } from "../storage/db" -import { ProjectTable } from "../project/project.sql" -import type { ProjectID } from "../project/schema" -import { Log } from "../util/log" -import { Slug } from "@opencode-ai/shared/util/slug" -import { errorMessage } from "../util/error" -import { BusEvent } from "@/bus/bus-event" -import { GlobalBus } from "@/bus/global" -import { Git } from "@/git" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { BootstrapRuntime } from "@/effect/bootstrap-runtime" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" - -export namespace Worktree { - const log = Log.create({ service: "worktree" }) - - export const Event = { - Ready: BusEvent.define( - "worktree.ready", - z.object({ - name: z.string(), - branch: z.string(), - }), - ), - Failed: BusEvent.define( - "worktree.failed", - z.object({ - message: z.string(), - }), - ), - } - - export const Info = z - .object({ - name: z.string(), - branch: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Worktree", - }) - - export type Info = z.infer - - export const CreateInput = z - .object({ - name: z.string().optional(), - startCommand: z - .string() - .optional() - .describe("Additional startup script to run after the project's start command"), - }) - .meta({ - ref: "WorktreeCreateInput", - }) - - export type CreateInput = z.infer - - export const RemoveInput = z - .object({ - directory: z.string(), - }) - .meta({ - ref: "WorktreeRemoveInput", - }) - - export type RemoveInput = z.infer - - export const ResetInput = z - .object({ - directory: z.string(), - }) - .meta({ - ref: "WorktreeResetInput", - }) - - export type ResetInput = z.infer - - export const NotGitError = NamedError.create( - "WorktreeNotGitError", - z.object({ - message: z.string(), - }), - ) - - export const NameGenerationFailedError = NamedError.create( - "WorktreeNameGenerationFailedError", - z.object({ - message: z.string(), - }), - ) - - export const CreateFailedError = NamedError.create( - "WorktreeCreateFailedError", - z.object({ - message: z.string(), - }), - ) - - export const StartCommandFailedError = NamedError.create( - "WorktreeStartCommandFailedError", - z.object({ - message: z.string(), - }), - ) - - export const RemoveFailedError = NamedError.create( - "WorktreeRemoveFailedError", - z.object({ - message: z.string(), - }), - ) - - export const ResetFailedError = NamedError.create( - "WorktreeResetFailedError", - z.object({ - message: z.string(), - }), - ) - - function slugify(input: string) { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, "") - } - - function failedRemoves(...chunks: string[]) { - return chunks.filter(Boolean).flatMap((chunk) => - chunk - .split("\n") - .map((line) => line.trim()) - .flatMap((line) => { - const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i) - if (!match) return [] - const value = match[1]?.trim().replace(/^['"]|['"]$/g, "") - if (!value) return [] - return [value] - }), - ) - } - - // --------------------------------------------------------------------------- - // Effect service - // --------------------------------------------------------------------------- - - export interface Interface { - readonly makeWorktreeInfo: (name?: string) => Effect.Effect - readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect - readonly create: (input?: CreateInput) => Effect.Effect - readonly remove: (input: RemoveInput) => Effect.Effect - readonly reset: (input: ResetInput) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Worktree") {} - - type GitResult = { code: number; text: string; stderr: string } - - export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service - > = Layer.effect( - Service, - Effect.gen(function* () { - const scope = yield* Scope.Scope - const fs = yield* AppFileSystem.Service - const pathSvc = yield* Path.Path - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const gitSvc = yield* Git.Service - const project = yield* Project.Service - - const git = Effect.fnUntraced( - function* (args: string[], opts?: { cwd?: string }) { - const handle = yield* spawner.spawn( - ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), - ) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((e) => - Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult), - ), - ) - - const MAX_NAME_ATTEMPTS = 26 - const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { - const ctx = yield* InstanceState.context - for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { - const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() - const branch = `opencode/${name}` - const directory = pathSvc.join(root, name) - - if (yield* fs.exists(directory).pipe(Effect.orDie)) continue - - const ref = `refs/heads/${branch}` - const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) - if (branchCheck.code === 0) continue - - return Info.parse({ name, branch, directory }) - } - throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) - }) - - const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) { - const ctx = yield* InstanceState.context - if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) - } - - const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) - yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) - - const base = name ? slugify(name) : "" - return yield* candidate(root, base || undefined) - }) - - const setup = Effect.fnUntraced(function* (info: Info) { - const ctx = yield* InstanceState.context - const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { - cwd: ctx.worktree, - }) - if (created.code !== 0) { - throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) - } - - yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) - }) - - const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { - const ctx = yield* InstanceState.context - const workspaceID = yield* InstanceState.workspaceID - const projectID = ctx.project.id - const extra = startCommand?.trim() - - const populated = yield* git(["reset", "--hard"], { cwd: info.directory }) - if (populated.code !== 0) { - const message = populated.stderr || populated.text || "Failed to populate worktree" - log.error("worktree checkout failed", { directory: info.directory, message }) - GlobalBus.emit("event", { - directory: info.directory, - project: ctx.project.id, - workspace: workspaceID, - payload: { type: Event.Failed.type, properties: { message } }, - }) - return - } - - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { - const message = errorMessage(error) - log.error("worktree bootstrap failed", { directory: info.directory, message }) - GlobalBus.emit("event", { - directory: info.directory, - project: ctx.project.id, - workspace: workspaceID, - payload: { type: Event.Failed.type, properties: { message } }, - }) - return false - }), - ) - if (!booted) return - - GlobalBus.emit("event", { - directory: info.directory, - project: ctx.project.id, - workspace: workspaceID, - payload: { - type: Event.Ready.type, - properties: { name: info.name, branch: info.branch }, - }, - }) - - yield* runStartScripts(info.directory, { projectID, extra }) - }) - - const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { - yield* setup(info) - yield* boot(info, startCommand) - }) - - const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { - const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) - return info - }) - - const canonical = Effect.fnUntraced(function* (input: string) { - const abs = pathSvc.resolve(input) - const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) - const normalized = pathSvc.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized - }) - - function parseWorktreeList(text: string) { - return text - .split("\n") - .map((line) => line.trim()) - .reduce<{ path?: string; branch?: string }[]>((acc, line) => { - if (!line) return acc - if (line.startsWith("worktree ")) { - acc.push({ path: line.slice("worktree ".length).trim() }) - return acc - } - const current = acc[acc.length - 1] - if (!current) return acc - if (line.startsWith("branch ")) { - current.branch = line.slice("branch ".length).trim() - } - return acc - }, []) - } - - const locateWorktree = Effect.fnUntraced(function* ( - entries: { path?: string; branch?: string }[], - directory: string, - ) { - for (const item of entries) { - if (!item.path) continue - const key = yield* canonical(item.path) - if (key === directory) return item - } - return undefined - }) - - function stopFsmonitor(target: string) { - return fs.exists(target).pipe( - Effect.orDie, - Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)), - ) - } - - function cleanDirectory(target: string) { - return Effect.promise(() => - import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) - .catch((error) => { - const message = errorMessage(error) - throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) - }), - ) - } - - const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { - if (Instance.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) - } - - const directory = yield* canonical(input.directory) - - const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) - if (list.code !== 0) { - throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) - } - - const entries = parseWorktreeList(list.text) - const entry = yield* locateWorktree(entries, directory) - - if (!entry?.path) { - const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) - if (directoryExists) { - yield* stopFsmonitor(directory) - yield* cleanDirectory(directory) - } - return true - } - - yield* stopFsmonitor(entry.path) - const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree }) - if (removed.code !== 0) { - const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) - if (next.code !== 0) { - throw new RemoveFailedError({ - message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", - }) - } - - const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) - if (stale?.path) { - throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) - } - } - - yield* cleanDirectory(entry.path) - - const branch = entry.branch?.replace(/^refs\/heads\//, "") - if (branch) { - const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree }) - if (deleted.code !== 0) { - throw new RemoveFailedError({ - message: deleted.stderr || deleted.text || "Failed to delete worktree branch", - }) - } - } - - return true - }) - - const gitExpect = Effect.fnUntraced(function* ( - args: string[], - opts: { cwd: string }, - error: (r: GitResult) => Error, - ) { - const result = yield* git(args, opts) - if (result.code !== 0) throw error(result) - return result - }) - - const runStartCommand = Effect.fnUntraced( - function* (directory: string, cmd: string) { - const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]] - const handle = yield* spawner.spawn( - ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }), - ) - // Drain stdout, capture stderr for error reporting - const [, stderr] = yield* Effect.all( - [Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ).pipe(Effect.orDie) - const code = yield* handle.exitCode - return { code, stderr } - }, - Effect.scoped, - Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })), - ) - - const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) { - const text = cmd.trim() - if (!text) return true - const result = yield* runStartCommand(directory, text) - if (result.code === 0) return true - log.error("worktree start command failed", { kind, directory, message: result.stderr }) - return false - }) - - const runStartScripts = Effect.fnUntraced(function* ( - directory: string, - input: { projectID: ProjectID; extra?: string }, - ) { - const row = yield* Effect.sync(() => - Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()), - ) - const project = row ? Project.fromRow(row) : undefined - const startup = project?.commands?.start?.trim() ?? "" - const ok = yield* runStartScript(directory, startup, "project") - if (!ok) return false - yield* runStartScript(directory, input.extra ?? "", "worktree") - return true - }) - - const prune = Effect.fnUntraced(function* (root: string, entries: string[]) { - const base = yield* canonical(root) - yield* Effect.forEach( - entries, - (entry) => - Effect.gen(function* () { - const target = yield* canonical(pathSvc.resolve(root, entry)) - if (target === base) return - if (!target.startsWith(`${base}${pathSvc.sep}`)) return - yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore) - }), - { concurrency: "unbounded" }, - ) - }) - - const sweep = Effect.fnUntraced(function* (root: string) { - const first = yield* git(["clean", "-ffdx"], { cwd: root }) - if (first.code === 0) return first - - const entries = failedRemoves(first.stderr, first.text) - if (!entries.length) return first - - yield* prune(root, entries) - return yield* git(["clean", "-ffdx"], { cwd: root }) - }) - - const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { - if (Instance.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) - } - - const directory = yield* canonical(input.directory) - const primary = yield* canonical(Instance.worktree) - if (directory === primary) { - throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) - } - - const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) - if (list.code !== 0) { - throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) - } - - const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) - if (!entry?.path) { - throw new ResetFailedError({ message: "Worktree not found" }) - } - - const worktreePath = entry.path - - const base = yield* gitSvc.defaultBranch(Instance.worktree) - if (!base) { - throw new ResetFailedError({ message: "Default branch not found" }) - } - - const sep = base.ref.indexOf("/") - if (base.ref !== base.name && sep > 0) { - const remote = base.ref.slice(0, sep) - const branch = base.ref.slice(sep + 1) - yield* gitExpect( - ["fetch", remote, branch], - { cwd: Instance.worktree }, - (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }), - ) - } - - yield* gitExpect( - ["reset", "--hard", base.ref], - { cwd: worktreePath }, - (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }), - ) - - const cleanResult = yield* sweep(worktreePath) - if (cleanResult.code !== 0) { - throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) - } - - yield* gitExpect( - ["submodule", "update", "--init", "--recursive", "--force"], - { cwd: worktreePath }, - (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }), - ) - - yield* gitExpect( - ["submodule", "foreach", "--recursive", "git", "reset", "--hard"], - { cwd: worktreePath }, - (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }), - ) - - yield* gitExpect( - ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], - { cwd: worktreePath }, - (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }), - ) - - const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) - if (status.code !== 0) { - throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) - } - - if (status.text.trim()) { - throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) - } - - yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))), - Effect.forkIn(scope), - ) - - return true - }) - - return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodePath.layer), - ) -} +export * as Worktree from "./worktree" diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts new file mode 100644 index 0000000000..9280b7a52e --- /dev/null +++ b/packages/opencode/src/worktree/worktree.ts @@ -0,0 +1,598 @@ +import z from "zod" +import { NamedError } from "@opencode-ai/shared/util/error" +import { Global } from "../global" +import { Instance } from "../project/instance" +import { InstanceBootstrap } from "../project/bootstrap" +import { Project } from "../project/project" +import { Database, eq } from "../storage/db" +import { ProjectTable } from "../project/project.sql" +import type { ProjectID } from "../project/schema" +import { Log } from "../util/log" +import { Slug } from "@opencode-ai/shared/util/slug" +import { errorMessage } from "../util/error" +import { BusEvent } from "@/bus/bus-event" +import { GlobalBus } from "@/bus/global" +import { Git } from "@/git" +import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodePath } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { BootstrapRuntime } from "@/effect/bootstrap-runtime" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { InstanceState } from "@/effect/instance-state" + +const log = Log.create({ service: "worktree" }) + +export const Event = { + Ready: BusEvent.define( + "worktree.ready", + z.object({ + name: z.string(), + branch: z.string(), + }), + ), + Failed: BusEvent.define( + "worktree.failed", + z.object({ + message: z.string(), + }), + ), +} + +export const Info = z + .object({ + name: z.string(), + branch: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Worktree", + }) + +export type Info = z.infer + +export const CreateInput = z + .object({ + name: z.string().optional(), + startCommand: z + .string() + .optional() + .describe("Additional startup script to run after the project's start command"), + }) + .meta({ + ref: "WorktreeCreateInput", + }) + +export type CreateInput = z.infer + +export const RemoveInput = z + .object({ + directory: z.string(), + }) + .meta({ + ref: "WorktreeRemoveInput", + }) + +export type RemoveInput = z.infer + +export const ResetInput = z + .object({ + directory: z.string(), + }) + .meta({ + ref: "WorktreeResetInput", + }) + +export type ResetInput = z.infer + +export const NotGitError = NamedError.create( + "WorktreeNotGitError", + z.object({ + message: z.string(), + }), +) + +export const NameGenerationFailedError = NamedError.create( + "WorktreeNameGenerationFailedError", + z.object({ + message: z.string(), + }), +) + +export const CreateFailedError = NamedError.create( + "WorktreeCreateFailedError", + z.object({ + message: z.string(), + }), +) + +export const StartCommandFailedError = NamedError.create( + "WorktreeStartCommandFailedError", + z.object({ + message: z.string(), + }), +) + +export const RemoveFailedError = NamedError.create( + "WorktreeRemoveFailedError", + z.object({ + message: z.string(), + }), +) + +export const ResetFailedError = NamedError.create( + "WorktreeResetFailedError", + z.object({ + message: z.string(), + }), +) + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} + +function failedRemoves(...chunks: string[]) { + return chunks.filter(Boolean).flatMap((chunk) => + chunk + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i) + if (!match) return [] + const value = match[1]?.trim().replace(/^['"]|['"]$/g, "") + if (!value) return [] + return [value] + }), + ) +} + +// --------------------------------------------------------------------------- +// Effect service +// --------------------------------------------------------------------------- + +export interface Interface { + readonly makeWorktreeInfo: (name?: string) => Effect.Effect + readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly remove: (input: RemoveInput) => Effect.Effect + readonly reset: (input: ResetInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Worktree") {} + +type GitResult = { code: number; text: string; stderr: string } + +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const scope = yield* Scope.Scope + const fs = yield* AppFileSystem.Service + const pathSvc = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const gitSvc = yield* Git.Service + const project = yield* Project.Service + + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((e) => + Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult), + ), + ) + + const MAX_NAME_ATTEMPTS = 26 + const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { + const ctx = yield* InstanceState.context + for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { + const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() + const branch = `opencode/${name}` + const directory = pathSvc.join(root, name) + + if (yield* fs.exists(directory).pipe(Effect.orDie)) continue + + const ref = `refs/heads/${branch}` + const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) + if (branchCheck.code === 0) continue + + return Info.parse({ name, branch, directory }) + } + throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) + }) + + const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) + yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) + + const base = name ? slugify(name) : "" + return yield* candidate(root, base || undefined) + }) + + const setup = Effect.fnUntraced(function* (info: Info) { + const ctx = yield* InstanceState.context + const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + cwd: ctx.worktree, + }) + if (created.code !== 0) { + throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) + } + + yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) + }) + + const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { + const ctx = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID + const projectID = ctx.project.id + const extra = startCommand?.trim() + + const populated = yield* git(["reset", "--hard"], { cwd: info.directory }) + if (populated.code !== 0) { + const message = populated.stderr || populated.text || "Failed to populate worktree" + log.error("worktree checkout failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { type: Event.Failed.type, properties: { message } }, + }) + return + } + + const booted = yield* Effect.promise(() => + Instance.provide({ + directory: info.directory, + init: () => BootstrapRuntime.runPromise(InstanceBootstrap), + fn: () => undefined, + }) + .then(() => true) + .catch((error) => { + const message = errorMessage(error) + log.error("worktree bootstrap failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { type: Event.Failed.type, properties: { message } }, + }) + return false + }), + ) + if (!booted) return + + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { + type: Event.Ready.type, + properties: { name: info.name, branch: info.branch }, + }, + }) + + yield* runStartScripts(info.directory, { projectID, extra }) + }) + + const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { + yield* setup(info) + yield* boot(info, startCommand) + }) + + const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { + const info = yield* makeWorktreeInfo(input?.name) + yield* setup(info) + yield* boot(info, input?.startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) + return info + }) + + const canonical = Effect.fnUntraced(function* (input: string) { + const abs = pathSvc.resolve(input) + const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) + const normalized = pathSvc.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized + }) + + function parseWorktreeList(text: string) { + return text + .split("\n") + .map((line) => line.trim()) + .reduce<{ path?: string; branch?: string }[]>((acc, line) => { + if (!line) return acc + if (line.startsWith("worktree ")) { + acc.push({ path: line.slice("worktree ".length).trim() }) + return acc + } + const current = acc[acc.length - 1] + if (!current) return acc + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).trim() + } + return acc + }, []) + } + + const locateWorktree = Effect.fnUntraced(function* ( + entries: { path?: string; branch?: string }[], + directory: string, + ) { + for (const item of entries) { + if (!item.path) continue + const key = yield* canonical(item.path) + if (key === directory) return item + } + return undefined + }) + + function stopFsmonitor(target: string) { + return fs.exists(target).pipe( + Effect.orDie, + Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)), + ) + } + + function cleanDirectory(target: string) { + return Effect.promise(() => + import("fs/promises") + .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) + .catch((error) => { + const message = errorMessage(error) + throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) + }), + ) + } + + const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { + if (Instance.project.vcs !== "git") { + throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const directory = yield* canonical(input.directory) + + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + if (list.code !== 0) { + throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + } + + const entries = parseWorktreeList(list.text) + const entry = yield* locateWorktree(entries, directory) + + if (!entry?.path) { + const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) + if (directoryExists) { + yield* stopFsmonitor(directory) + yield* cleanDirectory(directory) + } + return true + } + + yield* stopFsmonitor(entry.path) + const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree }) + if (removed.code !== 0) { + const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + if (next.code !== 0) { + throw new RemoveFailedError({ + message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", + }) + } + + const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) + if (stale?.path) { + throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) + } + } + + yield* cleanDirectory(entry.path) + + const branch = entry.branch?.replace(/^refs\/heads\//, "") + if (branch) { + const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree }) + if (deleted.code !== 0) { + throw new RemoveFailedError({ + message: deleted.stderr || deleted.text || "Failed to delete worktree branch", + }) + } + } + + return true + }) + + const gitExpect = Effect.fnUntraced(function* ( + args: string[], + opts: { cwd: string }, + error: (r: GitResult) => Error, + ) { + const result = yield* git(args, opts) + if (result.code !== 0) throw error(result) + return result + }) + + const runStartCommand = Effect.fnUntraced( + function* (directory: string, cmd: string) { + const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]] + const handle = yield* spawner.spawn( + ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }), + ) + // Drain stdout, capture stderr for error reporting + const [, stderr] = yield* Effect.all( + [Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ).pipe(Effect.orDie) + const code = yield* handle.exitCode + return { code, stderr } + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })), + ) + + const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) { + const text = cmd.trim() + if (!text) return true + const result = yield* runStartCommand(directory, text) + if (result.code === 0) return true + log.error("worktree start command failed", { kind, directory, message: result.stderr }) + return false + }) + + const runStartScripts = Effect.fnUntraced(function* ( + directory: string, + input: { projectID: ProjectID; extra?: string }, + ) { + const row = yield* Effect.sync(() => + Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()), + ) + const project = row ? Project.fromRow(row) : undefined + const startup = project?.commands?.start?.trim() ?? "" + const ok = yield* runStartScript(directory, startup, "project") + if (!ok) return false + yield* runStartScript(directory, input.extra ?? "", "worktree") + return true + }) + + const prune = Effect.fnUntraced(function* (root: string, entries: string[]) { + const base = yield* canonical(root) + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const target = yield* canonical(pathSvc.resolve(root, entry)) + if (target === base) return + if (!target.startsWith(`${base}${pathSvc.sep}`)) return + yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore) + }), + { concurrency: "unbounded" }, + ) + }) + + const sweep = Effect.fnUntraced(function* (root: string) { + const first = yield* git(["clean", "-ffdx"], { cwd: root }) + if (first.code === 0) return first + + const entries = failedRemoves(first.stderr, first.text) + if (!entries.length) return first + + yield* prune(root, entries) + return yield* git(["clean", "-ffdx"], { cwd: root }) + }) + + const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { + if (Instance.project.vcs !== "git") { + throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const directory = yield* canonical(input.directory) + const primary = yield* canonical(Instance.worktree) + if (directory === primary) { + throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) + } + + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + if (list.code !== 0) { + throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + } + + const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) + if (!entry?.path) { + throw new ResetFailedError({ message: "Worktree not found" }) + } + + const worktreePath = entry.path + + const base = yield* gitSvc.defaultBranch(Instance.worktree) + if (!base) { + throw new ResetFailedError({ message: "Default branch not found" }) + } + + const sep = base.ref.indexOf("/") + if (base.ref !== base.name && sep > 0) { + const remote = base.ref.slice(0, sep) + const branch = base.ref.slice(sep + 1) + yield* gitExpect( + ["fetch", remote, branch], + { cwd: Instance.worktree }, + (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }), + ) + } + + yield* gitExpect( + ["reset", "--hard", base.ref], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }), + ) + + const cleanResult = yield* sweep(worktreePath) + if (cleanResult.code !== 0) { + throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) + } + + yield* gitExpect( + ["submodule", "update", "--init", "--recursive", "--force"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }), + ) + + yield* gitExpect( + ["submodule", "foreach", "--recursive", "git", "reset", "--hard"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }), + ) + + yield* gitExpect( + ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }), + ) + + const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + if (status.code !== 0) { + throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) + } + + if (status.text.trim()) { + throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) + } + + yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))), + Effect.forkIn(scope), + ) + + return true + }) + + return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), +) From 0b975b01fbb4e2774fdd6d690675fd92b81d6029 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:16:42 -0400 Subject: [PATCH 34/75] feat: unwrap ugit namespace to flat exports + barrel (#22704) --- packages/opencode/src/git/git.ts | 258 ++++++++++++++++++++++++++++ packages/opencode/src/git/index.ts | 261 +---------------------------- 2 files changed, 259 insertions(+), 260 deletions(-) create mode 100644 packages/opencode/src/git/git.ts diff --git a/packages/opencode/src/git/git.ts b/packages/opencode/src/git/git.ts new file mode 100644 index 0000000000..908c718521 --- /dev/null +++ b/packages/opencode/src/git/git.ts @@ -0,0 +1,258 @@ +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Effect, Layer, Context, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" + +const cfg = [ + "--no-optional-locks", + "-c", + "core.autocrlf=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", +] as const + +const out = (result: { text(): string }) => result.text().trim() +const nuls = (text: string) => text.split("\0").filter(Boolean) +const fail = (err: unknown) => + ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + }) satisfies Result + +export type Kind = "added" | "deleted" | "modified" + +export type Base = { + readonly name: string + readonly ref: string +} + +export type Item = { + readonly file: string + readonly code: string + readonly status: Kind +} + +export type Stat = { + readonly file: string + readonly additions: number + readonly deletions: number +} + +export interface Result { + readonly exitCode: number + readonly text: () => string + readonly stdout: Buffer + readonly stderr: Buffer +} + +export interface Options { + readonly cwd: string + readonly env?: Record +} + +export interface Interface { + readonly run: (args: string[], opts: Options) => Effect.Effect + readonly branch: (cwd: string) => Effect.Effect + readonly prefix: (cwd: string) => Effect.Effect + readonly defaultBranch: (cwd: string) => Effect.Effect + readonly hasHead: (cwd: string) => Effect.Effect + readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect + readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect + readonly status: (cwd: string) => Effect.Effect + readonly diff: (cwd: string, ref: string) => Effect.Effect + readonly stats: (cwd: string, ref: string) => Effect.Effect +} + +const kind = (code: string): Kind => { + if (code === "??") return "added" + if (code.includes("U")) return "modified" + if (code.includes("A") && !code.includes("D")) return "added" + if (code.includes("D") && !code.includes("A")) return "deleted" + return "modified" +} + +export class Service extends Context.Service()("@opencode/Git") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const run = Effect.fn("Git.run")( + function* (args: string[], opts: Options) { + const proc = ChildProcess.make("git", [...cfg, ...args], { + cwd: opts.cwd, + env: opts.env, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + return { + exitCode: yield* handle.exitCode, + text: () => stdout, + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), + } satisfies Result + }, + Effect.scoped, + Effect.catch((err) => Effect.succeed(fail(err))), + ) + + const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { + return (yield* run(args, opts)).text() + }) + + const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { + return (yield* text(args, opts)) + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + }) + + const refs = Effect.fnUntraced(function* (cwd: string) { + return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) + }) + + const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { + const result = yield* run(["config", "init.defaultBranch"], { cwd }) + const name = out(result) + if (!name || !list.includes(name)) return + return { name, ref: name } satisfies Base + }) + + const primary = Effect.fnUntraced(function* (cwd: string) { + const list = yield* lines(["remote"], { cwd }) + if (list.includes("origin")) return "origin" + if (list.length === 1) return list[0] + if (list.includes("upstream")) return "upstream" + return list[0] + }) + + const branch = Effect.fn("Git.branch")(function* (cwd: string) { + const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) + if (result.exitCode !== 0) return "" + return out(result) + }) + + const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { + const remote = yield* primary(cwd) + if (remote) { + const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) + if (head.exitCode === 0) { + const ref = out(head).replace(/^refs\/remotes\//, "") + const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" + if (name) return { name, ref } satisfies Base + } + } + + const list = yield* refs(cwd) + const next = yield* configured(cwd, list) + if (next) return next + if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base + if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base + }) + + const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) + return result.exitCode === 0 + }) + + const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { + const result = yield* run(["merge-base", base, head], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { + const target = prefix ? `${prefix}${file}` : file + const result = yield* run(["show", `${ref}:${target}`], { cwd }) + if (result.exitCode !== 0) return "" + if (result.stdout.includes(0)) return "" + return result.text() + }) + + const status = Effect.fn("Git.status")(function* (cwd: string) { + return nuls( + yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { + cwd, + }), + ).flatMap((item) => { + const file = item.slice(3) + if (!file) return [] + const code = item.slice(0, 2) + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { + const list = nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), + ) + return list.flatMap((code, idx) => { + if (idx % 2 !== 0) return [] + const file = list[idx + 1] + if (!code || !file) return [] + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { + return nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), + ).flatMap((item) => { + const a = item.indexOf("\t") + const b = item.indexOf("\t", a + 1) + if (a === -1 || b === -1) return [] + const file = item.slice(b + 1) + if (!file) return [] + const adds = item.slice(0, a) + const dels = item.slice(a + 1, b) + const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) + const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) + return [ + { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat, + ] + }) + }) + + return Service.of({ + run, + branch, + prefix, + defaultBranch, + hasHead, + mergeBase, + show, + status, + diff, + stats, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index ac964ee0a0..019819d6e3 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -1,260 +1 @@ -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { Effect, Layer, Context, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" - -export namespace Git { - const cfg = [ - "--no-optional-locks", - "-c", - "core.autocrlf=false", - "-c", - "core.fsmonitor=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ] as const - - const out = (result: { text(): string }) => result.text().trim() - const nuls = (text: string) => text.split("\0").filter(Boolean) - const fail = (err: unknown) => - ({ - exitCode: 1, - text: () => "", - stdout: Buffer.alloc(0), - stderr: Buffer.from(err instanceof Error ? err.message : String(err)), - }) satisfies Result - - export type Kind = "added" | "deleted" | "modified" - - export type Base = { - readonly name: string - readonly ref: string - } - - export type Item = { - readonly file: string - readonly code: string - readonly status: Kind - } - - export type Stat = { - readonly file: string - readonly additions: number - readonly deletions: number - } - - export interface Result { - readonly exitCode: number - readonly text: () => string - readonly stdout: Buffer - readonly stderr: Buffer - } - - export interface Options { - readonly cwd: string - readonly env?: Record - } - - export interface Interface { - readonly run: (args: string[], opts: Options) => Effect.Effect - readonly branch: (cwd: string) => Effect.Effect - readonly prefix: (cwd: string) => Effect.Effect - readonly defaultBranch: (cwd: string) => Effect.Effect - readonly hasHead: (cwd: string) => Effect.Effect - readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect - readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect - readonly status: (cwd: string) => Effect.Effect - readonly diff: (cwd: string, ref: string) => Effect.Effect - readonly stats: (cwd: string, ref: string) => Effect.Effect - } - - const kind = (code: string): Kind => { - if (code === "??") return "added" - if (code.includes("U")) return "modified" - if (code.includes("A") && !code.includes("D")) return "added" - if (code.includes("D") && !code.includes("A")) return "deleted" - return "modified" - } - - export class Service extends Context.Service()("@opencode/Git") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fn("Git.run")( - function* (args: string[], opts: Options) { - const proc = ChildProcess.make("git", [...cfg, ...args], { - cwd: opts.cwd, - env: opts.env, - extendEnv: true, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - return { - exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), - } satisfies Result - }, - Effect.scoped, - Effect.catch((err) => Effect.succeed(fail(err))), - ) - - const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { - return (yield* run(args, opts)).text() - }) - - const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { - return (yield* text(args, opts)) - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean) - }) - - const refs = Effect.fnUntraced(function* (cwd: string) { - return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) - }) - - const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { - const result = yield* run(["config", "init.defaultBranch"], { cwd }) - const name = out(result) - if (!name || !list.includes(name)) return - return { name, ref: name } satisfies Base - }) - - const primary = Effect.fnUntraced(function* (cwd: string) { - const list = yield* lines(["remote"], { cwd }) - if (list.includes("origin")) return "origin" - if (list.length === 1) return list[0] - if (list.includes("upstream")) return "upstream" - return list[0] - }) - - const branch = Effect.fn("Git.branch")(function* (cwd: string) { - const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) - if (result.exitCode !== 0) return "" - return out(result) - }) - - const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { - const remote = yield* primary(cwd) - if (remote) { - const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) - if (head.exitCode === 0) { - const ref = out(head).replace(/^refs\/remotes\//, "") - const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" - if (name) return { name, ref } satisfies Base - } - } - - const list = yield* refs(cwd) - const next = yield* configured(cwd, list) - if (next) return next - if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base - if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base - }) - - const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) - return result.exitCode === 0 - }) - - const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { - const result = yield* run(["merge-base", base, head], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { - const target = prefix ? `${prefix}${file}` : file - const result = yield* run(["show", `${ref}:${target}`], { cwd }) - if (result.exitCode !== 0) return "" - if (result.stdout.includes(0)) return "" - return result.text() - }) - - const status = Effect.fn("Git.status")(function* (cwd: string) { - return nuls( - yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { - cwd, - }), - ).flatMap((item) => { - const file = item.slice(3) - if (!file) return [] - const code = item.slice(0, 2) - return [{ file, code, status: kind(code) } satisfies Item] - }) - }) - - const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { - const list = nuls( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), - ) - return list.flatMap((code, idx) => { - if (idx % 2 !== 0) return [] - const file = list[idx + 1] - if (!code || !file) return [] - return [{ file, code, status: kind(code) } satisfies Item] - }) - }) - - const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { - return nuls( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), - ).flatMap((item) => { - const a = item.indexOf("\t") - const b = item.indexOf("\t", a + 1) - if (a === -1 || b === -1) return [] - const file = item.slice(b + 1) - if (!file) return [] - const adds = item.slice(0, a) - const dels = item.slice(a + 1, b) - const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) - const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) - return [ - { - file, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - } satisfies Stat, - ] - }) - }) - - return Service.of({ - run, - branch, - prefix, - defaultBranch, - hasHead, - mergeBase, - show, - status, - diff, - stats, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -} +export * as Git from "./git" From 62ddb9d3ad774f97d34c4004ee607fc86e69ddfc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:17:19 -0400 Subject: [PATCH 35/75] feat: unwrap uskill namespace to flat exports + barrel (#22714) --- packages/opencode/src/skill/index.ts | 265 +-------------------------- packages/opencode/src/skill/skill.ts | 262 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 264 deletions(-) create mode 100644 packages/opencode/src/skill/skill.ts diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 4bf5d0cfed..6d7b428dfb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,264 +1 @@ -import os from "os" -import path from "path" -import { pathToFileURL } from "url" -import z from "zod" -import { Effect, Layer, Context } from "effect" -import { NamedError } from "@opencode-ai/shared/util/error" -import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" -import { Flag } from "@/flag/flag" -import { Global } from "@/global" -import { Permission } from "@/permission" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Config } from "../config" -import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "@opencode-ai/shared/util/glob" -import { Log } from "../util/log" -import { Discovery } from "./discovery" - -export namespace Skill { - const log = Log.create({ service: "skill" }) - const EXTERNAL_DIRS = [".claude", ".agents"] - const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" - const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" - const SKILL_PATTERN = "**/SKILL.md" - - export const Info = z.object({ - name: z.string(), - description: z.string(), - location: z.string(), - content: z.string(), - }) - export type Info = z.infer - - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) - - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) - - type State = { - skills: Record - dirs: Set - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { - const md = yield* Effect.tryPromise({ - try: () => ConfigMarkdown.parse(match), - catch: (err) => err, - }).pipe( - Effect.catch( - Effect.fnUntraced(function* (err) { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - const { Session } = yield* Effect.promise(() => import("@/session")) - yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }), - ), - ) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - if (state.skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, - duplicate: match, - }) - } - - state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - }) - - const scan = Effect.fnUntraced(function* ( - state: State, - bus: Bus.Interface, - root: string, - pattern: string, - opts?: { dot?: boolean; scope?: string }, - ) { - const matches = yield* Effect.tryPromise({ - try: () => - Glob.scan(pattern, { - cwd: root, - absolute: true, - include: "file", - symlink: true, - dot: opts?.dot, - }), - catch: (error) => error, - }).pipe( - Effect.catch((error) => { - if (!opts?.scope) return Effect.die(error) - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - return Effect.succeed([] as string[]) - }), - ) - - yield* Effect.forEach(matches, (match) => add(state, match, bus), { - concurrency: "unbounded", - discard: true, - }) - }) - - const loadSkills = Effect.fnUntraced(function* ( - state: State, - config: Config.Interface, - discovery: Discovery.Interface, - bus: Bus.Interface, - fsys: AppFileSystem.Interface, - directory: string, - worktree: string, - ) { - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(yield* fsys.isDir(root))) continue - yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) - } - - const upDirs = yield* fsys - .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree }) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - - for (const root of upDirs) { - yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) - } - } - - const configDirs = yield* config.directories() - for (const dir of configDirs) { - yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN) - } - - const cfg = yield* config.get() - for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item - const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) - if (!(yield* fsys.isDir(dir))) { - log.warn("skill path not found", { path: dir }) - continue - } - - yield* scan(state, bus, dir, SKILL_PATTERN) - } - - for (const url of cfg.skills?.urls ?? []) { - const pulledDirs = yield* discovery.pull(url) - for (const dir of pulledDirs) { - state.dirs.add(dir) - yield* scan(state, bus, dir, SKILL_PATTERN) - } - } - - log.info("init", { count: Object.keys(state.skills).length }) - }) - - export class Service extends Context.Service()("@opencode/Skill") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const discovery = yield* Discovery.Service - const config = yield* Config.Service - const bus = yield* Bus.Service - const fsys = yield* AppFileSystem.Service - const state = yield* InstanceState.make( - Effect.fn("Skill.state")(function* (ctx) { - const s: State = { skills: {}, dirs: new Set() } - yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree) - return s - }), - ) - - const get = Effect.fn("Skill.get")(function* (name: string) { - const s = yield* InstanceState.get(state) - return s.skills[name] - }) - - const all = Effect.fn("Skill.all")(function* () { - const s = yield* InstanceState.get(state) - return Object.values(s.skills) - }) - - const dirs = Effect.fn("Skill.dirs")(function* () { - const s = yield* InstanceState.get(state) - return Array.from(s.dirs) - }) - - const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { - const s = yield* InstanceState.get(state) - const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name)) - if (!agent) return list - return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") - }) - - return Service.of({ get, all, dirs, available }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(AppFileSystem.defaultLayer), - ) - - export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." - if (opts.verbose) { - return [ - "", - ...list - .sort((a, b) => a.name.localeCompare(b.name)) - .flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return [ - "## Available Skills", - ...list - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((skill) => `- **${skill.name}**: ${skill.description}`), - ].join("\n") - } -} +export * as Skill from "./skill" diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts new file mode 100644 index 0000000000..afc6446678 --- /dev/null +++ b/packages/opencode/src/skill/skill.ts @@ -0,0 +1,262 @@ +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import z from "zod" +import { Effect, Layer, Context } from "effect" +import { NamedError } from "@opencode-ai/shared/util/error" +import type { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Permission } from "@/permission" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Config } from "../config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "@opencode-ai/shared/util/glob" +import { Log } from "../util/log" +import { Discovery } from "./discovery" + +const log = Log.create({ service: "skill" }) +const EXTERNAL_DIRS = [".claude", ".agents"] +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" + +export const Info = z.object({ + name: z.string(), + description: z.string(), + location: z.string(), + content: z.string(), +}) +export type Info = z.infer + +export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), +) + +export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), +) + +type State = { + skills: Record + dirs: Set +} + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect +} + +const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { + const md = yield* Effect.tryPromise({ + try: () => ConfigMarkdown.parse(match), + catch: (err) => err, + }).pipe( + Effect.catch( + Effect.fnUntraced(function* (err) { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = yield* Effect.promise(() => import("@/session")) + yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }), + ), + ) + + if (!md) return + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + if (state.skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: state.skills[parsed.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } +}) + +const scan = Effect.fnUntraced(function* ( + state: State, + bus: Bus.Interface, + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: string }, +) { + const matches = yield* Effect.tryPromise({ + try: () => + Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }), + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.die(error) + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) + return Effect.succeed([] as string[]) + }), + ) + + yield* Effect.forEach(matches, (match) => add(state, match, bus), { + concurrency: "unbounded", + discard: true, + }) +}) + +const loadSkills = Effect.fnUntraced(function* ( + state: State, + config: Config.Interface, + discovery: Discovery.Interface, + bus: Bus.Interface, + fsys: AppFileSystem.Interface, + directory: string, + worktree: string, +) { + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(yield* fsys.isDir(root))) continue + yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + const upDirs = yield* fsys + .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + for (const root of upDirs) { + yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + const configDirs = yield* config.directories() + for (const dir of configDirs) { + yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = yield* config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(yield* fsys.isDir(dir))) { + log.warn("skill path not found", { path: dir }) + continue + } + + yield* scan(state, bus, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + const pulledDirs = yield* discovery.pull(url) + for (const dir of pulledDirs) { + state.dirs.add(dir) + yield* scan(state, bus, dir, SKILL_PATTERN) + } + } + + log.info("init", { count: Object.keys(state.skills).length }) +}) + +export class Service extends Context.Service()("@opencode/Skill") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const config = yield* Config.Service + const bus = yield* Bus.Service + const fsys = yield* AppFileSystem.Service + const state = yield* InstanceState.make( + Effect.fn("Skill.state")(function* (ctx) { + const s: State = { skills: {}, dirs: new Set() } + yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree) + return s + }), + ) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.skills[name] + }) + + const all = Effect.fn("Skill.all")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + const s = yield* InstanceState.get(state) + return Array.from(s.dirs) + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const s = yield* InstanceState.get(state) + const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Discovery.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(AppFileSystem.defaultLayer), +) + +export function fmt(list: Info[], opts: { verbose: boolean }) { + if (list.length === 0) return "No skills are currently available." + if (opts.verbose) { + return [ + "", + ...list + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return [ + "## Available Skills", + ...list + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((skill) => `- **${skill.name}**: ${skill.description}`), + ].join("\n") +} From cf423d27693ab5718eb836bdba7ba3ed357204b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:17:59 -0400 Subject: [PATCH 36/75] fix: remove 10 unused type-only imports and declarations (#22696) --- packages/app/src/components/file-tree.tsx | 1 - packages/app/src/context/global-sync/types.ts | 1 - packages/app/src/i18n/ko.ts | 2 -- packages/opencode/src/cli/cmd/tui/context/sdk.tsx | 2 +- packages/opencode/src/cli/cmd/tui/plugin/api.tsx | 1 - .../opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx | 2 +- packages/opencode/test/fixture/plugin-meta-worker.ts | 7 ------- packages/opencode/test/mcp/oauth-auto-connect.test.ts | 1 - packages/opencode/test/session/prompt-effect.test.ts | 1 - packages/plugin/src/tui.ts | 1 - 10 files changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 8fbecf6712..211ce05ef0 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -14,7 +14,6 @@ import { Switch, untrack, type ComponentProps, - type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index b0f340a902..e3ec83c5ee 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -8,7 +8,6 @@ import type { Part, Path, PermissionRequest, - Project, ProviderListResponse, QuestionRequest, Session, diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0f2f7647ab..1c15720091 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -1,7 +1,5 @@ import { dict as en } from "./en" -type Keys = keyof typeof en - export const dict = { "command.category.suggested": "추천", "command.category.view": "보기", diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index ad35aa45c2..14d3062886 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,5 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 42bf78adbf..3af70d8c25 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -19,7 +19,6 @@ import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { Installation } from "@/installation" -import { type OpencodeClient } from "@opencode-ai/sdk/v2" type RouteEntry = { key: symbol diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 4442eb9e60..513d34910b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show, type JSX } from "solid-js" +import { onMount, Show } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogExportOptionsProps = { diff --git a/packages/opencode/test/fixture/plugin-meta-worker.ts b/packages/opencode/test/fixture/plugin-meta-worker.ts index 86284b4c73..c02b448ae7 100644 --- a/packages/opencode/test/fixture/plugin-meta-worker.ts +++ b/packages/opencode/test/fixture/plugin-meta-worker.ts @@ -1,10 +1,3 @@ -type Msg = { - file: string - spec: string - target: string - id: string -} - const raw = process.argv[2] if (!raw) throw new Error("Missing worker payload") diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 13ae0bb34d..89edd09084 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,6 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" import { Effect } from "effect" -import type { MCP as MCPNS } from "../../src/mcp/index" // Mock UnauthorizedError to match the SDK's class class MockUnauthorizedError extends Error { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 7a118cb050..5ff8bf3424 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -14,7 +14,6 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider" import { Env } from "../../src/env" -import type { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index e6f832f7e1..099cf27580 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -13,7 +13,6 @@ import type { QuestionRequest, SessionStatus, TextPart, - Workspace, Config as SdkConfig, } from "@opencode-ai/sdk/v2" import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core" From 069cef8a44f134a9a3517144b54ea5b1046bf6ff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 02:18:58 +0000 Subject: [PATCH 37/75] chore: generate --- packages/opencode/src/file/file.ts | 3 +-- packages/opencode/src/session/session.ts | 12 ++---------- packages/opencode/src/snapshot/snapshot.ts | 4 +--- packages/opencode/src/worktree/worktree.ts | 5 +---- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts index 657fe9a583..a101574f61 100644 --- a/packages/opencode/src/file/file.ts +++ b/packages/opencode/src/file/file.ts @@ -631,8 +631,7 @@ export const layer = Layer.effect( return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit) } - const items = - kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] + const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs] const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 369a2085ff..12ecd85529 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -258,11 +258,7 @@ export function plan(input: { slug: string; time: { created: number } }) { return path.join(base, [input.time.created, input.slug].join("-") + ".md") } -export const getUsage = (input: { - model: Provider.Model - usage: LanguageModelUsage - metadata?: ProviderMetadata -}) => { +export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 return value @@ -357,11 +353,7 @@ export interface Interface { readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly removePart: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) => Effect.Effect + readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect readonly getPart: (input: { sessionID: SessionID messageID: MessageID diff --git a/packages/opencode/src/snapshot/snapshot.ts b/packages/opencode/src/snapshot/snapshot.ts index 32c637a216..6624dee986 100644 --- a/packages/opencode/src/snapshot/snapshot.ts +++ b/packages/opencode/src/snapshot/snapshot.ts @@ -531,9 +531,7 @@ export const layer: Layer.Layer< if (row.status === "added") { return [ "", - yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), + yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), ] } if (row.status === "deleted") { diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts index 9280b7a52e..674d4d7570 100644 --- a/packages/opencode/src/worktree/worktree.ts +++ b/packages/opencode/src/worktree/worktree.ts @@ -54,10 +54,7 @@ export type Info = z.infer export const CreateInput = z .object({ name: z.string().optional(), - startCommand: z - .string() - .optional() - .describe("Additional startup script to run after the project's start command"), + startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"), }) .meta({ ref: "WorktreeCreateInput", From 60c927cf4faa5e95c7c7a3aeb1a43e5431a069fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:21:46 -0400 Subject: [PATCH 38/75] feat: unwrap Pty namespace to flat exports + barrel (#22719) --- packages/opencode/script/unwrap-namespace.ts | 8 +- packages/opencode/src/pty/index.ts | 365 +------------------ packages/opencode/src/pty/service.ts | 362 ++++++++++++++++++ 3 files changed, 368 insertions(+), 367 deletions(-) create mode 100644 packages/opencode/src/pty/service.ts diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts index bdb49a7fcf..45c16f6c73 100644 --- a/packages/opencode/script/unwrap-namespace.ts +++ b/packages/opencode/script/unwrap-namespace.ts @@ -5,6 +5,7 @@ * Usage: * bun script/unwrap-namespace.ts src/bus/index.ts * bun script/unwrap-namespace.ts src/bus/index.ts --dry-run + * bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts * * What it does: * 1. Reads the file and finds the `export namespace Foo { ... }` block @@ -24,10 +25,11 @@ import fs from "fs" const args = process.argv.slice(2) const dryRun = args.includes("--dry-run") -const filePath = args.find((a) => !a.startsWith("--")) +const nameFlag = args.find((a, i) => args[i - 1] === "--name") +const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name") if (!filePath) { - console.error("Usage: bun script/unwrap-namespace.ts [--dry-run]") + console.error("Usage: bun script/unwrap-namespace.ts [--dry-run] [--name ]") process.exit(1) } @@ -188,7 +190,7 @@ if (exportedNames.size > 0) { const dir = path.dirname(absPath) const basename = path.basename(absPath, ".ts") const isIndex = basename === "index" -const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename +const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename) const implFile = path.join(dir, `${implName}.ts`) const indexFile = path.join(dir, "index.ts") const barrelLine = `export * as ${nsName} from "./${implName}"\n` diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1c969b4b93..37cb4e49a8 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,364 +1 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" -import { Instance } from "@/project/instance" -import type { Proc } from "#pty" -import z from "zod" -import { Log } from "../util/log" -import { lazy } from "@opencode-ai/shared/util/lazy" -import { Shell } from "@/shell/shell" -import { Plugin } from "@/plugin" -import { PtyID } from "./schema" -import { Effect, Layer, Context } from "effect" -import { EffectBridge } from "@/effect/bridge" - -export namespace Pty { - const log = Log.create({ service: "pty" }) - - const BUFFER_LIMIT = 1024 * 1024 * 2 - const BUFFER_CHUNK = 64 * 1024 - const encoder = new TextEncoder() - - type Socket = { - readyState: number - data?: unknown - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void - } - - const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) - - type Active = { - info: Info - process: Proc - buffer: string - bufferCursor: number - cursor: number - subscribers: Map - } - - type State = { - dir: string - sessions: Map - } - - // WebSocket control frame: 0x00 + UTF-8 JSON. - const meta = (cursor: number) => { - const json = JSON.stringify({ cursor }) - const bytes = encoder.encode(json) - const out = new Uint8Array(bytes.length + 1) - out[0] = 0 - out.set(bytes, 1) - return out - } - - const pty = lazy(() => import("#pty")) - - export const Info = z - .object({ - id: PtyID.zod, - title: z.string(), - command: z.string(), - args: z.array(z.string()), - cwd: z.string(), - status: z.enum(["running", "exited"]), - pid: z.number(), - }) - .meta({ ref: "Pty" }) - - export type Info = z.infer - - export const CreateInput = z.object({ - command: z.string().optional(), - args: z.array(z.string()).optional(), - cwd: z.string().optional(), - title: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), - }) - - export type CreateInput = z.infer - - export const UpdateInput = z.object({ - title: z.string().optional(), - size: z - .object({ - rows: z.number(), - cols: z.number(), - }) - .optional(), - }) - - export type UpdateInput = z.infer - - export const Event = { - Created: BusEvent.define("pty.created", z.object({ info: Info })), - Updated: BusEvent.define("pty.updated", z.object({ info: Info })), - Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })), - Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })), - } - - export interface Interface { - readonly list: () => Effect.Effect - readonly get: (id: PtyID) => Effect.Effect - readonly create: (input: CreateInput) => Effect.Effect - readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect - readonly remove: (id: PtyID) => Effect.Effect - readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect - readonly write: (id: PtyID, data: string) => Effect.Effect - readonly connect: ( - id: PtyID, - ws: Socket, - cursor?: number, - ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined> - } - - export class Service extends Context.Service()("@opencode/Pty") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const plugin = yield* Plugin.Service - function teardown(session: Active) { - try { - session.process.kill() - } catch {} - for (const [sub, ws] of session.subscribers.entries()) { - try { - if (sock(ws) === sub) ws.close() - } catch {} - } - session.subscribers.clear() - } - - const state = yield* InstanceState.make( - Effect.fn("Pty.state")(function* (ctx) { - const state = { - dir: ctx.directory, - sessions: new Map(), - } - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const session of state.sessions.values()) { - teardown(session) - } - state.sessions.clear() - }), - ) - - return state - }), - ) - - const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) return - s.sessions.delete(id) - log.info("removing session", { id }) - teardown(session) - yield* bus.publish(Event.Deleted, { id: session.info.id }) - }) - - const list = Effect.fn("Pty.list")(function* () { - const s = yield* InstanceState.get(state) - return Array.from(s.sessions.values()).map((session) => session.info) - }) - - const get = Effect.fn("Pty.get")(function* (id: PtyID) { - const s = yield* InstanceState.get(state) - return s.sessions.get(id)?.info - }) - - const create = Effect.fn("Pty.create")(function* (input: CreateInput) { - const s = yield* InstanceState.get(state) - const bridge = yield* EffectBridge.make() - const id = PtyID.ascending() - const command = input.command || Shell.preferred() - const args = input.args || [] - if (Shell.login(command)) { - args.push("-l") - } - - const cwd = input.cwd || s.dir - const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) - const env = { - ...process.env, - ...input.env, - ...shell.env, - TERM: "xterm-256color", - OPENCODE_TERMINAL: "1", - } as Record - - if (process.platform === "win32") { - env.LC_ALL = "C.UTF-8" - env.LC_CTYPE = "C.UTF-8" - env.LANG = "C.UTF-8" - } - log.info("creating session", { id, cmd: command, args, cwd }) - - const { spawn } = yield* Effect.promise(() => pty()) - const proc = yield* Effect.sync(() => - spawn(command, args, { - name: "xterm-256color", - cwd, - env, - }), - ) - - const info = { - id, - title: input.title || `Terminal ${id.slice(-4)}`, - command, - args, - cwd, - status: "running", - pid: proc.pid, - } as const - const session: Active = { - info, - process: proc, - buffer: "", - bufferCursor: 0, - cursor: 0, - subscribers: new Map(), - } - s.sessions.set(id, session) - proc.onData( - Instance.bind((chunk) => { - session.cursor += chunk.length - - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1) { - session.subscribers.delete(key) - continue - } - if (sock(ws) !== key) { - session.subscribers.delete(key) - continue - } - try { - ws.send(chunk) - } catch { - session.subscribers.delete(key) - } - } - - session.buffer += chunk - if (session.buffer.length <= BUFFER_LIMIT) return - const excess = session.buffer.length - BUFFER_LIMIT - session.buffer = session.buffer.slice(excess) - session.bufferCursor += excess - }), - ) - proc.onExit( - Instance.bind(({ exitCode }) => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - bridge.fork(bus.publish(Event.Exited, { id, exitCode })) - bridge.fork(remove(id)) - }), - ) - yield* bus.publish(Event.Created, { info }) - return info - }) - - const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) return - if (input.title) { - session.info.title = input.title - } - if (input.size) { - session.process.resize(input.size.cols, input.size.rows) - } - yield* bus.publish(Event.Updated, { info: session.info }) - return session.info - }) - - const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (session && session.info.status === "running") { - session.process.resize(cols, rows) - } - }) - - const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (session && session.info.status === "running") { - session.process.write(data) - } - }) - - const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { - const s = yield* InstanceState.get(state) - const session = s.sessions.get(id) - if (!session) { - ws.close() - return - } - log.info("client connected to session", { id }) - - const sub = sock(ws) - session.subscribers.delete(sub) - session.subscribers.set(sub, ws) - - const cleanup = () => { - session.subscribers.delete(sub) - } - - const start = session.bufferCursor - const end = session.cursor - const from = - cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 - - const data = (() => { - if (!session.buffer) return "" - if (from >= end) return "" - const offset = Math.max(0, from - start) - if (offset >= session.buffer.length) return "" - return session.buffer.slice(offset) - })() - - if (data) { - try { - for (let i = 0; i < data.length; i += BUFFER_CHUNK) { - ws.send(data.slice(i, i + BUFFER_CHUNK)) - } - } catch { - cleanup() - ws.close() - return - } - } - - try { - ws.send(meta(end)) - } catch { - cleanup() - ws.close() - return - } - - return { - onMessage: (message: string | ArrayBuffer) => { - session.process.write(String(message)) - }, - onClose: () => { - log.info("client disconnected from session", { id }) - cleanup() - }, - } - }) - - return Service.of({ list, get, create, update, remove, resize, write, connect }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) -} +export * as Pty from "./service" diff --git a/packages/opencode/src/pty/service.ts b/packages/opencode/src/pty/service.ts new file mode 100644 index 0000000000..3359d0aabf --- /dev/null +++ b/packages/opencode/src/pty/service.ts @@ -0,0 +1,362 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { Instance } from "@/project/instance" +import type { Proc } from "#pty" +import z from "zod" +import { Log } from "../util/log" +import { lazy } from "@opencode-ai/shared/util/lazy" +import { Shell } from "@/shell/shell" +import { Plugin } from "@/plugin" +import { PtyID } from "./schema" +import { Effect, Layer, Context } from "effect" +import { EffectBridge } from "@/effect/bridge" + +const log = Log.create({ service: "pty" }) + +const BUFFER_LIMIT = 1024 * 1024 * 2 +const BUFFER_CHUNK = 64 * 1024 +const encoder = new TextEncoder() + +type Socket = { + readyState: number + data?: unknown + send: (data: string | Uint8Array | ArrayBuffer) => void + close: (code?: number, reason?: string) => void +} + +const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) + +type Active = { + info: Info + process: Proc + buffer: string + bufferCursor: number + cursor: number + subscribers: Map +} + +type State = { + dir: string + sessions: Map +} + +// WebSocket control frame: 0x00 + UTF-8 JSON. +const meta = (cursor: number) => { + const json = JSON.stringify({ cursor }) + const bytes = encoder.encode(json) + const out = new Uint8Array(bytes.length + 1) + out[0] = 0 + out.set(bytes, 1) + return out +} + +const pty = lazy(() => import("#pty")) + +export const Info = z + .object({ + id: PtyID.zod, + title: z.string(), + command: z.string(), + args: z.array(z.string()), + cwd: z.string(), + status: z.enum(["running", "exited"]), + pid: z.number(), + }) + .meta({ ref: "Pty" }) + +export type Info = z.infer + +export const CreateInput = z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + title: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), +}) + +export type CreateInput = z.infer + +export const UpdateInput = z.object({ + title: z.string().optional(), + size: z + .object({ + rows: z.number(), + cols: z.number(), + }) + .optional(), +}) + +export type UpdateInput = z.infer + +export const Event = { + Created: BusEvent.define("pty.created", z.object({ info: Info })), + Updated: BusEvent.define("pty.updated", z.object({ info: Info })), + Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })), + Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })), +} + +export interface Interface { + readonly list: () => Effect.Effect + readonly get: (id: PtyID) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect + readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect + readonly remove: (id: PtyID) => Effect.Effect + readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect + readonly write: (id: PtyID, data: string) => Effect.Effect + readonly connect: ( + id: PtyID, + ws: Socket, + cursor?: number, + ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined> +} + +export class Service extends Context.Service()("@opencode/Pty") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const plugin = yield* Plugin.Service + function teardown(session: Active) { + try { + session.process.kill() + } catch {} + for (const [sub, ws] of session.subscribers.entries()) { + try { + if (sock(ws) === sub) ws.close() + } catch {} + } + session.subscribers.clear() + } + + const state = yield* InstanceState.make( + Effect.fn("Pty.state")(function* (ctx) { + const state = { + dir: ctx.directory, + sessions: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const session of state.sessions.values()) { + teardown(session) + } + state.sessions.clear() + }), + ) + + return state + }), + ) + + const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { + const s = yield* InstanceState.get(state) + const session = s.sessions.get(id) + if (!session) return + s.sessions.delete(id) + log.info("removing session", { id }) + teardown(session) + yield* bus.publish(Event.Deleted, { id: session.info.id }) + }) + + const list = Effect.fn("Pty.list")(function* () { + const s = yield* InstanceState.get(state) + return Array.from(s.sessions.values()).map((session) => session.info) + }) + + const get = Effect.fn("Pty.get")(function* (id: PtyID) { + const s = yield* InstanceState.get(state) + return s.sessions.get(id)?.info + }) + + const create = Effect.fn("Pty.create")(function* (input: CreateInput) { + const s = yield* InstanceState.get(state) + const bridge = yield* EffectBridge.make() + const id = PtyID.ascending() + const command = input.command || Shell.preferred() + const args = input.args || [] + if (Shell.login(command)) { + args.push("-l") + } + + const cwd = input.cwd || s.dir + const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) + const env = { + ...process.env, + ...input.env, + ...shell.env, + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + } as Record + + if (process.platform === "win32") { + env.LC_ALL = "C.UTF-8" + env.LC_CTYPE = "C.UTF-8" + env.LANG = "C.UTF-8" + } + log.info("creating session", { id, cmd: command, args, cwd }) + + const { spawn } = yield* Effect.promise(() => pty()) + const proc = yield* Effect.sync(() => + spawn(command, args, { + name: "xterm-256color", + cwd, + env, + }), + ) + + const info = { + id, + title: input.title || `Terminal ${id.slice(-4)}`, + command, + args, + cwd, + status: "running", + pid: proc.pid, + } as const + const session: Active = { + info, + process: proc, + buffer: "", + bufferCursor: 0, + cursor: 0, + subscribers: new Map(), + } + s.sessions.set(id, session) + proc.onData( + Instance.bind((chunk) => { + session.cursor += chunk.length + + for (const [key, ws] of session.subscribers.entries()) { + if (ws.readyState !== 1) { + session.subscribers.delete(key) + continue + } + if (sock(ws) !== key) { + session.subscribers.delete(key) + continue + } + try { + ws.send(chunk) + } catch { + session.subscribers.delete(key) + } + } + + session.buffer += chunk + if (session.buffer.length <= BUFFER_LIMIT) return + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess + }), + ) + proc.onExit( + Instance.bind(({ exitCode }) => { + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(remove(id)) + }), + ) + yield* bus.publish(Event.Created, { info }) + return info + }) + + const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { + const s = yield* InstanceState.get(state) + const session = s.sessions.get(id) + if (!session) return + if (input.title) { + session.info.title = input.title + } + if (input.size) { + session.process.resize(input.size.cols, input.size.rows) + } + yield* bus.publish(Event.Updated, { info: session.info }) + return session.info + }) + + const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { + const s = yield* InstanceState.get(state) + const session = s.sessions.get(id) + if (session && session.info.status === "running") { + session.process.resize(cols, rows) + } + }) + + const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { + const s = yield* InstanceState.get(state) + const session = s.sessions.get(id) + if (session && session.info.status === "running") { + session.process.write(data) + } + }) + + const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { + const s = yield* InstanceState.get(state) + const session = s.sessions.get(id) + if (!session) { + ws.close() + return + } + log.info("client connected to session", { id }) + + const sub = sock(ws) + session.subscribers.delete(sub) + session.subscribers.set(sub, ws) + + const cleanup = () => { + session.subscribers.delete(sub) + } + + const start = session.bufferCursor + const end = session.cursor + const from = + cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 + + const data = (() => { + if (!session.buffer) return "" + if (from >= end) return "" + const offset = Math.max(0, from - start) + if (offset >= session.buffer.length) return "" + return session.buffer.slice(offset) + })() + + if (data) { + try { + for (let i = 0; i < data.length; i += BUFFER_CHUNK) { + ws.send(data.slice(i, i + BUFFER_CHUNK)) + } + } catch { + cleanup() + ws.close() + return + } + } + + try { + ws.send(meta(end)) + } catch { + cleanup() + ws.close() + return + } + + return { + onMessage: (message: string | ArrayBuffer) => { + session.process.write(String(message)) + }, + onClose: () => { + log.info("client disconnected from session", { id }) + cleanup() + }, + } + }) + + return Service.of({ list, get, create, update, remove, resize, write, connect }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) From 48f88af9aa930e245321b2cec5765896b02c36ef Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 02:39:40 +0000 Subject: [PATCH 39/75] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index a12e6f7e5e..6f0d2fbb27 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-PvIx2g1J5QIUIzkz2ABaAM4K/k/+xlBPDUExoOJNNuo=", - "aarch64-linux": "sha256-YTAL+P13L5hgNJdDSiBED/UNa5zdTntnUUYDYL+Jdzo=", - "aarch64-darwin": "sha256-y2VCJifYAp+H0lpDcJ0QfKNMG00Q/usFElaUIpdc8Vs=", - "x86_64-darwin": "sha256-yz8edIlqLp06Y95ad8YjKz5azP7YATPle4TcDx6lM+U=" + "x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=", + "aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=", + "aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=", + "x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU=" } } From 6c7e9f6f3ac211454576e6e51cc5ff65718cd491 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:39:59 -0400 Subject: [PATCH 40/75] refactor: migrate Effect call sites from Flock to EffectFlock (#22688) --- packages/opencode/src/config/config.ts | 899 +++++++++---------- packages/opencode/test/config/config.test.ts | 26 +- packages/shared/src/npm.ts | 8 +- packages/shared/src/util/effect-flock.ts | 2 +- 4 files changed, 463 insertions(+), 472 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 58d9343ad9..43ec8d7099 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -34,7 +34,8 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" -import { Flock } from "@opencode-ai/shared/util/flock" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" + import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "../npm" import { InstanceRef } from "@/effect/instance-ref" @@ -1144,497 +1145,483 @@ export const ConfigDirectoryTypoError = NamedError.create( }), ) -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const authSvc = yield* Auth.Service - const accountSvc = yield* Account.Service - const env = yield* Env.Service +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Auth.Service | Account.Service | Env.Service | EffectFlock.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const authSvc = yield* Auth.Service + const accountSvc = yield* Account.Service + const env = yield* Env.Service + const flock = yield* EffectFlock.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) - - const loadConfig = Effect.fnUntraced(function* ( - text: string, - options: { path: string } | { dir: string; source: string }, - ) { - const original = text - const source = "path" in options ? options.path : options.source - const isFile = "path" in options - const data = yield* Effect.promise(() => - ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }), - ) - - const normalized = (() => { - if (!data || typeof data !== "object" || Array.isArray(data)) return data - const copy = { ...(data as Record) } - const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy - if (!hadLegacy) return copy - delete copy.theme - delete copy.keybinds - delete copy.tui - log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source }) - return copy - })() - - const parsed = Info.safeParse(normalized) - if (parsed.success) { - if (!parsed.data.$schema && isFile) { - parsed.data.$schema = "https://opencode.ai/config.json" - const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) - } - const data = parsed.data - if (data.plugin && isFile) { - const list = data.plugin - for (let i = 0; i < list.length; i++) { - list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path)) - } - } - return data - } - - throw new InvalidError({ - path: source, - issues: parsed.error.issues, - }) - }) - - const loadFile = Effect.fnUntraced(function* (filepath: string) { - log.info("loading", { path: filepath }) - const text = yield* readConfigFile(filepath) - if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }) - }) - - const loadGlobal = Effect.fnUntraced(function* () { - let result: Info = pipe( - {}, - mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) - - const legacy = path.join(Global.Path.config, "config") - if (existsSync(legacy)) { - yield* Effect.promise(() => - import(pathToFileURL(legacy).href, { with: { type: "toml" } }) - .then(async (mod) => { - const { provider, model, ...rest } = mod.default - if (provider && model) result.model = `${provider}/${model}` - result["$schema"] = "https://opencode.ai/config.json" - result = mergeDeep(result, rest) - await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) - await fsNode.unlink(legacy) - }) - .catch(() => {}), - ) - } - - return result - }) - - const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( - loadGlobal().pipe( - Effect.tapError((error) => - Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), - ), - Effect.orElseSucceed((): Info => ({})), + const readConfigFile = Effect.fnUntraced(function* (filepath: string) { + return yield* fs.readFileString(filepath).pipe( + Effect.catchIf( + (e) => e.reason._tag === "NotFound", + () => Effect.succeed(undefined), ), - Duration.infinity, + Effect.orDie, + ) + }) + + const loadConfig = Effect.fnUntraced(function* ( + text: string, + options: { path: string } | { dir: string; source: string }, + ) { + const original = text + const source = "path" in options ? options.path : options.source + const isFile = "path" in options + const data = yield* Effect.promise(() => + ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }), ) - const getGlobal = Effect.fn("Config.getGlobal")(function* () { - return yield* cachedGlobal + const normalized = (() => { + if (!data || typeof data !== "object" || Array.isArray(data)) return data + const copy = { ...(data as Record) } + const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy + if (!hadLegacy) return copy + delete copy.theme + delete copy.keybinds + delete copy.tui + log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source }) + return copy + })() + + const parsed = Info.safeParse(normalized) + if (parsed.success) { + if (!parsed.data.$schema && isFile) { + parsed.data.$schema = "https://opencode.ai/config.json" + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) + } + const data = parsed.data + if (data.plugin && isFile) { + const list = data.plugin + for (let i = 0; i < list.length; i++) { + list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path)) + } + } + return data + } + + throw new InvalidError({ + path: source, + issues: parsed.error.issues, + }) + }) + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + log.info("loading", { path: filepath }) + const text = yield* readConfigFile(filepath) + if (!text) return {} as Info + return yield* loadConfig(text, { path: filepath }) + }) + + const loadGlobal = Effect.fnUntraced(function* () { + let result: Info = pipe( + {}, + mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), + mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), + mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), + ) + + const legacy = path.join(Global.Path.config, "config") + if (existsSync(legacy)) { + yield* Effect.promise(() => + import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + .then(async (mod) => { + const { provider, model, ...rest } = mod.default + if (provider && model) result.model = `${provider}/${model}` + result["$schema"] = "https://opencode.ai/config.json" + result = mergeDeep(result, rest) + await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) + await fsNode.unlink(legacy) + }) + .catch(() => {}), + ) + } + + return result + }) + + const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( + loadGlobal().pipe( + Effect.tapError((error) => + Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), + ), + Effect.orElseSucceed((): Info => ({})), + ), + Duration.infinity, + ) + + const getGlobal = Effect.fn("Config.getGlobal")(function* () { + return yield* cachedGlobal + }) + + const install = Effect.fn("Config.install")(function* (dir: string) { + const pkg = path.join(dir, "package.json") + const gitignore = path.join(dir, ".gitignore") + const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") + const target = Installation.isLocal() ? "*" : Installation.VERSION + const json = yield* fs.readJson(pkg).pipe( + Effect.catch(() => Effect.succeed({} satisfies Package)), + Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), + ) + const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target + const hasIgnore = yield* fs.existsSafe(gitignore) + const hasPkg = yield* fs.existsSafe(plugin) + + if (!hasDep) { + yield* fs.writeJson(pkg, { + ...json, + dependencies: { + ...json.dependencies, + "@opencode-ai/plugin": target, + }, + }) + } + + if (!hasIgnore) { + yield* fs.writeFileString( + gitignore, + ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + ) + } + + if (hasDep && hasIgnore && hasPkg) return + + yield* Effect.promise(() => Npm.install(dir)) + }) + + const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) { + if ( + !(yield* fs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + )) + ) + return + + const key = process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}` + + yield* flock.withLock(install(dir), key).pipe(Effect.orDie) + }) + + const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) { + const auth = yield* authSvc.all().pipe(Effect.orDie) + + let result: Info = {} + const consoleManagedProviders = new Set() + let activeOrgName: string | undefined + + const scope = Effect.fnUntraced(function* (source: string) { + if (source.startsWith("http://") || source.startsWith("https://")) return "global" + if (source === "OPENCODE_CONFIG_CONTENT") return "local" + if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + return "global" }) - const install = Effect.fn("Config.install")(function* (dir: string) { - const pkg = path.join(dir, "package.json") - const gitignore = path.join(dir, ".gitignore") - const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") - const target = Installation.isLocal() ? "*" : Installation.VERSION - const json = yield* fs.readJson(pkg).pipe( - Effect.catch(() => Effect.succeed({} satisfies Package)), - Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), - ) - const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target - const hasIgnore = yield* fs.existsSafe(gitignore) - const hasPkg = yield* fs.existsSafe(plugin) + const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) { + if (!list?.length) return + const hit = kind ?? (yield* scope(source)) + const plugins = deduplicatePluginOrigins([ + ...(result.plugin_origins ?? []), + ...list.map((spec) => ({ spec, source, scope: hit })), + ]) + result.plugin = plugins.map((item) => item.spec) + result.plugin_origins = plugins + }) - if (!hasDep) { - yield* fs.writeJson(pkg, { - ...json, - dependencies: { - ...json.dependencies, - "@opencode-ai/plugin": target, - }, + const merge = (source: string, next: Info, kind?: PluginScope) => { + result = mergeConfigConcatArrays(result, next) + return track(source, next.plugin, kind) + } + + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + const url = key.replace(/\/+$/, "") + process.env[value.key] = value.token + log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) + const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + } + const wellknown = (yield* Effect.promise(() => response.json())) as any + const remoteConfig = wellknown.config ?? {} + if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + const source = `${url}/.well-known/opencode` + const next = yield* loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, }) + yield* merge(source, next, "global") + log.debug("loaded remote config from well-known", { url }) + } + } + + const global = yield* getGlobal() + yield* merge(Global.Path.config, global, "global") + + if (Flag.OPENCODE_CONFIG) { + yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) + log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + } + + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of yield* Effect.promise(() => + ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), + )) { + yield* merge(file, yield* loadFile(file), "local") + } + } + + result.agent = result.agent || {} + result.mode = result.mode || {} + result.plugin = result.plugin || [] + + const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) + + if (Flag.OPENCODE_CONFIG_DIR) { + log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) + } + + const deps: Fiber.Fiber[] = [] + + for (const dir of unique(directories)) { + if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(dir, file) + log.debug(`loading config from ${source}`) + yield* merge(source, yield* loadFile(source)) + result.agent ??= {} + result.mode ??= {} + result.plugin ??= [] + } } - if (!hasIgnore) { - yield* fs.writeFileString( - gitignore, - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - } - - if (hasDep && hasIgnore && hasPkg) return - - yield* Effect.promise(() => Npm.install(dir)) - }) - - const installDependencies = Effect.fn("Config.installDependencies")(function* ( - dir: string, - input?: InstallInput, - ) { - if ( - !(yield* fs.access(dir, { writable: true }).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - )) - ) - return - - const key = - process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}` - - yield* Effect.acquireUseRelease( - Effect.promise((signal) => - Flock.acquire(key, { - signal, - onWait: (tick) => - input?.waitTick?.({ - dir, - attempt: tick.attempt, - delay: tick.delay, - waited: tick.waited, - }), - }), + const dep = yield* installDependencies(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + log.warn("background dependency install failed", { dir, error: String(exit.cause) }) + }) + : Effect.void, ), - () => install(dir), - (lease) => Effect.promise(() => lease.release()), + Effect.asVoid, + Effect.forkScoped, ) - }) + deps.push(dep) - const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) { - const auth = yield* authSvc.all().pipe(Effect.orDie) + result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) + result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) + result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) + const list = yield* Effect.promise(() => loadPlugin(dir)) + yield* track(dir, list) + } - let result: Info = {} - const consoleManagedProviders = new Set() - let activeOrgName: string | undefined - - const scope = Effect.fnUntraced(function* (source: string) { - if (source.startsWith("http://") || source.startsWith("https://")) return "global" - if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" - return "global" + if (process.env.OPENCODE_CONFIG_CONTENT) { + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { + dir: ctx.directory, + source, }) + yield* merge(source, next, "local") + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } - const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) { - if (!list?.length) return - const hit = kind ?? (yield* scope(source)) - const plugins = deduplicatePluginOrigins([ - ...(result.plugin_origins ?? []), - ...list.map((spec) => ({ spec, source, scope: hit })), - ]) - result.plugin = plugins.map((item) => item.spec) - result.plugin_origins = plugins - }) + const activeAccount = Option.getOrUndefined( + yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + ) + if (activeAccount?.active_org_id) { + const accountID = activeAccount.id + const orgID = activeAccount.active_org_id + const url = activeAccount.url + yield* Effect.gen(function* () { + const [configOpt, tokenOpt] = yield* Effect.all( + [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], + { concurrency: 2 }, + ) + if (Option.isSome(tokenOpt)) { + process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value + yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) + } - const merge = (source: string, next: Info, kind?: PluginScope) => { - result = mergeConfigConcatArrays(result, next) - return track(source, next.plugin, kind) - } - - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (yield* Effect.promise(() => response.json())) as any - const remoteConfig = wellknown.config ?? {} - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - const source = `${url}/.well-known/opencode` - const next = yield* loadConfig(JSON.stringify(remoteConfig), { + if (Option.isSome(configOpt)) { + const source = `${url}/api/config` + const next = yield* loadConfig(JSON.stringify(configOpt.value), { dir: path.dirname(source), source, }) + for (const providerID of Object.keys(next.provider ?? {})) { + consoleManagedProviders.add(providerID) + } yield* merge(source, next, "global") - log.debug("loaded remote config from well-known", { url }) } - } - - const global = yield* getGlobal() - yield* merge(Global.Path.config, global, "global") - - if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) - } - - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of yield* Effect.promise(() => - ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), - )) { - yield* merge(file, yield* loadFile(file), "local") - } - } - - result.agent = result.agent || {} - result.mode = result.mode || {} - result.plugin = result.plugin || [] - - const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) - - if (Flag.OPENCODE_CONFIG_DIR) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) - } - - const deps: Fiber.Fiber[] = [] - - for (const dir of unique(directories)) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(dir, file) - log.debug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source)) - result.agent ??= {} - result.mode ??= {} - result.plugin ??= [] - } - } - - const dep = yield* installDependencies(dir).pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkScoped, - ) - deps.push(dep) - - result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) - result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) - result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) - const list = yield* Effect.promise(() => loadPlugin(dir)) - yield* track(dir, list) - } - - if (process.env.OPENCODE_CONFIG_CONTENT) { - const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) - yield* merge(source, next, "local") - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } - - const activeAccount = Option.getOrUndefined( - yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + }).pipe( + Effect.withSpan("Config.loadActiveOrgConfig"), + Effect.catch((err) => { + log.debug("failed to fetch remote account config", { + error: err instanceof Error ? err.message : String(err), + }) + return Effect.void + }), ) - if (activeAccount?.active_org_id) { - const accountID = activeAccount.id - const orgID = activeAccount.active_org_id - const url = activeAccount.url - yield* Effect.gen(function* () { - const [configOpt, tokenOpt] = yield* Effect.all( - [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], - { concurrency: 2 }, - ) - if (Option.isSome(tokenOpt)) { - process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value - yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) - } + } - if (Option.isSome(configOpt)) { - const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) - for (const providerID of Object.keys(next.provider ?? {})) { - consoleManagedProviders.add(providerID) - } - yield* merge(source, next, "global") - } - }).pipe( - Effect.withSpan("Config.loadActiveOrgConfig"), - Effect.catch((err) => { - log.debug("failed to fetch remote account config", { - error: err instanceof Error ? err.message : String(err), - }) - return Effect.void - }), - ) + if (existsSync(managedDir)) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(managedDir, file) + yield* merge(source, yield* loadFile(source), "global") } + } - if (existsSync(managedDir)) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") - } - } + // macOS managed preferences (.mobileconfig deployed via MDM) override everything + result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences())) - // macOS managed preferences (.mobileconfig deployed via MDM) override everything - result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences())) - - for (const [name, mode] of Object.entries(result.mode ?? {})) { - result.agent = mergeDeep(result.agent ?? {}, { - [name]: { - ...mode, - mode: "primary" as const, - }, - }) - } - - if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) - } - - if (result.tools) { - const perms: Record = {} - for (const [tool, enabled] of Object.entries(result.tools)) { - const action: PermissionAction = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { - perms.edit = action - continue - } - perms[tool] = action - } - result.permission = mergeDeep(perms, result.permission ?? {}) - } - - if (!result.username) result.username = os.userInfo().username - - if (result.autoshare === true && !result.share) { - result.share = "auto" - } - - if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { - result.compaction = { ...result.compaction, auto: false } - } - if (Flag.OPENCODE_DISABLE_PRUNE) { - result.compaction = { ...result.compaction, prune: false } - } - - return { - config: result, - directories, - deps, - consoleState: { - consoleManagedProviders: Array.from(consoleManagedProviders), - activeOrgName, - switchableOrgCount: 0, + for (const [name, mode] of Object.entries(result.mode ?? {})) { + result.agent = mergeDeep(result.agent ?? {}, { + [name]: { + ...mode, + mode: "primary" as const, }, + }) + } + + if (Flag.OPENCODE_PERMISSION) { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + } + + if (result.tools) { + const perms: Record = {} + for (const [tool, enabled] of Object.entries(result.tools)) { + const action: PermissionAction = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + perms.edit = action + continue + } + perms[tool] = action } - }) + result.permission = mergeDeep(perms, result.permission ?? {}) + } - const state = yield* InstanceState.make( - Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx) - }), - ) + if (!result.username) result.username = os.userInfo().username - const get = Effect.fn("Config.get")(function* () { - return yield* InstanceState.use(state, (s) => s.config) - }) + if (result.autoshare === true && !result.share) { + result.share = "auto" + } - const directories = Effect.fn("Config.directories")(function* () { - return yield* InstanceState.use(state, (s) => s.directories) - }) + if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.OPENCODE_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } - const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { - return yield* InstanceState.use(state, (s) => s.consoleState) - }) - - const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { - yield* InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ) - }) - - const update = Effect.fn("Config.update")(function* (config: Info) { - const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) - .pipe(Effect.orDie) - yield* Effect.promise(() => Instance.dispose()) - }) - - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { - yield* invalidateGlobal - const task = Instance.disposeAll() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task - }) - - const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { - const file = globalConfigFile() - const before = (yield* readConfigFile(file)) ?? "{}" - const input = writable(config) - - let next: Info - if (!file.endsWith(".jsonc")) { - const existing = parseConfig(before, file) - const merged = mergeDeep(writable(existing), input) - yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) - next = merged - } else { - const updated = patchJsonc(before, input) - next = parseConfig(updated, file) - yield* fs.writeFileString(file, updated).pipe(Effect.orDie) - } - - yield* invalidate() - return next - }) - - return Service.of({ - get, - getGlobal, - getConsoleState, - installDependencies, - update, - updateGlobal, - invalidate, + return { + config: result, directories, - waitForDependencies, - }) - }), - ) + deps, + consoleState: { + consoleManagedProviders: Array.from(consoleManagedProviders), + activeOrgName, + switchableOrgCount: 0, + }, + } + }) + + const state = yield* InstanceState.make( + Effect.fn("Config.state")(function* (ctx) { + return yield* loadInstanceState(ctx) + }), + ) + + const get = Effect.fn("Config.get")(function* () { + return yield* InstanceState.use(state, (s) => s.config) + }) + + const directories = Effect.fn("Config.directories")(function* () { + return yield* InstanceState.use(state, (s) => s.directories) + }) + + const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { + return yield* InstanceState.use(state, (s) => s.consoleState) + }) + + const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { + yield* InstanceState.useEffect(state, (s) => + Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), + ) + }) + + const update = Effect.fn("Config.update")(function* (config: Info) { + const dir = yield* InstanceState.directory + const file = path.join(dir, "config.json") + const existing = yield* loadFile(file) + yield* fs + .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) + .pipe(Effect.orDie) + yield* Effect.promise(() => Instance.dispose()) + }) + + const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + yield* invalidateGlobal + const task = Instance.disposeAll() + .catch(() => undefined) + .finally(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), + ) + if (wait) yield* Effect.promise(() => task) + else void task + }) + + const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { + const file = globalConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + const input = writable(config) + + let next: Info + if (!file.endsWith(".jsonc")) { + const existing = parseConfig(before, file) + const merged = mergeDeep(writable(existing), input) + yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + next = merged + } else { + const updated = patchJsonc(before, input) + next = parseConfig(updated, file) + yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + } + + yield* invalidate() + return next + }) + + return Service.of({ + get, + getGlobal, + getConsoleState, + installDependencies, + update, + updateGlobal, + invalidate, + directories, + waitForDependencies, + }) + }), +) export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(Auth.defaultLayer), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 88957c6141..8cf410c3d2 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2,6 +2,8 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun: import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "../../src/config" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" + import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" @@ -34,7 +36,10 @@ const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}), }) +const testFlock = EffectFlock.defaultLayer + const layer = Config.layer.pipe( + Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(emptyAuth), @@ -333,6 +338,7 @@ test("resolves env templates in account config with account token", async () => }) const layer = Config.layer.pipe( + Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(emptyAuth), @@ -879,11 +885,7 @@ it.live("dedupes concurrent config dependency installs for the same dir", () => yield* Deferred.await(ready) let done = false - const second = yield* installDeps(dir, { - waitTick: () => { - Deferred.doneUnsafe(blocked, Effect.void) - }, - }).pipe( + const second = yield* installDeps(dir).pipe( Effect.tap(() => Effect.sync(() => { done = true @@ -892,7 +894,8 @@ it.live("dedupes concurrent config dependency installs for the same dir", () => Effect.forkScoped, ) - yield* Deferred.await(blocked) + // Give the second fiber time to hit the lock retry loop + yield* Effect.sleep(500) expect(done).toBe(false) yield* Deferred.succeed(hold, void 0) @@ -955,12 +958,9 @@ it.live("serializes config dependency installs across dirs", () => const first = yield* installDeps(a).pipe(Effect.forkScoped) yield* Deferred.await(ready) - const second = yield* installDeps(b, { - waitTick: () => { - Deferred.doneUnsafe(blocked, Effect.void) - }, - }).pipe(Effect.forkScoped) - yield* Deferred.await(blocked) + const second = yield* installDeps(b).pipe(Effect.forkScoped) + // Give the second fiber time to hit the lock retry loop + yield* Effect.sleep(500) expect(peak).toBe(1) yield* Deferred.succeed(hold, void 0) @@ -1826,6 +1826,7 @@ test("project config overrides remote well-known config", async () => { }) const layer = Config.layer.pipe( + Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(fakeAuth), @@ -1882,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => { }) const layer = Config.layer.pipe( + Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(fakeAuth), diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts index 994ec04dae..8bd0cc468b 100644 --- a/packages/shared/src/npm.ts +++ b/packages/shared/src/npm.ts @@ -5,7 +5,7 @@ import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "./filesystem" import { Global } from "./global" -import { Flock } from "./util/flock" +import { EffectFlock } from "./util/effect-flock" export namespace Npm { export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { @@ -62,6 +62,7 @@ export namespace Npm { const afs = yield* AppFileSystem.Service const global = yield* Global.Service const fs = yield* FileSystem.FileSystem + const flock = yield* EffectFlock.Service const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { @@ -92,7 +93,7 @@ export namespace Npm { const add = Effect.fn("Npm.add")(function* (pkg: string) { const dir = directory(pkg) - yield* Flock.effect(`npm-install:${dir}`) + yield* flock.acquire(`npm-install:${dir}`) const arborist = new Arborist({ path: dir, @@ -133,7 +134,7 @@ export namespace Npm { }, Effect.scoped) const install = Effect.fn("Npm.install")(function* (dir: string) { - yield* Flock.effect(`npm-install:${dir}`) + yield* flock.acquire(`npm-install:${dir}`) const reify = Effect.fnUntraced(function* () { const arb = new Arborist({ @@ -240,6 +241,7 @@ export namespace Npm { ) export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.layer), Layer.provide(AppFileSystem.layer), Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts index d728c0ef15..3e00afc9e4 100644 --- a/packages/shared/src/util/effect-flock.ts +++ b/packages/shared/src/util/effect-flock.ts @@ -274,5 +274,5 @@ export namespace EffectFlock { }), ) - export const live = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer)) } From 379e40d7720ab1caecfc750ca87ab3d039957ba3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:45:45 -0400 Subject: [PATCH 41/75] feat: unwrap InstanceState + EffectBridge namespaces to flat exports + barrel (#22721) --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/bus/bus.ts | 4 +- packages/opencode/src/command/command.ts | 4 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/tui.ts | 2 +- packages/opencode/src/effect/bridge.ts | 80 ++++++----- packages/opencode/src/effect/index.ts | 2 + .../opencode/src/effect/instance-state.ts | 132 +++++++++--------- packages/opencode/src/env/env.ts | 2 +- packages/opencode/src/file/file.ts | 2 +- packages/opencode/src/file/time.ts | 2 +- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/format.ts | 2 +- packages/opencode/src/lsp/index.ts | 2 +- packages/opencode/src/mcp/mcp.ts | 4 +- .../opencode/src/permission/permission.ts | 2 +- packages/opencode/src/plugin/plugin.ts | 4 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/provider.ts | 4 +- packages/opencode/src/pty/service.ts | 4 +- packages/opencode/src/question/index.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/run-state.ts | 2 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/session/status.ts | 2 +- packages/opencode/src/share/share-next.ts | 4 +- packages/opencode/src/skill/skill.ts | 2 +- packages/opencode/src/snapshot/snapshot.ts | 2 +- packages/opencode/src/storage/db.ts | 2 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/worktree/worktree.ts | 2 +- .../test/effect/app-runtime-logger.test.ts | 2 +- .../test/effect/instance-state.test.ts | 4 +- 40 files changed, 152 insertions(+), 154 deletions(-) create mode 100644 packages/opencode/src/effect/index.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8d11a93b39..b027c8c945 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -20,7 +20,7 @@ import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Effect, Context, Layer } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts index fe9169171c..12d7f246cd 100644 --- a/packages/opencode/src/bus/bus.ts +++ b/packages/opencode/src/bus/bus.ts @@ -1,10 +1,10 @@ import z from "zod" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" +import { EffectBridge } from "@/effect" import { Log } from "../util/log" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { makeRuntime } from "@/effect/run-service" const log = Log.create({ service: "bus" }) diff --git a/packages/opencode/src/command/command.ts b/packages/opencode/src/command/command.ts index fe9005edb2..4ea1325240 100644 --- a/packages/opencode/src/command/command.ts +++ b/packages/opencode/src/command/command.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" -import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect" +import { EffectBridge } from "@/effect" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context } from "effect" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 43ec8d7099..7eeacf1ffc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -32,7 +32,7 @@ import { isRecord } from "@/util/record" import { ConfigPaths } from "./paths" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 163bd4d7d7..24ccefecdb 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -10,7 +10,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Global } from "@/global" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index bafa5a0ea6..9ca7b50ad9 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -5,45 +5,43 @@ import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" -export namespace EffectBridge { - export interface Shape { - readonly promise: (effect: Effect.Effect) => Promise - readonly fork: (effect: Effect.Effect) => Fiber.Fiber - } - - function restore(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R { - if (instance && workspace !== undefined) { - return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) - } - if (instance) return Instance.restore(instance, fn) - if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) - return fn() - } - - export function make(): Effect.Effect { - return Effect.gen(function* () { - const ctx = yield* Effect.context() - const value = yield* InstanceRef - const instance = - value ?? - (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() - const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID - const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) - const wrap = (effect: Effect.Effect) => - attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect - - return { - promise: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runPromise(wrap(effect))), - fork: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runFork(wrap(effect))), - } satisfies Shape - }) - } +export interface Shape { + readonly promise: (effect: Effect.Effect) => Promise + readonly fork: (effect: Effect.Effect) => Fiber.Fiber +} + +function restore(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R { + if (instance && workspace !== undefined) { + return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) + } + if (instance) return Instance.restore(instance, fn) + if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) + return fn() +} + +export function make(): Effect.Effect { + return Effect.gen(function* () { + const ctx = yield* Effect.context() + const value = yield* InstanceRef + const instance = + value ?? + (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) + const wrap = (effect: Effect.Effect) => + attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect + + return { + promise: (effect: Effect.Effect) => + restore(instance, workspace, () => Effect.runPromise(wrap(effect))), + fork: (effect: Effect.Effect) => + restore(instance, workspace, () => Effect.runFork(wrap(effect))), + } satisfies Shape + }) } diff --git a/packages/opencode/src/effect/index.ts b/packages/opencode/src/effect/index.ts new file mode 100644 index 0000000000..d10afdff2b --- /dev/null +++ b/packages/opencode/src/effect/index.ts @@ -0,0 +1,2 @@ +export * as InstanceState from "./instance-state" +export * as EffectBridge from "./bridge" diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index b3392d1563..2a51931ada 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -13,72 +13,70 @@ export interface InstanceState { readonly cache: ScopedCache.ScopedCache } -export namespace InstanceState { - export const bind = any>(fn: F): F => { - try { - return Instance.bind(fn) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - const fiber = Fiber.getCurrent() - const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined - if (!ctx) return fn - return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F +export const bind = any>(fn: F): F => { + try { + return Instance.bind(fn) + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err } - - export const context = Effect.gen(function* () { - return (yield* InstanceRef) ?? Instance.current - }) - - export const workspaceID = Effect.gen(function* () { - return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID - }) - - export const directory = Effect.map(context, (ctx) => ctx.directory) - - export const make = ( - init: (ctx: InstanceContext) => Effect.Effect, - ): Effect.Effect>, never, R | Scope.Scope> => - Effect.gen(function* () { - const cache = yield* ScopedCache.make({ - capacity: Number.POSITIVE_INFINITY, - lookup: () => - Effect.gen(function* () { - return yield* init(yield* context) - }), - }) - - const off = registerDisposer((directory) => - Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))), - ) - yield* Effect.addFinalizer(() => Effect.sync(off)) - - return { - [TypeId]: TypeId, - cache, - } - }) - - export const get = (self: InstanceState) => - Effect.gen(function* () { - return yield* ScopedCache.get(self.cache, yield* directory) - }) - - export const use = (self: InstanceState, select: (value: A) => B) => - Effect.map(get(self), select) - - export const useEffect = ( - self: InstanceState, - select: (value: A) => Effect.Effect, - ) => Effect.flatMap(get(self), select) - - export const has = (self: InstanceState) => - Effect.gen(function* () { - return yield* ScopedCache.has(self.cache, yield* directory) - }) - - export const invalidate = (self: InstanceState) => - Effect.gen(function* () { - return yield* ScopedCache.invalidate(self.cache, yield* directory) - }) + const fiber = Fiber.getCurrent() + const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined + if (!ctx) return fn + return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F } + +export const context = Effect.gen(function* () { + return (yield* InstanceRef) ?? Instance.current +}) + +export const workspaceID = Effect.gen(function* () { + return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID +}) + +export const directory = Effect.map(context, (ctx) => ctx.directory) + +export const make = ( + init: (ctx: InstanceContext) => Effect.Effect, +): Effect.Effect>, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + capacity: Number.POSITIVE_INFINITY, + lookup: () => + Effect.gen(function* () { + return yield* init(yield* context) + }), + }) + + const off = registerDisposer((directory) => + Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))), + ) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + return { + [TypeId]: TypeId, + cache, + } + }) + +export const get = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.get(self.cache, yield* directory) + }) + +export const use = (self: InstanceState, select: (value: A) => B) => + Effect.map(get(self), select) + +export const useEffect = ( + self: InstanceState, + select: (value: A) => Effect.Effect, +) => Effect.flatMap(get(self), select) + +export const has = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.has(self.cache, yield* directory) + }) + +export const invalidate = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.invalidate(self.cache, yield* directory) + }) diff --git a/packages/opencode/src/env/env.ts b/packages/opencode/src/env/env.ts index 0ffd5ebdc3..618ae32684 100644 --- a/packages/opencode/src/env/env.ts +++ b/packages/opencode/src/env/env.ts @@ -1,5 +1,5 @@ import { Context, Effect, Layer } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" type State = Record diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts index a101574f61..35f2a8740a 100644 --- a/packages/opencode/src/file/file.ts +++ b/packages/opencode/src/file/file.ts @@ -1,5 +1,5 @@ import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 853da3bd98..86b6b4116b 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,5 +1,5 @@ import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 4dcec5094c..ab5942547d 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -7,7 +7,7 @@ import path from "path" import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Flag } from "@/flag/flag" import { Git } from "@/git" import { Instance } from "@/project/instance" diff --git a/packages/opencode/src/format/format.ts b/packages/opencode/src/format/format.ts index 6df00d3db3..2ce922495e 100644 --- a/packages/opencode/src/format/format.ts +++ b/packages/opencode/src/format/format.ts @@ -1,7 +1,7 @@ import { Effect, Layer, Context } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import path from "path" import { mergeDeep } from "remeda" import z from "zod" diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 4daacd30b8..f567868f68 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -12,7 +12,7 @@ import { Flag } from "@/flag/flag" import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" export namespace LSP { const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts index 947f29c05b..f5179b224d 100644 --- a/packages/opencode/src/mcp/mcp.ts +++ b/packages/opencode/src/mcp/mcp.ts @@ -25,8 +25,8 @@ import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" import { Effect, Exit, Layer, Option, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts index a5f6ded329..e2dead8fe2 100644 --- a/packages/opencode/src/permission/permission.ts +++ b/packages/opencode/src/permission/permission.ts @@ -1,7 +1,7 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { Config } from "@/config" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" diff --git a/packages/opencode/src/plugin/plugin.ts b/packages/opencode/src/plugin/plugin.ts index 537794138a..23c807ebe7 100644 --- a/packages/opencode/src/plugin/plugin.ts +++ b/packages/opencode/src/plugin/plugin.ts @@ -18,8 +18,8 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e4093fd456..187c616602 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -3,7 +3,7 @@ import { formatPatch, structuredPatch } from "diff" import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 0f2923a587..fd71f2f7a3 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,7 +1,7 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/shared/util/error" import { Auth } from "@/auth" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fed4d93583..ef6cbd61e7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -19,8 +19,8 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Effect, Layer, Context } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" diff --git a/packages/opencode/src/pty/service.ts b/packages/opencode/src/pty/service.ts index 3359d0aabf..ff52095b4f 100644 --- a/packages/opencode/src/pty/service.ts +++ b/packages/opencode/src/pty/service.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Instance } from "@/project/instance" import type { Proc } from "#pty" import z from "zod" @@ -10,7 +10,7 @@ import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" -import { EffectBridge } from "@/effect/bridge" +import { EffectBridge } from "@/effect" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index ba76efa640..8d023c18bf 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,7 +1,7 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { SessionID, MessageID } from "@/session/schema" import { zod } from "@/util/effect-zod" import { Log } from "@/util/log" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 644a76752d..3d39a60555 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,7 +14,7 @@ import { Config } from "@/config" import { NotFoundError } from "@/storage/db" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { isOverflow as overflow } from "./overflow" export namespace SessionCompaction { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 23dd88ff5a..076c81ec75 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Config } from "@/config" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Flag } from "@/flag/flag" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 3db1c99d6b..bde36d2638 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,7 +20,7 @@ import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" -import { EffectBridge } from "@/effect/bridge" +import { EffectBridge } from "@/effect" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b699676897..7a74939034 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,10 +45,10 @@ import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { EffectLogger } from "@/effect/logger" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" -import { EffectBridge } from "@/effect/bridge" +import { EffectBridge } from "@/effect" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index f67c726ec7..922daf1178 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,4 +1,4 @@ -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Runner } from "@/effect/runner" import { Effect, Layer, Scope, Context } from "effect" import { Session } from "." diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 12ecd85529..0b82d8b99f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -18,7 +18,7 @@ import { Log } from "../util/log" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 5800cb7322..f0d4e6cf79 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { SessionID } from "./schema" import { Effect, Layer, Context } from "effect" import z from "zod" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index c764c20b99..9b345ac8ef 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -3,7 +3,7 @@ import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "eff import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Account } from "@/account" import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session" @@ -142,7 +142,7 @@ export namespace ShareNext { }) } - const state: InstanceState = yield* InstanceState.make( + const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { const cache: State = { queue: new Map(), scope: yield* Scope.make() } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index afc6446678..3122115cd3 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -6,7 +6,7 @@ import { Effect, Layer, Context } from "effect" import { NamedError } from "@opencode-ai/shared/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" diff --git a/packages/opencode/src/snapshot/snapshot.ts b/packages/opencode/src/snapshot/snapshot.ts index 6624dee986..7aa3a4debf 100644 --- a/packages/opencode/src/snapshot/snapshot.ts +++ b/packages/opencode/src/snapshot/snapshot.ts @@ -4,7 +4,7 @@ import { formatPatch, structuredPatch } from "diff" import path from "path" import z from "zod" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Hash } from "@opencode-ai/shared/util/hash" import { Config } from "../config" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 68a41e471f..247cb347cb 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -12,7 +12,7 @@ import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "../flag/flag" import { CHANNEL } from "../installation/meta" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { iife } from "@/util/iife" import { init } from "#db" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 352cc07390..c91b698038 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect } from "effect" import { EffectLogger } from "@/effect/logger" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import type { Tool } from "./tool" import { Instance } from "../project/instance" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 778a74ddcf..0a0a8f1e25 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -2,7 +2,7 @@ import path from "path" import z from "zod" import { Effect, Option } from "effect" import * as Stream from "effect/Stream" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 9a2bab5b2d..b6b4a063f0 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,7 +1,7 @@ import path from "path" import z from "zod" import { Effect, Option } from "effect" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b9870d194d..6171e4366e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,7 +35,7 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { Ripgrep } from "../file/ripgrep" import { Format } from "../format" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "../lsp" diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts index 674d4d7570..fab9ce57fa 100644 --- a/packages/opencode/src/worktree/worktree.ts +++ b/packages/opencode/src/worktree/worktree.ts @@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { InstanceState } from "@/effect/instance-state" +import { InstanceState } from "@/effect" const log = Log.create({ service: "worktree" }) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index 7388748f92..91f367ff3e 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { Context, Effect, Layer, Logger } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" -import { EffectBridge } from "../../src/effect/bridge" +import { EffectBridge } from "../../src/effect" import { InstanceRef } from "../../src/effect/instance-ref" import { EffectLogger } from "../../src/effect/logger" import { makeRuntime } from "../../src/effect/run-service" diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index ca74c915be..50206ba84f 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,11 +1,11 @@ import { afterEach, expect, test } from "bun:test" import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" -import { InstanceState } from "../../src/effect/instance-state" +import { InstanceState } from "../../src/effect" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -async function access(state: InstanceState, dir: string) { +async function access(state: InstanceState.InstanceState, dir: string) { return Instance.provide({ directory: dir, fn: () => Effect.runPromise(InstanceState.get(state)), From f6243603f8e432361a0f75a3e4705e14e88c0d4a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 02:46:39 +0000 Subject: [PATCH 42/75] chore: generate --- packages/opencode/src/effect/instance-state.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index 2a51931ada..2681d5febf 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -63,8 +63,7 @@ export const get = (self: InstanceState) => return yield* ScopedCache.get(self.cache, yield* directory) }) -export const use = (self: InstanceState, select: (value: A) => B) => - Effect.map(get(self), select) +export const use = (self: InstanceState, select: (value: A) => B) => Effect.map(get(self), select) export const useEffect = ( self: InstanceState, From 1508196c0f4f4892325accddb5affeadbc4e8574 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:50:22 -0400 Subject: [PATCH 43/75] feat: bridge question routes from Hono to Effect HttpApi (#22718) --- packages/opencode/src/flag/flag.ts | 1 + .../src/server/instance/httpapi/question.ts | 25 ++++++++++--- .../src/server/instance/httpapi/server.ts | 36 +++++++++---------- .../opencode/src/server/instance/index.ts | 14 ++++++-- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index a63f8d1c66..21923f982f 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -84,6 +84,7 @@ export namespace Flag { export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS") export const OPENCODE_WORKSPACE_ID = process.env["OPENCODE_WORKSPACE_ID"] + export const OPENCODE_EXPERIMENTAL_HTTPAPI = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") function number(key: string) { diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index 686c6abb17..51966d13b9 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/packages/opencode/src/server/instance/httpapi/question.ts @@ -3,7 +3,7 @@ import { QuestionID } from "@/question/schema" import { Effect, Layer, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -const root = "/experimental/httpapi/question" +const root = "/question" export const QuestionApi = HttpApi.make("question") .add( @@ -29,19 +29,29 @@ export const QuestionApi = HttpApi.make("question") description: "Provide answers to a question request from the AI assistant.", }), ), + HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { + params: { requestID: QuestionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reject", + summary: "Reject question request", + description: "Reject a question request from the AI assistant.", + }), + ), ) .annotateMerge( OpenApi.annotations({ title: "question", - description: "Experimental HttpApi question routes.", + description: "Question routes.", }), ), ) .annotateMerge( OpenApi.annotations({ - title: "opencode experimental HttpApi", + title: "opencode HttpApi", version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", + description: "Effect HttpApi surface for instance routes.", }), ) @@ -64,8 +74,13 @@ export const QuestionLive = Layer.unwrap( return true }) + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) + return HttpApiBuilder.group(QuestionApi, "question", (handlers) => - handlers.handle("list", list).handle("reply", reply), + handlers.handle("list", list).handle("reply", reply).handle("reject", reject), ) }), ).pipe(Layer.provide(Question.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 9894343c56..2ca692efbe 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -1,17 +1,15 @@ -import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer, Redacted, Schema } from "effect" import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { createServer } from "node:http" +import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { Observability } from "@/effect/observability" +import { memoMap } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" +import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" -import { Permission } from "@/permission" -import { ProviderAuth } from "@/provider/auth" -import { Question } from "@/question" import { PermissionApi, PermissionLive } from "./permission" import { ProviderApi, ProviderLive } from "./provider" import { QuestionApi, QuestionLive } from "./question" @@ -113,26 +111,24 @@ export namespace ExperimentalHttpApiServer { const ProviderSecured = ProviderApi.middleware(Authorization) export const routes = Layer.mergeAll( - HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( - Layer.provide(QuestionLive), - ), + HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(QuestionLive)), HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( Layer.provide(PermissionLive), ), HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe( Layer.provide(ProviderLive), ), - ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) + ).pipe( + Layer.provide(auth), + Layer.provide(normalize), + Layer.provide(instance), + Layer.provide(HttpServer.layerServices), + Layer.provideMerge(Observability.layer), + ) - export const layer = (opts: { hostname: string; port: number }) => - HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })), - ) - - export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), - Layer.provideMerge(Question.defaultLayer), - Layer.provideMerge(Permission.defaultLayer), - Layer.provideMerge(ProviderAuth.defaultLayer), + export const webHandler = lazy(() => + HttpRouter.toWebHandler(routes, { + memoMap, + }), ) } diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 4a03b7b29c..950b9a8588 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -14,6 +14,8 @@ import { LSP } from "../../lsp" import { Command } from "../../command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" +import { Flag } from "@/flag/flag" +import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -27,8 +29,8 @@ import { SyncRoutes } from "./sync" import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => - new Hono() +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { + const app = new Hono() .use(WorkspaceRouterMiddleware(upgrade)) .route("/project", ProjectRoutes()) .route("/pty", PtyRoutes(upgrade)) @@ -36,6 +38,13 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + const handler = ExperimentalHttpApiServer.webHandler().handler + app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw)) + } + + return app .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/sync", SyncRoutes()) @@ -283,3 +292,4 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) }, ) +} From 665a8430864fb7a55dedea63a6cbdbe400218f80 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:52:34 -0400 Subject: [PATCH 44/75] feat: unwrap Archive namespace to flat exports + barrel (#22722) --- packages/opencode/src/lsp/server.ts | 2 +- packages/opencode/src/util/archive.ts | 22 ++++++++++------------ packages/opencode/src/util/index.ts | 1 + 3 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/src/util/index.ts diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f4554ae3e6..769880ef03 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -8,7 +8,7 @@ import fs from "fs/promises" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" -import { Archive } from "../util/archive" +import { Archive } from "../util" import { Process } from "../util/process" import { which } from "../util/which" import { Module } from "@opencode-ai/shared/util/module" diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts index f65ceba547..cf25636841 100644 --- a/packages/opencode/src/util/archive.ts +++ b/packages/opencode/src/util/archive.ts @@ -1,17 +1,15 @@ import path from "path" import { Process } from "./process" -export namespace Archive { - export async function extractZip(zipPath: string, destDir: string) { - if (process.platform === "win32") { - const winZipPath = path.resolve(zipPath) - const winDestDir = path.resolve(destDir) - // $global:ProgressPreference suppresses PowerShell's blue progress bar popup - const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` - await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd]) - return - } - - await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir]) +export async function extractZip(zipPath: string, destDir: string) { + if (process.platform === "win32") { + const winZipPath = path.resolve(zipPath) + const winDestDir = path.resolve(destDir) + // $global:ProgressPreference suppresses PowerShell's blue progress bar popup + const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` + await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd]) + return } + + await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir]) } diff --git a/packages/opencode/src/util/index.ts b/packages/opencode/src/util/index.ts new file mode 100644 index 0000000000..157bb8e521 --- /dev/null +++ b/packages/opencode/src/util/index.ts @@ -0,0 +1 @@ +export * as Archive from "./archive" From 702f7412676deae8317213a56a3b32095dba5aa4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 22:53:10 -0400 Subject: [PATCH 45/75] feat: enable oxlint suspicious category, fix 24 violations (#22727) --- .oxlintrc.json | 22 ++++++++++++++++++- github/index.ts | 4 ++-- packages/app/src/context/global-sdk.tsx | 1 + .../app/src/utils/runtime-adapters.test.ts | 2 ++ .../workspace/[id]/billing/reload-section.tsx | 2 +- packages/desktop-electron/src/main/apps.ts | 2 +- packages/function/src/api.ts | 1 + packages/opencode/script/postinstall.mjs | 2 +- packages/opencode/src/bus/bus-event.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 1 + packages/opencode/src/cli/cmd/github.ts | 3 ++- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/patch/patch.ts | 2 +- packages/opencode/src/server/instance/tui.ts | 2 +- packages/opencode/src/session/prompt.ts | 3 +-- packages/opencode/src/sync/sync-event.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/test/mcp/lifecycle.test.ts | 3 +++ packages/opencode/test/session/prompt.test.ts | 11 +++++----- 20 files changed, 49 insertions(+), 22 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index c366084ee7..37d91f4254 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", + "categories": { + "suspicious": "warn" + }, "rules": { // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield "require-yield": "off", @@ -10,7 +13,24 @@ // Intentional control char matching (ANSI escapes, null byte sanitization) "no-control-regex": "off", // SST and plugin tools require triple-slash references - "triple-slash-reference": "off" + "triple-slash-reference": "off", + + // Suspicious category: suppress noisy rules + // Effect's nested function* closures inherently shadow outer scope + "no-shadow": "off", + // Namespace-heavy codebase makes this too noisy + "unicorn/consistent-function-scoping": "off", + // Opinionated — .sort()/.reverse() mutation is fine in this codebase + "unicorn/no-array-sort": "off", + "unicorn/no-array-reverse": "off", + // Not relevant — this isn't a DOM event handler codebase + "unicorn/prefer-add-event-listener": "off", + // Bundler handles module resolution + "unicorn/require-module-specifiers": "off", + // postMessage target origin not relevant for this codebase + "unicorn/require-post-message-target-origin": "off", + // Side-effectful constructors are intentional in some places + "no-new": "off" }, "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] } diff --git a/github/index.ts b/github/index.ts index be8e5aafcd..4463aa2002 100644 --- a/github/index.ts +++ b/github/index.ts @@ -542,7 +542,7 @@ async function subscribeSessionEvents() { ? JSON.stringify(part.state.input) : "Unknown" console.log() - console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title) + console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`) } if (part.type === "text") { @@ -776,7 +776,7 @@ async function assertPermissions() { console.log(` permission: ${permission}`) } catch (error) { console.error(`Failed to check permissions: ${error}`) - throw new Error(`Failed to check permissions for user ${actor}: ${error}`) + throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error }) } if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 172b5c9664..e53d60d5a0 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (started) return run started = true run = (async () => { + // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started) { attempt = new AbortController() lastEventAt = Date.now() diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts index 9f408b8eb7..49552e179c 100644 --- a/packages/app/src/utils/runtime-adapters.test.ts +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -46,7 +46,9 @@ describe("runtime adapters", () => { }) test("resolves speech recognition constructor with webkit precedence", () => { + // oxlint-disable-next-line no-extraneous-class class SpeechCtor {} + // oxlint-disable-next-line no-extraneous-class class WebkitCtor {} const ctor = getSpeechRecognitionCtor({ SpeechRecognition: SpeechCtor, diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index 90c9d7a2e4..a25963ab07 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -90,7 +90,7 @@ export function ReloadSection() { } const info = billingInfo()! setStore("show", true) - setStore("reload", info.reload ? true : true) + setStore("reload", true) setStore("reloadAmount", info.reloadAmount.toString()) setStore("reloadTrigger", info.reloadTrigger.toString()) } diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index d21b6cc9e3..174da94a5d 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) return output.toString().trim() } catch (error) { - throw new Error(`Failed to run wslpath: ${String(error)}`) + throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } } diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 68b2d450bb..58c74fe322 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -13,6 +13,7 @@ type Env = { } export class SyncServer extends DurableObject { + // oxlint-disable-next-line no-useless-constructor constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) } diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 2b990251ce..7dcf3958a9 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -64,7 +64,7 @@ function findBinary() { return { binaryPath, binaryName } } catch (error) { - throw new Error(`Could not find package ${packageName}: ${error.message}`) + throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error }) } } diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index aad5f398e0..369a40ed88 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -25,7 +25,7 @@ export namespace BusEvent { properties: def.properties, }) .meta({ - ref: "Event" + "." + def.type, + ref: `Event.${def.type}`, }) }) .toArray() diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 6c7ad39c1a..29d6ace598 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -111,6 +111,7 @@ function parseToolParams(input?: string) { } catch (evalError) { throw new Error( `Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`, + { cause: evalError }, ) } } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 191aa2dfdf..46d091642f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1031,6 +1031,7 @@ export const GithubRunCommand = cmd({ console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error) throw new Error( "Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.", + { cause: error }, ) } } @@ -1221,7 +1222,7 @@ export const GithubRunCommand = cmd({ console.log(` permission: ${permission}`) } catch (error) { console.error(`Failed to check permissions: ${error}`) - throw new Error(`Failed to check permissions for user ${actor}: ${error}`) + throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error }) } if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index e656c83d9a..9dd8796d6e 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -34,7 +34,7 @@ export const WebCommand = cmd({ describe: "start opencode server and open web interface", handler: async (args) => { if (!Flag.OPENCODE_SERVER_PASSWORD) { - UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) const server = await Server.listen(opts) diff --git a/packages/opencode/src/patch/patch.ts b/packages/opencode/src/patch/patch.ts index d36ec72c72..749efd911c 100644 --- a/packages/opencode/src/patch/patch.ts +++ b/packages/opencode/src/patch/patch.ts @@ -313,7 +313,7 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile try { originalContent = readFileSync(filePath, "utf-8") } catch (error) { - throw new Error(`Failed to read file ${filePath}: ${error}`) + throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } let originalLines = originalContent.split("\n") diff --git a/packages/opencode/src/server/instance/tui.ts b/packages/opencode/src/server/instance/tui.ts index 13f150655b..0073ef98c9 100644 --- a/packages/opencode/src/server/instance/tui.ts +++ b/packages/opencode/src/server/instance/tui.ts @@ -339,7 +339,7 @@ export const TuiRoutes = lazy(() => properties: def.properties, }) .meta({ - ref: "Event" + "." + def.type, + ref: `Event.${def.type}`, }) }), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7a74939034..f04ea8cdeb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -260,8 +260,7 @@ export namespace SessionPrompt { messageID: userMessage.info.id, sessionID: userMessage.info.sessionID, type: "text", - text: - BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`, + text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`, synthetic: true, }) userMessage.parts.push(part) diff --git a/packages/opencode/src/sync/sync-event.ts b/packages/opencode/src/sync/sync-event.ts index 2b1eb09810..bee7e3c4cf 100644 --- a/packages/opencode/src/sync/sync-event.ts +++ b/packages/opencode/src/sync/sync-event.ts @@ -273,7 +273,7 @@ export function payloads() { data: def.schema, }) .meta({ - ref: "SyncEvent" + "." + def.type, + ref: `SyncEvent.${def.type}`, }) }) .toArray() diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 701bfc4b9d..4dc984d0ee 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -181,7 +181,7 @@ export const ReadTool = Tool.define( ) } - let output = [`${filepath}`, `file`, "" + "\n"].join("\n") + let output = [`${filepath}`, `file`, "\n"].join("\n") output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n") const last = file.offset + file.raw.length - 1 diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 30be63a320..ca25862349 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -78,7 +78,7 @@ export namespace Tool { ) { return () => Effect.gen(function* () { - const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init } + const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { const attrs = { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 09caa1cd8a..add7c66d94 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -53,6 +53,7 @@ function getOrCreateClientState(name?: string): MockClientState { class MockStdioTransport { stderr: null = null pid = 12345 + // oxlint-disable-next-line no-useless-constructor constructor(_opts: any) {} async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves @@ -64,6 +65,7 @@ class MockStdioTransport { } class MockStreamableHTTP { + // oxlint-disable-next-line no-useless-constructor constructor(_url: URL, _opts?: any) {} async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves @@ -76,6 +78,7 @@ class MockStreamableHTTP { } class MockSSE { + // oxlint-disable-next-line no-useless-constructor constructor(_url: URL, _opts?: any) {} async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 1290570b81..4f5b19bca0 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -60,12 +60,11 @@ function chat(text: string) { function hanging(ready: () => void) { const encoder = new TextEncoder() let timer: ReturnType | undefined - const first = - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}` + "\n\n" + const first = `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}\n\n` const rest = [ `data: ${JSON.stringify({ From b0eae5e12f271ee53e6ac269c12c8eeee7f65496 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:02:48 -0400 Subject: [PATCH 46/75] feat: bridge permission and provider auth routes behind OPENCODE_EXPERIMENTAL_HTTPAPI (#22736) --- packages/opencode/specs/effect/http-api.md | 82 ++++++++++++++----- .../src/server/instance/httpapi/permission.ts | 4 +- .../src/server/instance/httpapi/provider.ts | 4 +- .../src/server/instance/httpapi/question.ts | 2 +- .../src/server/instance/httpapi/server.ts | 16 ++-- .../opencode/src/server/instance/index.ts | 7 +- 6 files changed, 79 insertions(+), 36 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index bd1213bb6d..71b50250ed 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -121,17 +121,46 @@ Why `question` first: Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers. -### 4. Build in parallel, do not bridge into Hono +### 4. Bridge into Hono behind a feature flag -The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`. +The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means: -The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`. +- one process, one port — no separate server +- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.) +- Effect middleware handles auth and instance lookup independently from Hono middleware +- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers -The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes. +The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged. -### 5. Migrate JSON route groups gradually +```ts +// in instance/index.ts +if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + const handler = ExperimentalHttpApiServer.webHandler().handler + app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw)) +} +``` -If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them. +The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first. + +### 5. Observability + +The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost. + +This gives: + +- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans +- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger` + +### 6. Migrate JSON route groups gradually + +As each route group is ported to `HttpApi`: + +1. change its `root` path from `/experimental/httpapi/` to `/` +2. add `.all("/", handler)` / `.all("//*", handler)` to the flag block in `instance/index.ts` +3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path +4. verify SDK output is unchanged + +Leave streaming-style endpoints on Hono until there is a clear reason to move them. ## Schema rule for HttpApi work @@ -302,36 +331,43 @@ The first slice is successful if: - OpenAPI is generated from the `HttpApi` contract - the tests are straightforward enough that the next slice feels mechanical -## Learnings from the question slice +## Learnings -The first parallel `question` spike gave us a concrete pattern to reuse. +### Schema - `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. - if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged. -- compare generated OpenAPI semantically at the route and schema level. +- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. + +### Integration + +- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances. +- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost. +- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel. +- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead. +- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in. ## Route inventory Status legend: -- `done` - parallel `HttpApi` slice exists +- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag +- `done` - Effect HttpApi slice exists but not yet bridged - `next` - good near-term candidate - `later` - possible, but not first wave - `defer` - not a good early `HttpApi` target Current instance route inventory: -- `question` - `done` - endpoints in slice: `GET /question`, `POST /question/:requestID/reply` -- `permission` - `done` - endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply` -- `provider` - `next` - best next endpoint: `GET /provider/auth` - later endpoint: `GET /provider` - defer first-wave OAuth mutations +- `question` - `bridged` + endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject` +- `permission` - `bridged` + endpoints: `GET /permission`, `POST /permission/:requestID/reply` +- `provider` - `bridged` (partial) + bridged endpoint: `GET /provider/auth` + not yet ported: `GET /provider`, OAuth mutations - `config` - `next` best next endpoint: `GET /config/providers` later endpoint: `GET /config` @@ -371,7 +407,13 @@ Recommended near-term sequence after the first spike: - [x] keep the underlying service calls identical to the current handlers - [x] compare generated OpenAPI against the current Hono/OpenAPI setup - [x] document how auth, instance lookup, and error mapping would compose in the new stack -- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default +- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap` +- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag +- [x] verify OTEL spans and HTTP logs flow to motel +- [x] bridge question, permission, and provider auth routes +- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations) +- [ ] port `config` read endpoints +- [ ] decide when to remove the flag and make Effect routes the default ## Rule of thumb diff --git a/packages/opencode/src/server/instance/httpapi/permission.ts b/packages/opencode/src/server/instance/httpapi/permission.ts index e3d152c5a4..ed8cb4e277 100644 --- a/packages/opencode/src/server/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/instance/httpapi/permission.ts @@ -3,7 +3,7 @@ import { PermissionID } from "@/permission/schema" import { Effect, Layer, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -const root = "/experimental/httpapi/permission" +const root = "/permission" export const PermissionApi = HttpApi.make("permission") .add( @@ -45,7 +45,7 @@ export const PermissionApi = HttpApi.make("permission") }), ) -export const PermissionLive = Layer.unwrap( +export const permissionHandlers = Layer.unwrap( Effect.gen(function* () { const svc = yield* Permission.Service diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts index 23e2d1ea73..e59f23f123 100644 --- a/packages/opencode/src/server/instance/httpapi/provider.ts +++ b/packages/opencode/src/server/instance/httpapi/provider.ts @@ -2,7 +2,7 @@ import { ProviderAuth } from "@/provider/auth" import { Effect, Layer } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -const root = "/experimental/httpapi/provider" +const root = "/provider" export const ProviderApi = HttpApi.make("provider") .add( @@ -33,7 +33,7 @@ export const ProviderApi = HttpApi.make("provider") }), ) -export const ProviderLive = Layer.unwrap( +export const providerHandlers = Layer.unwrap( Effect.gen(function* () { const svc = yield* ProviderAuth.Service diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index 51966d13b9..3192b530e9 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/packages/opencode/src/server/instance/httpapi/question.ts @@ -55,7 +55,7 @@ export const QuestionApi = HttpApi.make("question") }), ) -export const QuestionLive = Layer.unwrap( +export const questionHandlers = Layer.unwrap( Effect.gen(function* () { const svc = yield* Question.Service diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 2ca692efbe..9ecefe2915 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -10,9 +10,9 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" -import { PermissionApi, PermissionLive } from "./permission" -import { ProviderApi, ProviderLive } from "./provider" -import { QuestionApi, QuestionLive } from "./question" +import { PermissionApi, permissionHandlers } from "./permission" +import { ProviderApi, providerHandlers } from "./provider" +import { QuestionApi, questionHandlers } from "./question" const Query = Schema.Struct({ directory: Schema.optional(Schema.String), @@ -111,13 +111,9 @@ export namespace ExperimentalHttpApiServer { const ProviderSecured = ProviderApi.middleware(Authorization) export const routes = Layer.mergeAll( - HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(QuestionLive)), - HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( - Layer.provide(PermissionLive), - ), - HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe( - Layer.provide(ProviderLive), - ), + HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), + HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), + HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 950b9a8588..874790f1cc 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -41,7 +41,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler - app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw)) + app + .all("/question", (c) => handler(c.req.raw)) + .all("/question/*", (c) => handler(c.req.raw)) + .all("/permission", (c) => handler(c.req.raw)) + .all("/permission/*", (c) => handler(c.req.raw)) + .all("/provider/auth", (c) => handler(c.req.raw)) } return app From 343a564183d3c1aa3fc4f46896c2350bda2d2058 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:15:58 -0400 Subject: [PATCH 47/75] feat: unwrap 11 util namespaces to flat exports + barrel (#22739) --- packages/opencode/src/acp/agent.ts | 4 +- packages/opencode/src/acp/session.ts | 2 +- packages/opencode/src/bus/bus.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 2 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/lsp.ts | 2 +- packages/opencode/src/cli/cmd/debug/scrap.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 4 +- packages/opencode/src/cli/cmd/import.ts | 2 +- packages/opencode/src/cli/cmd/mcp.ts | 2 +- packages/opencode/src/cli/cmd/plug.ts | 4 +- packages/opencode/src/cli/cmd/pr.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 4 +- packages/opencode/src/cli/cmd/session.ts | 6 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 2 +- .../cmd/tui/component/dialog-session-list.tsx | 4 +- .../cli/cmd/tui/component/dialog-stash.tsx | 2 +- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../cli/cmd/tui/component/prompt/frecency.tsx | 2 +- .../cli/cmd/tui/component/prompt/history.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 4 +- .../cli/cmd/tui/component/prompt/stash.tsx | 2 +- .../cmd/tui/component/textarea-keybindings.ts | 2 +- .../src/cli/cmd/tui/context/keybind.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/kv.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- .../src/cli/cmd/tui/context/theme.tsx | 2 +- .../tui/feature-plugins/system/plugins.tsx | 2 +- .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- .../session/dialog-fork-from-timeline.tsx | 2 +- .../tui/routes/session/dialog-timeline.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../cli/cmd/tui/routes/session/permission.tsx | 4 +- .../tui/routes/session/subagent-footer.tsx | 2 +- packages/opencode/src/cli/cmd/tui/thread.ts | 6 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 2 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 +- .../src/cli/cmd/tui/util/clipboard.ts | 4 +- .../opencode/src/cli/cmd/tui/util/editor.ts | 4 +- .../opencode/src/cli/cmd/tui/util/sound.ts | 2 +- .../src/cli/cmd/tui/util/transcript.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/cli/cmd/uninstall.ts | 4 +- packages/opencode/src/cli/heap.ts | 2 +- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/config/markdown.ts | 2 +- packages/opencode/src/config/paths.ts | 2 +- packages/opencode/src/config/tui-migrate.ts | 4 +- packages/opencode/src/config/tui.ts | 2 +- .../src/control-plane/workspace-context.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 4 +- packages/opencode/src/effect/bridge.ts | 2 +- .../opencode/src/effect/instance-state.ts | 2 +- packages/opencode/src/effect/logger.ts | 2 +- packages/opencode/src/effect/run-service.ts | 2 +- packages/opencode/src/file/file.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 4 +- packages/opencode/src/file/time.ts | 2 +- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/format.ts | 2 +- packages/opencode/src/format/formatter.ts | 4 +- packages/opencode/src/global/global.ts | 2 +- packages/opencode/src/ide/ide.ts | 4 +- packages/opencode/src/index.ts | 4 +- .../opencode/src/installation/installation.ts | 2 +- packages/opencode/src/lsp/client.ts | 6 +- packages/opencode/src/lsp/index.ts | 4 +- packages/opencode/src/lsp/launch.ts | 2 +- packages/opencode/src/lsp/server.ts | 6 +- packages/opencode/src/mcp/mcp.ts | 2 +- packages/opencode/src/mcp/oauth-callback.ts | 2 +- packages/opencode/src/mcp/oauth-provider.ts | 2 +- packages/opencode/src/node.ts | 2 +- packages/opencode/src/npm/npm.ts | 4 +- packages/opencode/src/patch/patch.ts | 2 +- packages/opencode/src/permission/evaluate.ts | 2 +- .../opencode/src/permission/permission.ts | 4 +- packages/opencode/src/plugin/codex.ts | 2 +- .../src/plugin/github-copilot/copilot.ts | 2 +- packages/opencode/src/plugin/install.ts | 2 +- packages/opencode/src/plugin/meta.ts | 2 +- packages/opencode/src/plugin/plugin.ts | 2 +- packages/opencode/src/plugin/shared.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/src/project/instance.ts | 4 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/models.ts | 4 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/pty/service.ts | 2 +- packages/opencode/src/question/index.ts | 2 +- packages/opencode/src/server/control/index.ts | 2 +- packages/opencode/src/server/fence.ts | 2 +- .../opencode/src/server/instance/event.ts | 2 +- .../opencode/src/server/instance/global.ts | 2 +- .../src/server/instance/httpapi/server.ts | 2 +- .../src/server/instance/middleware.ts | 2 +- .../opencode/src/server/instance/session.ts | 2 +- packages/opencode/src/server/instance/sync.ts | 2 +- .../opencode/src/server/instance/workspace.ts | 2 +- packages/opencode/src/server/mdns.ts | 2 +- packages/opencode/src/server/middleware.ts | 2 +- packages/opencode/src/server/proxy.ts | 2 +- packages/opencode/src/server/server.ts | 2 +- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/llm.ts | 4 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/projectors.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/revert.ts | 2 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/shell/shell.ts | 2 +- packages/opencode/src/skill/discovery.ts | 2 +- packages/opencode/src/skill/skill.ts | 2 +- packages/opencode/src/snapshot/snapshot.ts | 2 +- packages/opencode/src/storage/db.ts | 4 +- .../opencode/src/storage/json-migration.ts | 4 +- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/truncate.ts | 2 +- packages/opencode/src/util/archive.ts | 2 +- packages/opencode/src/util/color.ts | 34 +- packages/opencode/src/util/filesystem.ts | 450 +++++++++--------- packages/opencode/src/util/index.ts | 11 + packages/opencode/src/util/keybind.ts | 190 ++++---- packages/opencode/src/util/local-context.ts | 40 +- packages/opencode/src/util/locale.ts | 148 +++--- packages/opencode/src/util/lock.ts | 156 +++--- packages/opencode/src/util/log.ts | 346 +++++++------- packages/opencode/src/util/process.ts | 324 +++++++------ packages/opencode/src/util/rpc.ts | 122 +++-- packages/opencode/src/util/token.ts | 8 +- packages/opencode/src/util/wildcard.ts | 104 ++-- packages/opencode/src/worktree/worktree.ts | 2 +- .../test/cli/tui/plugin-loader.test.ts | 2 +- packages/opencode/test/cli/tui/thread.test.ts | 2 +- .../opencode/test/config/agent-color.test.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 +- packages/opencode/test/config/tui.test.ts | 2 +- packages/opencode/test/file/index.test.ts | 2 +- .../opencode/test/file/path-traversal.test.ts | 2 +- packages/opencode/test/file/time.test.ts | 2 +- packages/opencode/test/fixture/plug-worker.ts | 2 +- packages/opencode/test/keybind.test.ts | 2 +- packages/opencode/test/lsp/client.test.ts | 2 +- .../test/plugin/install-concurrency.test.ts | 4 +- packages/opencode/test/plugin/install.test.ts | 2 +- .../test/plugin/loader-shared.test.ts | 2 +- packages/opencode/test/plugin/meta.test.ts | 4 +- packages/opencode/test/preload.ts | 2 +- .../test/project/migrate-global.test.ts | 2 +- .../opencode/test/project/project.test.ts | 2 +- .../test/provider/amazon-bedrock.test.ts | 2 +- .../opencode/test/provider/provider.test.ts | 2 +- .../test/server/global-session-list.test.ts | 2 +- .../test/server/project-init-git.test.ts | 4 +- .../test/server/session-actions.test.ts | 2 +- .../opencode/test/server/session-list.test.ts | 2 +- .../test/server/session-messages.test.ts | 2 +- .../test/server/session-select.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 4 +- packages/opencode/test/session/llm.test.ts | 2 +- .../test/session/messages-pagination.test.ts | 2 +- .../test/session/processor-effect.test.ts | 2 +- .../test/session/prompt-effect.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 2 +- .../test/session/revert-compact.test.ts | 2 +- .../opencode/test/session/session.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- .../structured-output-integration.test.ts | 2 +- packages/opencode/test/shell/shell.test.ts | 2 +- .../opencode/test/skill/discovery.test.ts | 2 +- .../opencode/test/snapshot/snapshot.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- .../opencode/test/tool/truncation.test.ts | 4 +- .../opencode/test/util/filesystem.test.ts | 2 +- packages/opencode/test/util/lock.test.ts | 2 +- packages/opencode/test/util/log.test.ts | 2 +- packages/opencode/test/util/module.test.ts | 2 +- packages/opencode/test/util/process.test.ts | 2 +- packages/opencode/test/util/wildcard.test.ts | 2 +- 188 files changed, 1182 insertions(+), 1193 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5f0bcdc24b..669462772d 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -31,9 +31,9 @@ import { type Usage, } from "@agentclientprotocol/sdk" -import { Log } from "../util/log" +import { Log } from "../util" import { pathToFileURL } from "url" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" import { Hash } from "@opencode-ai/shared/util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index b96ebc1c89..523b037374 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,6 +1,6 @@ import { RequestError, type McpServer } from "@agentclientprotocol/sdk" import type { ACPSessionState } from "./types" -import { Log } from "@/util/log" +import { Log } from "@/util" import type { OpencodeClient } from "@opencode-ai/sdk/v2" const log = Log.create({ service: "acp-session-manager" }) diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts index 12d7f246cd..beac809925 100644 --- a/packages/opencode/src/bus/bus.ts +++ b/packages/opencode/src/bus/bus.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" import { EffectBridge } from "@/effect" -import { Log } from "../util/log" +import { Log } from "../util" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 2fb9038b0f..8141adc4f7 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,4 +1,4 @@ -import { Log } from "@/util/log" +import { Log } from "@/util" import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 0e93946a23..fd559935fc 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -7,7 +7,7 @@ import { Agent } from "../../agent/agent" import { Provider } from "../../provider" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../util/filesystem" +import { Filesystem } from "../../util" import matter from "gray-matter" import { Instance } from "../../project/instance" import { EOL } from "os" diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 18f67b3917..185cab9c75 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "../../../effect/app-runtime" import { Effect } from "effect" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" -import { Log } from "../../../util/log" +import { Log } from "../../../util" import { EOL } from "os" export const LSPCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index f4b96e883a..464b165d72 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Project } from "../../../project/project" -import { Log } from "../../../util/log" +import { Log } from "../../../util" import { cmd } from "../cmd" export const ScrapCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 46d091642f..d7863c5486 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1,6 +1,6 @@ import path from "path" import { exec } from "child_process" -import { Filesystem } from "../../util/filesystem" +import { Filesystem } from "../../util" import * as prompts from "@clack/prompts" import { map, pipe, sortBy, values } from "remeda" import { Octokit } from "@octokit/rest" @@ -32,7 +32,7 @@ import { SessionPrompt } from "@/session/prompt" import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" -import { Process } from "@/util/process" +import { Process } from "@/util" import { Effect } from "effect" type GitHubAuthor = { diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 1232f07422..bb8a1f63f3 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -9,7 +9,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql import { Instance } from "../../project/instance" import { ShareNext } from "../../share/share-next" import { EOL } from "os" -import { Filesystem } from "../../util/filesystem" +import { Filesystem } from "../../util" import { AppRuntime } from "@/effect/app-runtime" /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index b9e4b04219..06c03d9f49 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -13,7 +13,7 @@ import { Installation } from "../../installation" import path from "path" import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" -import { Filesystem } from "../../util/filesystem" +import { Filesystem } from "../../util" import { Bus } from "../../bus" import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 692c556b24..42d06ff47f 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -7,8 +7,8 @@ import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plug import { resolvePluginTarget } from "../../plugin/shared" import { Instance } from "../../project/instance" import { errorMessage } from "../../util/error" -import { Filesystem } from "../../util/filesystem" -import { Process } from "../../util/process" +import { Filesystem } from "../../util" +import { Process } from "../../util" import { UI } from "../ui" import { cmd } from "./cmd" diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index f392bab4c8..6141ef90a8 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -3,7 +3,7 @@ import { cmd } from "./cmd" import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { Instance } from "@/project/instance" -import { Process } from "@/util/process" +import { Process } from "@/util" export const PrCommand = cmd({ command: "pr ", diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 5b7f5a1a0d..47a5c37e85 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -12,7 +12,7 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" -import { Process } from "../../util/process" +import { Process } from "../../util" import { text } from "node:stream/consumers" import { Effect } from "effect" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index e94ba5d119..da72372370 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -6,7 +6,7 @@ import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" -import { Filesystem } from "../../util/filesystem" +import { Filesystem } from "../../util" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider" @@ -25,7 +25,7 @@ import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "../../util/locale" +import { Locale } from "../../util" import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 6f79e726fa..8537a74d45 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -4,10 +4,10 @@ import { Session } from "../../session" import { SessionID } from "../../session/schema" import { bootstrap } from "../bootstrap" import { UI } from "../ui" -import { Locale } from "../../util/locale" +import { Locale } from "../../util" import { Flag } from "../../flag/flag" -import { Filesystem } from "../../util/filesystem" -import { Process } from "../../util/process" +import { Filesystem } from "../../util" +import { Process } from "../../util" import { EOL } from "os" import path from "path" import { which } from "../../util/which" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 173c5ff60c..e3e80c0fda 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -4,7 +4,7 @@ import { useSync } from "@tui/context/sync" import { map, pipe, entries, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select" import { useTheme } from "../context/theme" -import { Keybind } from "@/util/keybind" +import { Keybind } from "@/util" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 9ecb21e82a..a42755bee7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -3,14 +3,14 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createMemo, createResource, createSignal, onMount } from "solid-js" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" +import { Keybind } from "@/util" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index e8664f6289..8a6e69145d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -1,7 +1,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { createMemo, createSignal } from "solid-js" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { useTheme } from "../context/theme" import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7ca73310bc..305d076223 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 3ea8826ef8..929f3a07da 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -1,6 +1,6 @@ import path from "path" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../../context/helper" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index d49dd5c7b6..03db74de94 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -1,6 +1,6 @@ import path from "path" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import { createSimpleContext } from "../../context/helper" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 87440d0e24..c361e48c9e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -3,7 +3,7 @@ import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, S import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" @@ -27,7 +27,7 @@ import { Clipboard } from "../../util/clipboard" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx index ef3eb329a9..84ba62338a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx @@ -1,6 +1,6 @@ import path from "path" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import { createSimpleContext } from "../../context/helper" diff --git a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts index 36ab03de54..eb7b622c6f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts +++ b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" import type { KeyBinding } from "@opentui/core" import { useKeybind } from "../context/keybind" -import { Keybind } from "@/util/keybind" +import { Keybind } from "@/util" const TEXTAREA_ACTIONS = [ "submit", diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 8d3fe487d1..9c883aa205 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,5 +1,5 @@ import { createMemo } from "solid-js" -import { Keybind } from "@/util/keybind" +import { Keybind } from "@/util" import { pipe, mapValues } from "remeda" import type { TuiConfig } from "@/config/tui" import type { ParsedKey, Renderable } from "@opentui/core" diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 7a52156f88..dc0b96c62a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,5 +1,5 @@ import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { createSignal, type Setter } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 29f95141c9..612f2b7177 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -12,7 +12,7 @@ import { Provider } from "@/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index cab162f8f0..a0a59199bb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -28,7 +28,7 @@ import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, createEffect, on } from "solid-js" -import { Log } from "@/util/log" +import { Log } from "@/util" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 0c0658e743..179dc93700 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -40,7 +40,7 @@ import { useKV } from "./kv" import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { useTuiConfig } from "./tui-config" import { isRecord } from "@/util/record" import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index f2fd25ffb6..f391eb24a7 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -1,4 +1,4 @@ -import { Keybind } from "@/util/keybind" +import { Keybind } from "@/util" import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { fileURLToPath } from "url" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index bd7eac7713..dd873b753a 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from "url" import { Config } from "@/config" import { TuiConfig } from "@/config/tui" -import { Log } from "@/util/log" +import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" @@ -32,8 +32,8 @@ import { PluginMeta } from "@/plugin/meta" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" +import { Filesystem } from "@/util" +import { Process } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 742d51be22..0ce33a59a9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import type { TextPart } from "@opencode-ai/sdk/v2" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useDialog } from "../../ui/dialog" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 87248a6a8b..c0052f25fb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import type { TextPart } from "@opencode-ai/sdk/v2" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" import type { PromptInfo } from "../../component/prompt/history" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9f0dfa6038..58b5d6626c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -33,7 +33,7 @@ import type { ReasoningPart, } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -73,7 +73,7 @@ import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index ad824fe48f..3554ab44ca 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,8 +11,8 @@ import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" -import { Keybind } from "@/util/keybind" -import { Locale } from "@/util/locale" +import { Keybind } from "@/util" +import { Locale } from "@/util" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index c857937d4a..5569599607 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -6,7 +6,7 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import { useTerminalDimensions } from "@opentui/solid" export function SubagentFooter() { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 972e67d103..3aaa5a54f8 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -1,15 +1,15 @@ import { cmd } from "@/cli/cmd/cmd" import { tui } from "./app" -import { Rpc } from "@/util/rpc" +import { Rpc } from "@/util" import { type rpc } from "./worker" import path from "path" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" -import { Log } from "@/util/log" +import { Log } from "@/util" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 48adddaedc..526f3a61b5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -4,7 +4,7 @@ import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" import { useKeyboard } from "@opentui/solid" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" export type DialogConfirmProps = { title: string diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b6c937f411..dda9a5a8ed 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -8,8 +8,8 @@ import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" -import { Keybind } from "@/util/keybind" -import { Locale } from "@/util/locale" +import { Keybind } from "@/util" +import { Locale } from "@/util" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 87c0a63abc..a67eb04f69 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -4,8 +4,8 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../../../util/filesystem" -import { Process } from "../../../../util/process" +import { Filesystem } from "../../../../util" +import { Process } from "../../../../util" import { which } from "../../../../util/which" /** diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index 9eaae99fce..540cf6f497 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -3,8 +3,8 @@ import { rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { CliRenderer } from "@opentui/core" -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" +import { Filesystem } from "@/util" +import { Process } from "@/util" export namespace Editor { export async function open(opts: { value: string; renderer: CliRenderer }): Promise { diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts index d3a8db8b4f..1be35eecbf 100644 --- a/packages/opencode/src/cli/cmd/tui/util/sound.ts +++ b/packages/opencode/src/cli/cmd/tui/util/sound.ts @@ -2,7 +2,7 @@ import { Player } from "cli-sound" import { mkdirSync } from "node:fs" import { tmpdir } from "node:os" import { basename, join } from "node:path" -import { Process } from "@/util/process" +import { Process } from "@/util" import { which } from "@/util/which" import pulseA from "../asset/pulse-a.wav" with { type: "file" } import pulseB from "../asset/pulse-b.wav" with { type: "file" } diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index a89559c953..8fa0bc426e 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -1,5 +1,5 @@ import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" -import { Locale } from "@/util/locale" +import { Locale } from "@/util" import * as Model from "./model" export type TranscriptOptions = { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index da9e3985b5..393a407eb0 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,9 +1,9 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" -import { Log } from "@/util/log" +import { Log } from "@/util" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" -import { Rpc } from "@/util/rpc" +import { Rpc } from "@/util" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config" import { GlobalBus } from "@/bus/global" diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 31830f0859..c0517d491d 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -7,8 +7,8 @@ import { Global } from "../../global" import fs from "fs/promises" import path from "path" import os from "os" -import { Filesystem } from "../../util/filesystem" -import { Process } from "../../util/process" +import { Filesystem } from "../../util" +import { Process } from "../../util" interface UninstallArgs { keepConfig: boolean diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts index bb5a3d0937..cf1cffa800 100644 --- a/packages/opencode/src/cli/heap.ts +++ b/packages/opencode/src/cli/heap.ts @@ -2,7 +2,7 @@ import path from "path" import { writeHeapSnapshot } from "node:v8" import { Flag } from "@/flag/flag" import { Global } from "@/global" -import { Log } from "@/util/log" +import { Log } from "@/util" const log = Log.create({ service: "heap" }) const MINUTE = 60_000 diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7eeacf1ffc..ee1c755ebc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,8 +1,8 @@ -import { Log } from "../util/log" +import { Log } from "../util" import path from "path" import { pathToFileURL } from "url" import os from "os" -import { Process } from "../util/process" +import { Process } from "../util" import z from "zod" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 2f1483dca3..8b5392be5e 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,7 +1,7 @@ import { NamedError } from "@opencode-ai/shared/util/error" import matter from "gray-matter" import { z } from "zod" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" export namespace ConfigMarkdown { export const FILE_REGEX = /(? diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 3de82e0d11..9553e7a3aa 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,7 +1,7 @@ import { Effect, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" -import { LocalContext } from "@/util/local-context" +import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { Observability } from "./observability" import { WorkspaceContext } from "@/control-plane/workspace-context" diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts index 35f2a8740a..2269065913 100644 --- a/packages/opencode/src/file/file.ts +++ b/packages/opencode/src/file/file.ts @@ -12,7 +12,7 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" -import { Log } from "../util/log" +import { Log } from "../util" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index fee9cf4430..9a78c5b7fb 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -5,8 +5,8 @@ import z from "zod" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { ripgrep } from "ripgrep" -import { Filesystem } from "@/util/filesystem" -import { Log } from "@/util/log" +import { Filesystem } from "@/util" +import { Log } from "@/util" export namespace Ripgrep { const log = Log.create({ service: "ripgrep" }) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 86b6b4116b..327eadbef5 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { Log } from "../util/log" +import { Log } from "../util" export namespace FileTime { const log = Log.create({ service: "file.time" }) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index ab5942547d..f11cf88a65 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -15,7 +15,7 @@ import { lazy } from "@/util/lazy" import { Config } from "../config" import { FileIgnore } from "./ignore" import { Protected } from "./protected" -import { Log } from "../util/log" +import { Log } from "../util" declare const OPENCODE_LIBC: string | undefined diff --git a/packages/opencode/src/format/format.ts b/packages/opencode/src/format/format.ts index 2ce922495e..40855636f9 100644 --- a/packages/opencode/src/format/format.ts +++ b/packages/opencode/src/format/format.ts @@ -6,7 +6,7 @@ import path from "path" import { mergeDeep } from "remeda" import z from "zod" import { Config } from "../config" -import { Log } from "../util/log" +import { Log } from "../util" import * as Formatter from "./formatter" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 6c17310ff8..36249db7db 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,7 +1,7 @@ import { Npm } from "../npm" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" -import { Process } from "../util/process" +import { Filesystem } from "../util" +import { Process } from "../util" import { which } from "../util/which" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/global/global.ts b/packages/opencode/src/global/global.ts index 1bbb5968c9..3633e0855a 100644 --- a/packages/opencode/src/global/global.ts +++ b/packages/opencode/src/global/global.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" import { Flock } from "@opencode-ai/shared/util/flock" const app = "opencode" diff --git a/packages/opencode/src/ide/ide.ts b/packages/opencode/src/ide/ide.ts index cbced9c3d8..65e80d7f28 100644 --- a/packages/opencode/src/ide/ide.ts +++ b/packages/opencode/src/ide/ide.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import z from "zod" import { NamedError } from "@opencode-ai/shared/util/error" -import { Log } from "../util/log" -import { Process } from "@/util/process" +import { Log } from "../util" +import { Process } from "@/util" const SUPPORTED_IDES = [ { name: "Windsurf" as const, cmd: "windsurf" }, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 641411461d..ab3ccb712a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2,7 +2,7 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" -import { Log } from "./util/log" +import { Log } from "./util" import { ConsoleCommand } from "./cli/cmd/account" import { ProvidersCommand } from "./cli/cmd/providers" import { AgentCommand } from "./cli/cmd/agent" @@ -14,7 +14,7 @@ import { Installation } from "./installation" import { NamedError } from "@opencode-ai/shared/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" -import { Filesystem } from "./util/filesystem" +import { Filesystem } from "./util" import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" diff --git a/packages/opencode/src/installation/installation.ts b/packages/opencode/src/installation/installation.ts index 898af9269c..dcaa0cd723 100644 --- a/packages/opencode/src/installation/installation.ts +++ b/packages/opencode/src/installation/installation.ts @@ -7,7 +7,7 @@ import path from "path" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" -import { Log } from "../util/log" +import { Log } from "../util" import { CHANNEL as channel, VERSION as version } from "./meta" import semver from "semver" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index fe5a9ab182..50051b3901 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -4,15 +4,15 @@ import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" -import { Log } from "../util/log" -import { Process } from "../util/process" +import { Log } from "../util" +import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type { LSPServer } from "./server" import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index f567868f68..a55ac18402 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { Log } from "../util/log" +import { Log } from "../util" import { LSPClient } from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" @@ -9,7 +9,7 @@ import z from "zod" import { Config } from "../config" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" -import { Process } from "../util/process" +import { Process } from "../util" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index 51a7c209b4..fb84666b01 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -1,5 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "child_process" -import { Process } from "../util/process" +import { Process } from "../util" type Child = Process.Child & ChildProcessWithoutNullStreams diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 769880ef03..8110e86082 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -2,14 +2,14 @@ import type { ChildProcessWithoutNullStreams } from "child_process" import path from "path" import os from "os" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" import { text } from "node:stream/consumers" import fs from "fs/promises" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util" -import { Process } from "../util/process" +import { Process } from "../util" import { which } from "../util/which" import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts index f5179b224d..8a57bcff73 100644 --- a/packages/opencode/src/mcp/mcp.ts +++ b/packages/opencode/src/mcp/mcp.ts @@ -10,7 +10,7 @@ import { ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" import { Config } from "../config" -import { Log } from "../util/log" +import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod/v4" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 6babccd779..3e6169517f 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,6 +1,6 @@ import { createConnection } from "net" import { createServer } from "http" -import { Log } from "../util/log" +import { Log } from "../util" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 4fdc192df7..fe09e14a58 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -7,7 +7,7 @@ import type { } from "@modelcontextprotocol/sdk/shared/auth.js" import { Effect } from "effect" import { McpAuth } from "./auth" -import { Log } from "../util/log" +import { Log } from "../util" const log = Log.create({ service: "mcp.oauth" }) diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index 6f020576d9..a30783fb21 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -1,6 +1,6 @@ export { Config } from "./config" export { Server } from "./server/server" export { bootstrap } from "./cli/bootstrap" -export { Log } from "./util/log" +export { Log } from "./util" export { Database } from "./storage/db" export { JsonMigration } from "./storage/json-migration" diff --git a/packages/opencode/src/npm/npm.ts b/packages/opencode/src/npm/npm.ts index f905130719..7f17446057 100644 --- a/packages/opencode/src/npm/npm.ts +++ b/packages/opencode/src/npm/npm.ts @@ -2,10 +2,10 @@ import semver from "semver" import z from "zod" import { NamedError } from "@opencode-ai/shared/util/error" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" import path from "path" import { readdir, rm } from "fs/promises" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" import { Arborist } from "@npmcli/arborist" diff --git a/packages/opencode/src/patch/patch.ts b/packages/opencode/src/patch/patch.ts index 749efd911c..1dc99b4da9 100644 --- a/packages/opencode/src/patch/patch.ts +++ b/packages/opencode/src/patch/patch.ts @@ -2,7 +2,7 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" -import { Log } from "../util/log" +import { Log } from "../util" const log = Log.create({ service: "patch" }) diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index 2b0604f4ba..bcc4e58118 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1,4 +1,4 @@ -import { Wildcard } from "@/util/wildcard" +import { Wildcard } from "@/util" type Rule = { permission: string diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts index e2dead8fe2..a8463510c4 100644 --- a/packages/opencode/src/permission/permission.ts +++ b/packages/opencode/src/permission/permission.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" import { zod } from "@/util/effect-zod" -import { Log } from "@/util/log" +import { Log } from "@/util" import { withStatics } from "@/util/schema" -import { Wildcard } from "@/util/wildcard" +import { Wildcard } from "@/util" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import { evaluate as evalRule } from "./evaluate" diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index ea356d55d2..e0f1afa63f 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,5 +1,5 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import { Log } from "../util/log" +import { Log } from "../util" import { Installation } from "../installation" import { OAUTH_DUMMY_KEY } from "../auth" import os from "os" diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index e12d182e4f..eeea219241 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Model } from "@opencode-ai/sdk/v2" import { Installation } from "@/installation" import { iife } from "@/util/iife" -import { Log } from "../../util/log" +import { Log } from "../../util" import { setTimeout as sleep } from "node:timers/promises" import { CopilotModels } from "./models" import { MessageV2 } from "@/session/message-v2" diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 8dd8212965..0a6256d6f2 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -9,7 +9,7 @@ import { import { ConfigPaths } from "@/config/paths" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" import { isRecord } from "@/util/record" diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index 3f02f543ef..89955d1dfb 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" import { parsePluginSpecifier, pluginSource } from "./shared" diff --git a/packages/opencode/src/plugin/plugin.ts b/packages/opencode/src/plugin/plugin.ts index 23c807ebe7..ec1cf1e313 100644 --- a/packages/opencode/src/plugin/plugin.ts +++ b/packages/opencode/src/plugin/plugin.ts @@ -7,7 +7,7 @@ import type { } from "@opencode-ai/plugin" import { Config } from "../config" import { Bus } from "../bus" -import { Log } from "../util/log" +import { Log } from "../util" import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 54cc32af5b..11f36c41ae 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -3,7 +3,7 @@ import { fileURLToPath, pathToFileURL } from "url" import npa from "npm-package-arg" import semver from "semver" import { Npm } from "../npm" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { isRecord } from "@/util/record" // Old npm package names for plugins that are now built-in diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a1f2a8cb02..f00d8ffd9b 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -8,7 +8,7 @@ import { Vcs } from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" -import { Log } from "@/util/log" +import { Log } from "@/util" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" import * as Effect from "effect/Effect" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 2a20ecac97..a8a5218751 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -3,8 +3,8 @@ import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { iife } from "@/util/iife" -import { Log } from "@/util/log" -import { LocalContext } from "../util/local-context" +import { Log } from "@/util" +import { LocalContext } from "../util" import { Project } from "./project" import { WorkspaceContext } from "@/control-plane/workspace-context" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index d20bf42494..9c4ed58ce8 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,7 +2,7 @@ import z from "zod" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" -import { Log } from "../util/log" +import { Log } from "../util" import { Flag } from "@/flag/flag" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 187c616602..cb0b46adcb 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -7,7 +7,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" -import { Log } from "@/util/log" +import { Log } from "@/util" import { Instance } from "./instance" import z from "zod" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 55f137aa0b..59d629a379 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,11 +1,11 @@ import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" import path from "path" import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" import { Flock } from "@opencode-ai/shared/util/flock" import { Hash } from "@opencode-ai/shared/util/hash" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ef6cbd61e7..432dbab34a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -4,7 +4,7 @@ import fuzzysort from "fuzzysort" import { Config } from "../config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" -import { Log } from "../util/log" +import { Log } from "../util" import { Npm } from "../npm" import { Hash } from "@opencode-ai/shared/util/hash" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/pty/service.ts b/packages/opencode/src/pty/service.ts index ff52095b4f..a4ebd0696b 100644 --- a/packages/opencode/src/pty/service.ts +++ b/packages/opencode/src/pty/service.ts @@ -4,7 +4,7 @@ import { InstanceState } from "@/effect" import { Instance } from "@/project/instance" import type { Proc } from "#pty" import z from "zod" -import { Log } from "../util/log" +import { Log } from "../util" import { lazy } from "@opencode-ai/shared/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 8d023c18bf..627d04564d 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect" import { SessionID, MessageID } from "@/session/schema" import { zod } from "@/util/effect-zod" -import { Log } from "@/util/log" +import { Log } from "@/util" import { withStatics } from "@/util/schema" import { QuestionID } from "./schema" diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts index cf8949c954..737f958d6b 100644 --- a/packages/opencode/src/server/control/index.ts +++ b/packages/opencode/src/server/control/index.ts @@ -1,6 +1,6 @@ import { Auth } from "@/auth" import { AppRuntime } from "@/effect/app-runtime" -import { Log } from "@/util/log" +import { Log } from "@/util" import { Effect } from "effect" import { ProviderID } from "@/provider/schema" import { Hono } from "hono" diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index b6dbde0081..87771745c8 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -3,7 +3,7 @@ import { Database, inArray } from "@/storage/db" import { EventSequenceTable } from "@/sync/event.sql" import { Workspace } from "@/control-plane/workspace" import type { WorkspaceID } from "@/control-plane/schema" -import { Log } from "@/util/log" +import { Log } from "@/util" const HEADER = "x-opencode-sync" type State = Record diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/instance/event.ts index f13ed035e0..103d3d7cfb 100644 --- a/packages/opencode/src/server/instance/event.ts +++ b/packages/opencode/src/server/instance/event.ts @@ -2,7 +2,7 @@ import z from "zod" import { Hono } from "hono" import { describeRoute, resolver } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Log } from "@/util/log" +import { Log } from "@/util" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { AsyncQueue } from "../../util/queue" diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index b69f35a649..ac73bb64d8 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -10,7 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Instance } from "../../project/instance" import { Installation } from "@/installation" -import { Log } from "../../util/log" +import { Log } from "../../util" import { lazy } from "../../util/lazy" import { Config } from "../../config" import { errors } from "../error" diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 9ecefe2915..62ffb5940d 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -9,7 +9,7 @@ import { Flag } from "@/flag/flag" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { PermissionApi, permissionHandlers } from "./permission" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 5fd1fc25e8..7b66072c23 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -11,7 +11,7 @@ import { Session } from "@/session" import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { AppRuntime } from "@/effect/app-runtime" -import { Log } from "@/util/log" +import { Log } from "@/util" import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index c606af8544..1b2755fb8a 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -18,7 +18,7 @@ import { AppRuntime } from "../../effect/app-runtime" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" import { Command } from "../../command" -import { Log } from "../../util/log" +import { Log } from "../../util" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/instance/sync.ts index c22969130a..2513e519ee 100644 --- a/packages/opencode/src/server/instance/sync.ts +++ b/packages/opencode/src/server/instance/sync.ts @@ -5,7 +5,7 @@ import { SyncEvent } from "@/sync" import { Database, asc, and, not, or, lte, eq } from "@/storage/db" import { EventTable } from "@/sync/event.sql" import { lazy } from "@/util/lazy" -import { Log } from "@/util/log" +import { Log } from "@/util" import { errors } from "../error" const ReplayEvent = z.object({ diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index a4ff4eda8d..59369ef8e7 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -6,7 +6,7 @@ import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { Log } from "@/util/log" +import { Log } from "@/util" import { errorData } from "@/util/error" const log = Log.create({ service: "server.workspace" }) diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 778afa26ac..2011771a20 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -1,4 +1,4 @@ -import { Log } from "@/util/log" +import { Log } from "@/util" import { Bonjour } from "bonjour-service" const log = Log.create({ service: "mdns" }) diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 880c432c7c..e0958196a5 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -5,7 +5,7 @@ import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" import type { ErrorHandler, MiddlewareHandler } from "hono" import { HTTPException } from "hono/http-exception" -import { Log } from "../util/log" +import { Log } from "../util" import { Flag } from "@/flag/flag" import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 5effa5d05f..07edcc2bb2 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,6 +1,6 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Log } from "@/util/log" +import { Log } from "@/util" import * as Fence from "./fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c6c37ee438..fc3b399f79 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -7,7 +7,7 @@ import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, import { FenceMiddleware } from "./fence" import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" -import { Log } from "@/util/log" +import { Log } from "@/util" import { Flag } from "@/flag/flag" import { ControlPlaneRoutes } from "./control" import { UIRoutes } from "./ui" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3d39a60555..72b9963215 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -5,8 +5,8 @@ import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "../provider" import { MessageV2 } from "./message-v2" import z from "zod" -import { Token } from "../util/token" -import { Log } from "../util/log" +import { Token } from "../util" +import { Log } from "../util" import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 076c81ec75..cd2050adf5 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" import { Instance } from "../project/instance" -import { Log } from "../util/log" +import { Log } from "../util" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index bde36d2638..8f93bd5e15 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,5 +1,5 @@ import { Provider } from "@/provider" -import { Log } from "@/util/log" +import { Log } from "@/util" import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" @@ -16,7 +16,7 @@ import { Flag } from "@/flag/flag" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Bus } from "@/bus" -import { Wildcard } from "@/util/wildcard" +import { Wildcard } from "@/util" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1ae70c3c6e..72b27403bd 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -18,7 +18,7 @@ import { SessionSummary } from "./summary" import type { Provider } from "@/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" -import { Log } from "@/util/log" +import { Log } from "@/util" import { isRecord } from "@/util/record" export namespace SessionProcessor { diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index bc083105c2..1e092b07e0 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -3,7 +3,7 @@ import { SyncEvent } from "@/sync" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import { Log } from "../util/log" +import { Log } from "../util" const log = Log.create({ service: "session.projector" }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f04ea8cdeb..a072633aa7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3,7 +3,7 @@ import os from "os" import z from "zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" -import { Log } from "../util/log" +import { Log } from "../util" import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" @@ -42,7 +42,7 @@ import { Shell } from "@/shell/shell" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" -import { Process } from "@/util/process" +import { Process } from "@/util" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 7a7f847ad1..383fe08e87 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,7 +4,7 @@ import { Bus } from "../bus" import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" -import { Log } from "../util/log" +import { Log } from "../util" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0b82d8b99f..a4bf446a1a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -14,7 +14,7 @@ import type { SQL } from "../storage/db" import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" -import { Log } from "../util/log" +import { Log } from "../util" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 9b345ac8ef..bcb1fcc962 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -11,7 +11,7 @@ import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" import { Database, eq } from "@/storage/db" import { Config } from "@/config" -import { Log } from "@/util/log" +import { Log } from "@/util" import { SessionShareTable } from "./share.sql" export namespace ShareNext { diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 0044dda89c..056a794dc8 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -1,6 +1,6 @@ import { Flag } from "@/flag/flag" import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" +import { Filesystem } from "@/util" import { which } from "@/util/which" import path from "path" import { spawn, type ChildProcess } from "child_process" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0323f250f6..eff64ed2bb 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { withTransientReadRetry } from "@/util/effect-http-client" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" export namespace Discovery { const skillConcurrency = 4 diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 3122115cd3..ef9f661cb5 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -14,7 +14,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Config } from "../config" import { ConfigMarkdown } from "../config/markdown" import { Glob } from "@opencode-ai/shared/util/glob" -import { Log } from "../util/log" +import { Log } from "../util" import { Discovery } from "./discovery" const log = Log.create({ service: "skill" }) diff --git a/packages/opencode/src/snapshot/snapshot.ts b/packages/opencode/src/snapshot/snapshot.ts index 7aa3a4debf..8d8118131e 100644 --- a/packages/opencode/src/snapshot/snapshot.ts +++ b/packages/opencode/src/snapshot/snapshot.ts @@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Hash } from "@opencode-ai/shared/util/hash" import { Config } from "../config" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" export const Patch = z.object({ hash: z.string(), diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 247cb347cb..ee53182f36 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -2,10 +2,10 @@ import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" -import { LocalContext } from "../util/local-context" +import { LocalContext } from "../util" import { lazy } from "../util/lazy" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import z from "zod" import path from "path" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index c13a005ca6..4bf75f5a1c 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -1,13 +1,13 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite" import { Global } from "../global" -import { Log } from "../util/log" +import { Log } from "../util" import { ProjectTable } from "../project/project.sql" import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" import { SessionShareTable } from "../share/share.sql" import path from "path" import { existsSync } from "fs" -import { Filesystem } from "../util/filesystem" +import { Filesystem } from "../util" import { Glob } from "@opencode-ai/shared/util/glob" export namespace JsonMigration { diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 359c750ced..f4793c6204 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,4 +1,4 @@ -import { Log } from "../util/log" +import { Log } from "../util" import path from "path" import { Global } from "../global" import { NamedError } from "@opencode-ai/shared/util/error" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0ab1301305..1edd754143 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -4,7 +4,7 @@ import { createWriteStream } from "node:fs" import { Tool } from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" -import { Log } from "../util/log" +import { Log } from "../util" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6171e4366e..80115884d9 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,7 +22,7 @@ import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" -import { Log } from "@/util/log" +import { Log } from "@/util" import { LspTool } from "./lsp" import { Truncate } from "./truncate" import { ApplyPatchTool } from "./apply_patch" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index d607e22f28..d2aa944a85 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -5,7 +5,7 @@ import type { Agent } from "../agent/agent" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { evaluate } from "@/permission/evaluate" import { Identifier } from "../id/id" -import { Log } from "../util/log" +import { Log } from "../util" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts index cf25636841..21d014c6a8 100644 --- a/packages/opencode/src/util/archive.ts +++ b/packages/opencode/src/util/archive.ts @@ -1,5 +1,5 @@ import path from "path" -import { Process } from "./process" +import { Process } from "." export async function extractZip(zipPath: string, destDir: string) { if (process.platform === "win32") { diff --git a/packages/opencode/src/util/color.ts b/packages/opencode/src/util/color.ts index b96deaec47..43408295fa 100644 --- a/packages/opencode/src/util/color.ts +++ b/packages/opencode/src/util/color.ts @@ -1,19 +1,17 @@ -export namespace Color { - export function isValidHex(hex?: string): hex is string { - if (!hex) return false - return /^#[0-9a-fA-F]{6}$/.test(hex) - } - - export function hexToRgb(hex: string): { r: number; g: number; b: number } { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return { r, g, b } - } - - export function hexToAnsiBold(hex?: string): string | undefined { - if (!isValidHex(hex)) return undefined - const { r, g, b } = hexToRgb(hex) - return `\x1b[38;2;${r};${g};${b}m\x1b[1m` - } +export function isValidHex(hex?: string): hex is string { + if (!hex) return false + return /^#[0-9a-fA-F]{6}$/.test(hex) +} + +export function hexToRgb(hex: string): { r: number; g: number; b: number } { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +export function hexToAnsiBold(hex?: string): string | undefined { + if (!isValidHex(hex)) return undefined + const { r, g, b } = hexToRgb(hex) + return `\x1b[38;2;${r};${g};${b}m\x1b[1m` } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index b4aef05456..c3f59d3297 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -7,239 +7,237 @@ import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "@opencode-ai/shared/util/glob" -export namespace Filesystem { - // Fast sync version for metadata checks - export async function exists(p: string): Promise { - return existsSync(p) - } +// Fast sync version for metadata checks +export async function exists(p: string): Promise { + return existsSync(p) +} - export async function isDir(p: string): Promise { - try { - return statSync(p).isDirectory() - } catch { - return false +export async function isDir(p: string): Promise { + try { + return statSync(p).isDirectory() + } catch { + return false + } +} + +export function stat(p: string): ReturnType | undefined { + return statSync(p, { throwIfNoEntry: false }) ?? undefined +} + +export async function statAsync(p: string): Promise | undefined> { + return statFile(p).catch((e) => { + if (isEnoent(e)) return undefined + throw e + }) +} + +export async function size(p: string): Promise { + const s = stat(p)?.size ?? 0 + return typeof s === "bigint" ? Number(s) : s +} + +export async function readText(p: string): Promise { + return readFile(p, "utf-8") +} + +export async function readJson(p: string): Promise { + return JSON.parse(await readFile(p, "utf-8")) +} + +export async function readBytes(p: string): Promise { + return readFile(p) +} + +export async function readArrayBuffer(p: string): Promise { + const buf = await readFile(p) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer +} + +function isEnoent(e: unknown): e is { code: "ENOENT" } { + return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT" +} + +export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { + try { + if (mode) { + await writeFile(p, content, { mode }) + } else { + await writeFile(p, content) } - } - - export function stat(p: string): ReturnType | undefined { - return statSync(p, { throwIfNoEntry: false }) ?? undefined - } - - export async function statAsync(p: string): Promise | undefined> { - return statFile(p).catch((e) => { - if (isEnoent(e)) return undefined - throw e - }) - } - - export async function size(p: string): Promise { - const s = stat(p)?.size ?? 0 - return typeof s === "bigint" ? Number(s) : s - } - - export async function readText(p: string): Promise { - return readFile(p, "utf-8") - } - - export async function readJson(p: string): Promise { - return JSON.parse(await readFile(p, "utf-8")) - } - - export async function readBytes(p: string): Promise { - return readFile(p) - } - - export async function readArrayBuffer(p: string): Promise { - const buf = await readFile(p) - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer - } - - function isEnoent(e: unknown): e is { code: "ENOENT" } { - return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT" - } - - export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { - try { + } catch (e) { + if (isEnoent(e)) { + await mkdir(dirname(p), { recursive: true }) if (mode) { await writeFile(p, content, { mode }) } else { await writeFile(p, content) } - } catch (e) { - if (isEnoent(e)) { - await mkdir(dirname(p), { recursive: true }) - if (mode) { - await writeFile(p, content, { mode }) - } else { - await writeFile(p, content) - } - return - } - throw e + return } - } - - export async function writeJson(p: string, data: unknown, mode?: number): Promise { - return write(p, JSON.stringify(data, null, 2), mode) - } - - export async function writeStream( - p: string, - stream: ReadableStream | Readable, - mode?: number, - ): Promise { - const dir = dirname(p) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) - } - - const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream - const writeStream = createWriteStream(p) - await pipeline(nodeStream, writeStream) - - if (mode) { - await chmod(p, mode) - } - } - - export function mimeType(p: string): string { - return lookup(p) || "application/octet-stream" - } - - /** - * On Windows, normalize a path to its canonical casing using the filesystem. - * This is needed because Windows paths are case-insensitive but LSP servers - * may return paths with different casing than what we send them. - */ - export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - const resolved = win32.normalize(win32.resolve(windowsPath(p))) - try { - return realpathSync.native(resolved) - } catch { - return resolved - } - } - - export function normalizePathPattern(p: string): string { - if (process.platform !== "win32") return p - if (p === "*") return p - const match = p.match(/^(.*)[\\/]\*$/) - if (!match) return normalizePath(p) - const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] - return join(normalizePath(dir), "*") - } - - // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. - // Also resolves symlinks so that callers using the result as a cache key - // always get the same canonical path for a given physical directory. - export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e) { - if (isEnoent(e)) return normalizePath(resolved) - throw e - } - } - - export function windowsPath(p: string): string { - if (process.platform !== "win32") return p - return ( - p - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Git Bash for Windows paths are typically //... - .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Cygwin git paths are typically /cygdrive//... - .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // WSL paths are typically /mnt//... - .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - ) - } - export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") - } - - export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") - } - - export async function findUp( - target: string, - start: string, - stop?: string, - options?: { rootFirst?: boolean }, - ): Promise - export async function findUp( - target: string[], - start: string, - stop?: string, - options?: { rootFirst?: boolean }, - ): Promise - export async function findUp( - target: string | string[], - start: string, - stop?: string, - options?: { rootFirst?: boolean }, - ) { - const dirs = [start] - let current = start - while (true) { - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - dirs.push(parent) - current = parent - } - - const targets = Array.isArray(target) ? target : [target] - const result = [] - for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) { - for (const item of targets) { - const search = join(dir, item) - if (await exists(search)) result.push(search) - } - } - return result - } - - export async function* up(options: { targets: string[]; start: string; stop?: string }) { - const { targets, start, stop } = options - let current = start - while (true) { - for (const target of targets) { - const search = join(current, target) - if (await exists(search)) yield search - } - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - } - - export async function globUp(pattern: string, start: string, stop?: string) { - let current = start - const result = [] - while (true) { - try { - const matches = await Glob.scan(pattern, { - cwd: current, - absolute: true, - include: "file", - dot: true, - }) - result.push(...matches) - } catch { - // Skip invalid glob patterns - } - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result + throw e } } + +export async function writeJson(p: string, data: unknown, mode?: number): Promise { + return write(p, JSON.stringify(data, null, 2), mode) +} + +export async function writeStream( + p: string, + stream: ReadableStream | Readable, + mode?: number, +): Promise { + const dir = dirname(p) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + + const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream + const writeStream = createWriteStream(p) + await pipeline(nodeStream, writeStream) + + if (mode) { + await chmod(p, mode) + } +} + +export function mimeType(p: string): string { + return lookup(p) || "application/octet-stream" +} + +/** + * On Windows, normalize a path to its canonical casing using the filesystem. + * This is needed because Windows paths are case-insensitive but LSP servers + * may return paths with different casing than what we send them. + */ +export function normalizePath(p: string): string { + if (process.platform !== "win32") return p + const resolved = win32.normalize(win32.resolve(windowsPath(p))) + try { + return realpathSync.native(resolved) + } catch { + return resolved + } +} + +export function normalizePathPattern(p: string): string { + if (process.platform !== "win32") return p + if (p === "*") return p + const match = p.match(/^(.*)[\\/]\*$/) + if (!match) return normalizePath(p) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") +} + +// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. +// Also resolves symlinks so that callers using the result as a cache key +// always get the same canonical path for a given physical directory. +export function resolve(p: string): string { + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch (e) { + if (isEnoent(e)) return normalizePath(resolved) + throw e + } +} + +export function windowsPath(p: string): string { + if (process.platform !== "win32") return p + return ( + p + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Git Bash for Windows paths are typically //... + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Cygwin git paths are typically /cygdrive//... + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // WSL paths are typically /mnt//... + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + ) +} +export function overlaps(a: string, b: string) { + const relA = relative(a, b) + const relB = relative(b, a) + return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") +} + +export function contains(parent: string, child: string) { + return !relative(parent, child).startsWith("..") +} + +export async function findUp( + target: string, + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +): Promise +export async function findUp( + target: string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +): Promise +export async function findUp( + target: string | string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +) { + const dirs = [start] + let current = start + while (true) { + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + dirs.push(parent) + current = parent + } + + const targets = Array.isArray(target) ? target : [target] + const result = [] + for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) { + for (const item of targets) { + const search = join(dir, item) + if (await exists(search)) result.push(search) + } + } + return result +} + +export async function* up(options: { targets: string[]; start: string; stop?: string }) { + const { targets, start, stop } = options + let current = start + while (true) { + for (const target of targets) { + const search = join(current, target) + if (await exists(search)) yield search + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } +} + +export async function globUp(pattern: string, start: string, stop?: string) { + let current = start + const result = [] + while (true) { + try { + const matches = await Glob.scan(pattern, { + cwd: current, + absolute: true, + include: "file", + dot: true, + }) + result.push(...matches) + } catch { + // Skip invalid glob patterns + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result +} diff --git a/packages/opencode/src/util/index.ts b/packages/opencode/src/util/index.ts index 157bb8e521..f051ad9649 100644 --- a/packages/opencode/src/util/index.ts +++ b/packages/opencode/src/util/index.ts @@ -1 +1,12 @@ export * as Archive from "./archive" +export * as Color from "./color" +export * as Filesystem from "./filesystem" +export * as Keybind from "./keybind" +export * as LocalContext from "./local-context" +export * as Locale from "./locale" +export * as Lock from "./lock" +export * as Log from "./log" +export * as Process from "./process" +export * as Rpc from "./rpc" +export * as Token from "./token" +export * as Wildcard from "./wildcard" diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 83c7945ae1..10a68c4b2a 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -1,103 +1,101 @@ import { isDeepEqual } from "remeda" import type { ParsedKey } from "@opentui/core" -export namespace Keybind { - /** - * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. - * This ensures type compatibility and catches missing fields at compile time. - */ - export type Info = Pick & { - leader: boolean // our custom field - } +/** + * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. + * This ensures type compatibility and catches missing fields at compile time. + */ +export type Info = Pick & { + leader: boolean // our custom field +} - export function match(a: Info | undefined, b: Info): boolean { - if (!a) return false - const normalizedA = { ...a, super: a.super ?? false } - const normalizedB = { ...b, super: b.super ?? false } - return isDeepEqual(normalizedA, normalizedB) - } +export function match(a: Info | undefined, b: Info): boolean { + if (!a) return false + const normalizedA = { ...a, super: a.super ?? false } + const normalizedB = { ...b, super: b.super ?? false } + return isDeepEqual(normalizedA, normalizedB) +} - /** - * Convert OpenTUI's ParsedKey to our Keybind.Info format. - * This helper ensures all required fields are present and avoids manual object creation. - */ - export function fromParsedKey(key: ParsedKey, leader = false): Info { - return { - name: key.name === " " ? "space" : key.name, - ctrl: key.ctrl, - meta: key.meta, - shift: key.shift, - super: key.super ?? false, - leader, - } - } - - export function toString(info: Info | undefined): string { - if (!info) return "" - const parts: string[] = [] - - if (info.ctrl) parts.push("ctrl") - if (info.meta) parts.push("alt") - if (info.super) parts.push("super") - if (info.shift) parts.push("shift") - if (info.name) { - if (info.name === "delete") parts.push("del") - else parts.push(info.name) - } - - let result = parts.join("+") - - if (info.leader) { - result = result ? ` ${result}` : `` - } - - return result - } - - export function parse(key: string): Info[] { - if (key === "none") return [] - - return key.split(",").map((combo) => { - // Handle syntax by replacing with leader+ - const normalized = combo.replace(//g, "leader+") - const parts = normalized.toLowerCase().split("+") - const info: Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "", - } - - for (const part of parts) { - switch (part) { - case "ctrl": - info.ctrl = true - break - case "alt": - case "meta": - case "option": - info.meta = true - break - case "super": - info.super = true - break - case "shift": - info.shift = true - break - case "leader": - info.leader = true - break - case "esc": - info.name = "escape" - break - default: - info.name = part - break - } - } - - return info - }) +/** + * Convert OpenTUI's ParsedKey to our Keybind.Info format. + * This helper ensures all required fields are present and avoids manual object creation. + */ +export function fromParsedKey(key: ParsedKey, leader = false): Info { + return { + name: key.name === " " ? "space" : key.name, + ctrl: key.ctrl, + meta: key.meta, + shift: key.shift, + super: key.super ?? false, + leader, } } + +export function toString(info: Info | undefined): string { + if (!info) return "" + const parts: string[] = [] + + if (info.ctrl) parts.push("ctrl") + if (info.meta) parts.push("alt") + if (info.super) parts.push("super") + if (info.shift) parts.push("shift") + if (info.name) { + if (info.name === "delete") parts.push("del") + else parts.push(info.name) + } + + let result = parts.join("+") + + if (info.leader) { + result = result ? ` ${result}` : `` + } + + return result +} + +export function parse(key: string): Info[] { + if (key === "none") return [] + + return key.split(",").map((combo) => { + // Handle syntax by replacing with leader+ + const normalized = combo.replace(//g, "leader+") + const parts = normalized.toLowerCase().split("+") + const info: Info = { + ctrl: false, + meta: false, + shift: false, + leader: false, + name: "", + } + + for (const part of parts) { + switch (part) { + case "ctrl": + info.ctrl = true + break + case "alt": + case "meta": + case "option": + info.meta = true + break + case "super": + info.super = true + break + case "shift": + info.shift = true + break + case "leader": + info.leader = true + break + case "esc": + info.name = "escape" + break + default: + info.name = part + break + } + } + + return info + }) +} diff --git a/packages/opencode/src/util/local-context.ts b/packages/opencode/src/util/local-context.ts index 26f88ab09e..c1aef946f4 100644 --- a/packages/opencode/src/util/local-context.ts +++ b/packages/opencode/src/util/local-context.ts @@ -1,25 +1,23 @@ import { AsyncLocalStorage } from "async_hooks" -export namespace LocalContext { - export class NotFound extends Error { - constructor(public override readonly name: string) { - super(`No context found for ${name}`) - } - } - - export function create(name: string) { - const storage = new AsyncLocalStorage() - return { - use() { - const result = storage.getStore() - if (!result) { - throw new NotFound(name) - } - return result - }, - provide(value: T, fn: () => R) { - return storage.run(value, fn) - }, - } +export class NotFound extends Error { + constructor(public override readonly name: string) { + super(`No context found for ${name}`) + } +} + +export function create(name: string) { + const storage = new AsyncLocalStorage() + return { + use() { + const result = storage.getStore() + if (!result) { + throw new NotFound(name) + } + return result + }, + provide(value: T, fn: () => R) { + return storage.run(value, fn) + }, } } diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b..202e856b2e 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -1,81 +1,79 @@ -export namespace Locale { - export function titlecase(str: string) { - return str.replace(/\b\w/g, (c) => c.toUpperCase()) - } +export function titlecase(str: string) { + return str.replace(/\b\w/g, (c) => c.toUpperCase()) +} - export function time(input: number): string { - const date = new Date(input) - return date.toLocaleTimeString(undefined, { timeStyle: "short" }) - } +export function time(input: number): string { + const date = new Date(input) + return date.toLocaleTimeString(undefined, { timeStyle: "short" }) +} - export function datetime(input: number): string { - const date = new Date(input) - const localTime = time(input) - const localDate = date.toLocaleDateString() - return `${localTime} · ${localDate}` - } +export function datetime(input: number): string { + const date = new Date(input) + const localTime = time(input) + const localDate = date.toLocaleDateString() + return `${localTime} · ${localDate}` +} - export function todayTimeOrDateTime(input: number): string { - const date = new Date(input) - const now = new Date() - const isToday = - date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() +export function todayTimeOrDateTime(input: number): string { + const date = new Date(input) + const now = new Date() + const isToday = + date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() - if (isToday) { - return time(input) - } else { - return datetime(input) - } - } - - export function number(num: number): string { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + "M" - } else if (num >= 1000) { - return (num / 1000).toFixed(1) + "K" - } - return num.toString() - } - - export function duration(input: number) { - if (input < 1000) { - return `${input}ms` - } - if (input < 60000) { - return `${(input / 1000).toFixed(1)}s` - } - if (input < 3600000) { - const minutes = Math.floor(input / 60000) - const seconds = Math.floor((input % 60000) / 1000) - return `${minutes}m ${seconds}s` - } - if (input < 86400000) { - const hours = Math.floor(input / 3600000) - const minutes = Math.floor((input % 3600000) / 60000) - return `${hours}h ${minutes}m` - } - const hours = Math.floor(input / 3600000) - const days = Math.floor((input % 3600000) / 86400000) - return `${days}d ${hours}h` - } - - export function truncate(str: string, len: number): string { - if (str.length <= len) return str - return str.slice(0, len - 1) + "…" - } - - export function truncateMiddle(str: string, maxLength: number = 35): string { - if (str.length <= maxLength) return str - - const ellipsis = "…" - const keepStart = Math.ceil((maxLength - ellipsis.length) / 2) - const keepEnd = Math.floor((maxLength - ellipsis.length) / 2) - - return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd) - } - - export function pluralize(count: number, singular: string, plural: string): string { - const template = count === 1 ? singular : plural - return template.replace("{}", count.toString()) + if (isToday) { + return time(input) + } else { + return datetime(input) } } + +export function number(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M" + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + "K" + } + return num.toString() +} + +export function duration(input: number) { + if (input < 1000) { + return `${input}ms` + } + if (input < 60000) { + return `${(input / 1000).toFixed(1)}s` + } + if (input < 3600000) { + const minutes = Math.floor(input / 60000) + const seconds = Math.floor((input % 60000) / 1000) + return `${minutes}m ${seconds}s` + } + if (input < 86400000) { + const hours = Math.floor(input / 3600000) + const minutes = Math.floor((input % 3600000) / 60000) + return `${hours}h ${minutes}m` + } + const hours = Math.floor(input / 3600000) + const days = Math.floor((input % 3600000) / 86400000) + return `${days}d ${hours}h` +} + +export function truncate(str: string, len: number): string { + if (str.length <= len) return str + return str.slice(0, len - 1) + "…" +} + +export function truncateMiddle(str: string, maxLength: number = 35): string { + if (str.length <= maxLength) return str + + const ellipsis = "…" + const keepStart = Math.ceil((maxLength - ellipsis.length) / 2) + const keepEnd = Math.floor((maxLength - ellipsis.length) / 2) + + return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd) +} + +export function pluralize(count: number, singular: string, plural: string): string { + const template = count === 1 ? singular : plural + return template.replace("{}", count.toString()) +} diff --git a/packages/opencode/src/util/lock.ts b/packages/opencode/src/util/lock.ts index 3aea64394f..3f8e609378 100644 --- a/packages/opencode/src/util/lock.ts +++ b/packages/opencode/src/util/lock.ts @@ -1,54 +1,62 @@ -export namespace Lock { - const locks = new Map< - string, - { - readers: number - writer: boolean - waitingReaders: (() => void)[] - waitingWriters: (() => void)[] - } - >() +const locks = new Map< + string, + { + readers: number + writer: boolean + waitingReaders: (() => void)[] + waitingWriters: (() => void)[] + } +>() - function get(key: string) { - if (!locks.has(key)) { - locks.set(key, { - readers: 0, - writer: false, - waitingReaders: [], - waitingWriters: [], +function get(key: string) { + if (!locks.has(key)) { + locks.set(key, { + readers: 0, + writer: false, + waitingReaders: [], + waitingWriters: [], + }) + } + return locks.get(key)! +} + +function process(key: string) { + const lock = locks.get(key) + if (!lock || lock.writer || lock.readers > 0) return + + // Prioritize writers to prevent starvation + if (lock.waitingWriters.length > 0) { + const nextWriter = lock.waitingWriters.shift()! + nextWriter() + return + } + + // Wake up all waiting readers + while (lock.waitingReaders.length > 0) { + const nextReader = lock.waitingReaders.shift()! + nextReader() + } + + // Clean up empty locks + if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) { + locks.delete(key) + } +} + +export async function read(key: string): Promise { + const lock = get(key) + + return new Promise((resolve) => { + if (!lock.writer && lock.waitingWriters.length === 0) { + lock.readers++ + resolve({ + [Symbol.dispose]: () => { + lock.readers-- + process(key) + }, }) - } - return locks.get(key)! - } - - function process(key: string) { - const lock = locks.get(key) - if (!lock || lock.writer || lock.readers > 0) return - - // Prioritize writers to prevent starvation - if (lock.waitingWriters.length > 0) { - const nextWriter = lock.waitingWriters.shift()! - nextWriter() - return - } - - // Wake up all waiting readers - while (lock.waitingReaders.length > 0) { - const nextReader = lock.waitingReaders.shift()! - nextReader() - } - - // Clean up empty locks - if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) { - locks.delete(key) - } - } - - export async function read(key: string): Promise { - const lock = get(key) - - return new Promise((resolve) => { - if (!lock.writer && lock.waitingWriters.length === 0) { + } else { + lock.waitingReaders.push(() => { lock.readers++ resolve({ [Symbol.dispose]: () => { @@ -56,25 +64,25 @@ export namespace Lock { process(key) }, }) - } else { - lock.waitingReaders.push(() => { - lock.readers++ - resolve({ - [Symbol.dispose]: () => { - lock.readers-- - process(key) - }, - }) - }) - } - }) - } + }) + } + }) +} - export async function write(key: string): Promise { - const lock = get(key) +export async function write(key: string): Promise { + const lock = get(key) - return new Promise((resolve) => { - if (!lock.writer && lock.readers === 0) { + return new Promise((resolve) => { + if (!lock.writer && lock.readers === 0) { + lock.writer = true + resolve({ + [Symbol.dispose]: () => { + lock.writer = false + process(key) + }, + }) + } else { + lock.waitingWriters.push(() => { lock.writer = true resolve({ [Symbol.dispose]: () => { @@ -82,17 +90,7 @@ export namespace Lock { process(key) }, }) - } else { - lock.waitingWriters.push(() => { - lock.writer = true - resolve({ - [Symbol.dispose]: () => { - lock.writer = false - process(key) - }, - }) - }) - } - }) - } + }) + } + }) } diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 7812632768..6be9816a87 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -5,183 +5,181 @@ import { Global } from "../global" import z from "zod" import { Glob } from "@opencode-ai/shared/util/glob" -export namespace Log { - export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) - export type Level = z.infer +export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) +export type Level = z.infer - const levelPriority: Record = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - } - const keep = 10 +const levelPriority: Record = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, +} +const keep = 10 - let level: Level = "INFO" +let level: Level = "INFO" - function shouldLog(input: Level): boolean { - return levelPriority[input] >= levelPriority[level] - } +function shouldLog(input: Level): boolean { + return levelPriority[input] >= levelPriority[level] +} - export type Logger = { - debug(message?: any, extra?: Record): void - info(message?: any, extra?: Record): void - error(message?: any, extra?: Record): void - warn(message?: any, extra?: Record): void - tag(key: string, value: string): Logger - clone(): Logger - time( - message: string, - extra?: Record, - ): { - stop(): void - [Symbol.dispose](): void - } - } - - const loggers = new Map() - - export const Default = create({ service: "default" }) - - export interface Options { - print: boolean - dev?: boolean - level?: Level - } - - let logpath = "" - export function file() { - return logpath - } - let write = (msg: any) => { - process.stderr.write(msg) - return msg.length - } - - export async function init(options: Options) { - if (options.level) level = options.level - cleanup(Global.Path.log) - if (options.print) return - logpath = path.join( - Global.Path.log, - options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", - ) - await fs.truncate(logpath).catch(() => {}) - const stream = createWriteStream(logpath, { flags: "a" }) - write = async (msg: any) => { - return new Promise((resolve, reject) => { - stream.write(msg, (err) => { - if (err) reject(err) - else resolve(msg.length) - }) - }) - } - } - - async function cleanup(dir: string) { - const files = ( - await Glob.scan("????-??-??T??????.log", { - cwd: dir, - absolute: false, - include: "file", - }).catch(() => []) - ) - .filter((file) => path.basename(file) === file) - .sort() - if (files.length <= keep) return - - const doomed = files.slice(0, -keep) - await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {}))) - } - - function formatError(error: Error, depth = 0): string { - const result = error.message - return error.cause instanceof Error && depth < 10 - ? result + " Caused by: " + formatError(error.cause, depth + 1) - : result - } - - let last = Date.now() - export function create(tags?: Record) { - tags = tags || {} - - const service = tags["service"] - if (service && typeof service === "string") { - const cached = loggers.get(service) - if (cached) { - return cached - } - } - - function build(message: any, extra?: Record) { - const prefix = Object.entries({ - ...tags, - ...extra, - }) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([key, value]) => { - const prefix = `${key}=` - if (value instanceof Error) return prefix + formatError(value) - if (typeof value === "object") return prefix + JSON.stringify(value) - return prefix + value - }) - .join(" ") - const next = new Date() - const diff = next.getTime() - last - last = next.getTime() - return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n" - } - const result: Logger = { - debug(message?: any, extra?: Record) { - if (shouldLog("DEBUG")) { - write("DEBUG " + build(message, extra)) - } - }, - info(message?: any, extra?: Record) { - if (shouldLog("INFO")) { - write("INFO " + build(message, extra)) - } - }, - error(message?: any, extra?: Record) { - if (shouldLog("ERROR")) { - write("ERROR " + build(message, extra)) - } - }, - warn(message?: any, extra?: Record) { - if (shouldLog("WARN")) { - write("WARN " + build(message, extra)) - } - }, - tag(key: string, value: string) { - if (tags) tags[key] = value - return result - }, - clone() { - return Log.create({ ...tags }) - }, - time(message: string, extra?: Record) { - const now = Date.now() - result.info(message, { status: "started", ...extra }) - function stop() { - result.info(message, { - status: "completed", - duration: Date.now() - now, - ...extra, - }) - } - return { - stop, - [Symbol.dispose]() { - stop() - }, - } - }, - } - - if (service && typeof service === "string") { - loggers.set(service, result) - } - - return result +export type Logger = { + debug(message?: any, extra?: Record): void + info(message?: any, extra?: Record): void + error(message?: any, extra?: Record): void + warn(message?: any, extra?: Record): void + tag(key: string, value: string): Logger + clone(): Logger + time( + message: string, + extra?: Record, + ): { + stop(): void + [Symbol.dispose](): void } } + +const loggers = new Map() + +export const Default = create({ service: "default" }) + +export interface Options { + print: boolean + dev?: boolean + level?: Level +} + +let logpath = "" +export function file() { + return logpath +} +let write = (msg: any) => { + process.stderr.write(msg) + return msg.length +} + +export async function init(options: Options) { + if (options.level) level = options.level + cleanup(Global.Path.log) + if (options.print) return + logpath = path.join( + Global.Path.log, + options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", + ) + await fs.truncate(logpath).catch(() => {}) + const stream = createWriteStream(logpath, { flags: "a" }) + write = async (msg: any) => { + return new Promise((resolve, reject) => { + stream.write(msg, (err) => { + if (err) reject(err) + else resolve(msg.length) + }) + }) + } +} + +async function cleanup(dir: string) { + const files = ( + await Glob.scan("????-??-??T??????.log", { + cwd: dir, + absolute: false, + include: "file", + }).catch(() => []) + ) + .filter((file) => path.basename(file) === file) + .sort() + if (files.length <= keep) return + + const doomed = files.slice(0, -keep) + await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {}))) +} + +function formatError(error: Error, depth = 0): string { + const result = error.message + return error.cause instanceof Error && depth < 10 + ? result + " Caused by: " + formatError(error.cause, depth + 1) + : result +} + +let last = Date.now() +export function create(tags?: Record) { + tags = tags || {} + + const service = tags["service"] + if (service && typeof service === "string") { + const cached = loggers.get(service) + if (cached) { + return cached + } + } + + function build(message: any, extra?: Record) { + const prefix = Object.entries({ + ...tags, + ...extra, + }) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const prefix = `${key}=` + if (value instanceof Error) return prefix + formatError(value) + if (typeof value === "object") return prefix + JSON.stringify(value) + return prefix + value + }) + .join(" ") + const next = new Date() + const diff = next.getTime() - last + last = next.getTime() + return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n" + } + const result: Logger = { + debug(message?: any, extra?: Record) { + if (shouldLog("DEBUG")) { + write("DEBUG " + build(message, extra)) + } + }, + info(message?: any, extra?: Record) { + if (shouldLog("INFO")) { + write("INFO " + build(message, extra)) + } + }, + error(message?: any, extra?: Record) { + if (shouldLog("ERROR")) { + write("ERROR " + build(message, extra)) + } + }, + warn(message?: any, extra?: Record) { + if (shouldLog("WARN")) { + write("WARN " + build(message, extra)) + } + }, + tag(key: string, value: string) { + if (tags) tags[key] = value + return result + }, + clone() { + return create({ ...tags }) + }, + time(message: string, extra?: Record) { + const now = Date.now() + result.info(message, { status: "started", ...extra }) + function stop() { + result.info(message, { + status: "completed", + duration: Date.now() - now, + ...extra, + }) + } + return { + stop, + [Symbol.dispose]() { + stop() + }, + } + }, + } + + if (service && typeof service === "string") { + loggers.set(service, result) + } + + return result +} diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index e45ceb4710..96c35e5d6a 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -3,174 +3,172 @@ import launch from "cross-spawn" import { buffer } from "node:stream/consumers" import { errorMessage } from "./error" -export namespace Process { - export type Stdio = "inherit" | "pipe" | "ignore" - export type Shell = boolean | string +export type Stdio = "inherit" | "pipe" | "ignore" +export type Shell = boolean | string - export interface Options { - cwd?: string - env?: NodeJS.ProcessEnv | null - stdin?: Stdio - stdout?: Stdio - stderr?: Stdio - shell?: Shell - abort?: AbortSignal - kill?: NodeJS.Signals | number - timeout?: number - } +export interface Options { + cwd?: string + env?: NodeJS.ProcessEnv | null + stdin?: Stdio + stdout?: Stdio + stderr?: Stdio + shell?: Shell + abort?: AbortSignal + kill?: NodeJS.Signals | number + timeout?: number +} - export interface RunOptions extends Omit { - nothrow?: boolean - } +export interface RunOptions extends Omit { + nothrow?: boolean +} - export interface Result { - code: number - stdout: Buffer - stderr: Buffer - } +export interface Result { + code: number + stdout: Buffer + stderr: Buffer +} - export interface TextResult extends Result { - text: string - } +export interface TextResult extends Result { + text: string +} - export class RunFailedError extends Error { - readonly cmd: string[] - readonly code: number - readonly stdout: Buffer - readonly stderr: Buffer +export class RunFailedError extends Error { + readonly cmd: string[] + readonly code: number + readonly stdout: Buffer + readonly stderr: Buffer - constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) { - const text = stderr.toString().trim() - super( - text - ? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}` - : `Command failed with code ${code}: ${cmd.join(" ")}`, - ) - this.name = "ProcessRunFailedError" - this.cmd = [...cmd] - this.code = code - this.stdout = stdout - this.stderr = stderr - } - } - - export type Child = ChildProcess & { exited: Promise } - - export function spawn(cmd: string[], opts: Options = {}): Child { - if (cmd.length === 0) throw new Error("Command is required") - opts.abort?.throwIfAborted() - - const proc = launch(cmd[0], cmd.slice(1), { - cwd: opts.cwd, - shell: opts.shell, - env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, - stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], - windowsHide: process.platform === "win32", - }) - - let closed = false - let timer: ReturnType | undefined - - const abort = () => { - if (closed) return - if (proc.exitCode !== null || proc.signalCode !== null) return - closed = true - - proc.kill(opts.kill ?? "SIGTERM") - - const ms = opts.timeout ?? 5_000 - if (ms <= 0) return - timer = setTimeout(() => proc.kill("SIGKILL"), ms) - } - - const exited = new Promise((resolve, reject) => { - const done = () => { - opts.abort?.removeEventListener("abort", abort) - if (timer) clearTimeout(timer) - } - - proc.once("exit", (code, signal) => { - done() - resolve(code ?? (signal ? 1 : 0)) - }) - - proc.once("error", (error) => { - done() - reject(error) - }) - }) - void exited.catch(() => undefined) - - if (opts.abort) { - opts.abort.addEventListener("abort", abort, { once: true }) - if (opts.abort.aborted) abort() - } - - const child = proc as Child - child.exited = exited - return child - } - - export async function run(cmd: string[], opts: RunOptions = {}): Promise { - const proc = spawn(cmd, { - cwd: opts.cwd, - env: opts.env, - stdin: opts.stdin, - shell: opts.shell, - abort: opts.abort, - kill: opts.kill, - timeout: opts.timeout, - stdout: "pipe", - stderr: "pipe", - }) - - if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") - - const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) - .then(([code, stdout, stderr]) => ({ - code, - stdout, - stderr, - })) - .catch((err: unknown) => { - if (!opts.nothrow) throw err - return { - code: 1, - stdout: Buffer.alloc(0), - stderr: Buffer.from(errorMessage(err)), - } - }) - if (out.code === 0 || opts.nothrow) return out - throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) - } - - // Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import - // `opencode` without creating a cycle. Keep both copies in sync. - export async function stop(proc: ChildProcess) { - if (proc.exitCode !== null || proc.signalCode !== null) return - - if (process.platform !== "win32" || !proc.pid) { - proc.kill() - return - } - - const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], { - nothrow: true, - }) - - if (out.code === 0) return - proc.kill() - } - - export async function text(cmd: string[], opts: RunOptions = {}): Promise { - const out = await run(cmd, opts) - return { - ...out, - text: out.stdout.toString(), - } - } - - export async function lines(cmd: string[], opts: RunOptions = {}): Promise { - return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean) + constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) { + const text = stderr.toString().trim() + super( + text + ? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}` + : `Command failed with code ${code}: ${cmd.join(" ")}`, + ) + this.name = "ProcessRunFailedError" + this.cmd = [...cmd] + this.code = code + this.stdout = stdout + this.stderr = stderr } } + +export type Child = ChildProcess & { exited: Promise } + +export function spawn(cmd: string[], opts: Options = {}): Child { + if (cmd.length === 0) throw new Error("Command is required") + opts.abort?.throwIfAborted() + + const proc = launch(cmd[0], cmd.slice(1), { + cwd: opts.cwd, + shell: opts.shell, + env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, + stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], + windowsHide: process.platform === "win32", + }) + + let closed = false + let timer: ReturnType | undefined + + const abort = () => { + if (closed) return + if (proc.exitCode !== null || proc.signalCode !== null) return + closed = true + + proc.kill(opts.kill ?? "SIGTERM") + + const ms = opts.timeout ?? 5_000 + if (ms <= 0) return + timer = setTimeout(() => proc.kill("SIGKILL"), ms) + } + + const exited = new Promise((resolve, reject) => { + const done = () => { + opts.abort?.removeEventListener("abort", abort) + if (timer) clearTimeout(timer) + } + + proc.once("exit", (code, signal) => { + done() + resolve(code ?? (signal ? 1 : 0)) + }) + + proc.once("error", (error) => { + done() + reject(error) + }) + }) + void exited.catch(() => undefined) + + if (opts.abort) { + opts.abort.addEventListener("abort", abort, { once: true }) + if (opts.abort.aborted) abort() + } + + const child = proc as Child + child.exited = exited + return child +} + +export async function run(cmd: string[], opts: RunOptions = {}): Promise { + const proc = spawn(cmd, { + cwd: opts.cwd, + env: opts.env, + stdin: opts.stdin, + shell: opts.shell, + abort: opts.abort, + kill: opts.kill, + timeout: opts.timeout, + stdout: "pipe", + stderr: "pipe", + }) + + if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") + + const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) + .then(([code, stdout, stderr]) => ({ + code, + stdout, + stderr, + })) + .catch((err: unknown) => { + if (!opts.nothrow) throw err + return { + code: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(errorMessage(err)), + } + }) + if (out.code === 0 || opts.nothrow) return out + throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) +} + +// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import +// `opencode` without creating a cycle. Keep both copies in sync. +export async function stop(proc: ChildProcess) { + if (proc.exitCode !== null || proc.signalCode !== null) return + + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return + } + + const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], { + nothrow: true, + }) + + if (out.code === 0) return + proc.kill() +} + +export async function text(cmd: string[], opts: RunOptions = {}): Promise { + const out = await run(cmd, opts) + return { + ...out, + text: out.stdout.toString(), + } +} + +export async function lines(cmd: string[], opts: RunOptions = {}): Promise { + return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean) +} diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index ebd8be40e4..98f3a09d46 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -1,66 +1,64 @@ -export namespace Rpc { - type Definition = { - [method: string]: (input: any) => any - } +type Definition = { + [method: string]: (input: any) => any +} - export function listen(rpc: Definition) { - onmessage = async (evt) => { - const parsed = JSON.parse(evt.data) - if (parsed.type === "rpc.request") { - const result = await rpc[parsed.method](parsed.input) - postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) - } - } - } - - export function emit(event: string, data: unknown) { - postMessage(JSON.stringify({ type: "rpc.event", event, data })) - } - - export function client(target: { - postMessage: (data: string) => void | null - onmessage: ((this: Worker, ev: MessageEvent) => any) | null - }) { - const pending = new Map void>() - const listeners = new Map void>>() - let id = 0 - target.onmessage = async (evt) => { - const parsed = JSON.parse(evt.data) - if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) - pending.delete(parsed.id) - } - } - if (parsed.type === "rpc.event") { - const handlers = listeners.get(parsed.event) - if (handlers) { - for (const handler of handlers) { - handler(parsed.data) - } - } - } - } - return { - call(method: Method, input: Parameters[0]): Promise> { - const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) - target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) - }) - }, - on(event: string, handler: (data: Data) => void) { - let handlers = listeners.get(event) - if (!handlers) { - handlers = new Set() - listeners.set(event, handlers) - } - handlers.add(handler) - return () => { - handlers!.delete(handler) - } - }, +export function listen(rpc: Definition) { + onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) + if (parsed.type === "rpc.request") { + const result = await rpc[parsed.method](parsed.input) + postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) } } } + +export function emit(event: string, data: unknown) { + postMessage(JSON.stringify({ type: "rpc.event", event, data })) +} + +export function client(target: { + postMessage: (data: string) => void | null + onmessage: ((this: Worker, ev: MessageEvent) => any) | null +}) { + const pending = new Map void>() + const listeners = new Map void>>() + let id = 0 + target.onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) + if (parsed.type === "rpc.result") { + const resolve = pending.get(parsed.id) + if (resolve) { + resolve(parsed.result) + pending.delete(parsed.id) + } + } + if (parsed.type === "rpc.event") { + const handlers = listeners.get(parsed.event) + if (handlers) { + for (const handler of handlers) { + handler(parsed.data) + } + } + } + } + return { + call(method: Method, input: Parameters[0]): Promise> { + const requestId = id++ + return new Promise((resolve) => { + pending.set(requestId, resolve) + target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + }) + }, + on(event: string, handler: (data: Data) => void) { + let handlers = listeners.get(event) + if (!handlers) { + handlers = new Set() + listeners.set(event, handlers) + } + handlers.add(handler) + return () => { + handlers!.delete(handler) + } + }, + } +} diff --git a/packages/opencode/src/util/token.ts b/packages/opencode/src/util/token.ts index cee5adc377..52951c4cf2 100644 --- a/packages/opencode/src/util/token.ts +++ b/packages/opencode/src/util/token.ts @@ -1,7 +1,5 @@ -export namespace Token { - const CHARS_PER_TOKEN = 4 +const CHARS_PER_TOKEN = 4 - export function estimate(input: string) { - return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN)) - } +export function estimate(input: string) { + return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN)) } diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index f54b6c85fd..0efb94e915 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -1,59 +1,57 @@ import { sortBy, pipe } from "remeda" -export namespace Wildcard { - export function match(str: string, pattern: string) { - if (str) str = str.replaceAll("\\", "/") - if (pattern) pattern = pattern.replaceAll("\\", "/") - let escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars - .replace(/\*/g, ".*") // * becomes .* - .replace(/\?/g, ".") // ? becomes . +export function match(str: string, pattern: string) { + if (str) str = str.replaceAll("\\", "/") + if (pattern) pattern = pattern.replaceAll("\\", "/") + let escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars + .replace(/\*/g, ".*") // * becomes .* + .replace(/\?/g, ".") // ? becomes . - // If pattern ends with " *" (space + wildcard), make the trailing part optional - // This allows "ls *" to match both "ls" and "ls -la" - if (escaped.endsWith(" .*")) { - escaped = escaped.slice(0, -3) + "( .*)?" - } - - const flags = process.platform === "win32" ? "si" : "s" - return new RegExp("^" + escaped + "$", flags).test(str) + // If pattern ends with " *" (space + wildcard), make the trailing part optional + // This allows "ls *" to match both "ls" and "ls -la" + if (escaped.endsWith(" .*")) { + escaped = escaped.slice(0, -3) + "( .*)?" } - export function all(input: string, patterns: Record) { - const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) - let result = undefined - for (const [pattern, value] of sorted) { - if (match(input, pattern)) { - result = value - continue - } - } - return result - } - - export function allStructured(input: { head: string; tail: string[] }, patterns: Record) { - const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) - let result = undefined - for (const [pattern, value] of sorted) { - const parts = pattern.split(/\s+/) - if (!match(input.head, parts[0])) continue - if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) { - result = value - continue - } - } - return result - } - - function matchSequence(items: string[], patterns: string[]): boolean { - if (patterns.length === 0) return true - const [pattern, ...rest] = patterns - if (pattern === "*") return matchSequence(items, rest) - for (let i = 0; i < items.length; i++) { - if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) { - return true - } - } - return false - } + const flags = process.platform === "win32" ? "si" : "s" + return new RegExp("^" + escaped + "$", flags).test(str) +} + +export function all(input: string, patterns: Record) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = undefined + for (const [pattern, value] of sorted) { + if (match(input, pattern)) { + result = value + continue + } + } + return result +} + +export function allStructured(input: { head: string; tail: string[] }, patterns: Record) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = undefined + for (const [pattern, value] of sorted) { + const parts = pattern.split(/\s+/) + if (!match(input.head, parts[0])) continue + if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) { + result = value + continue + } + } + return result +} + +function matchSequence(items: string[], patterns: string[]): boolean { + if (patterns.length === 0) return true + const [pattern, ...rest] = patterns + if (pattern === "*") return matchSequence(items, rest) + for (let i = 0; i < items.length; i++) { + if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) { + return true + } + } + return false } diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts index fab9ce57fa..86ef95f0e6 100644 --- a/packages/opencode/src/worktree/worktree.ts +++ b/packages/opencode/src/worktree/worktree.ts @@ -7,7 +7,7 @@ import { Project } from "../project/project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" -import { Log } from "../util/log" +import { Log } from "../util" import { Slug } from "@opencode-ai/shared/util/slug" import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 119517b10c..8446570cc3 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { Global } from "../../../src/global" import { TuiConfig } from "../../../src/config/tui" -import { Filesystem } from "../../../src/util/filesystem" +import { Filesystem } from "../../../src/util" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 176c2575a3..1c5c7e65e4 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -3,7 +3,7 @@ import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" import * as App from "../../../src/cli/cmd/tui/app" -import { Rpc } from "../../../src/util/rpc" +import { Rpc } from "../../../src/util" import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index d77782354c..bfa948619b 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -5,7 +5,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Color } from "../../src/util/color" +import { Color } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8cf410c3d2..bc9fe5b015 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -23,7 +23,7 @@ import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import * as Network from "../../src/util/network" import { Npm } from "../../src/npm" diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 4767e94b01..c80905cd1d 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Config } from "../../src/config" import { TuiConfig } from "../../src/config/tui" import { Global } from "../../src/global" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 877e2ae0a3..28fd2c8384 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,7 +5,7 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 1f2e45a6ad..1190053949 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe } from "bun:test" import { Effect } from "effect" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { provideInstance, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index 7f65d05ead..cb6390df87 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -6,7 +6,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { FileTime } from "../../src/file/time" import { Instance } from "../../src/project/instance" import { SessionID } from "../../src/session/schema" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/fixture/plug-worker.ts b/packages/opencode/test/fixture/plug-worker.ts index e4b80c5dc5..c9afcd39f9 100644 --- a/packages/opencode/test/fixture/plug-worker.ts +++ b/packages/opencode/test/fixture/plug-worker.ts @@ -1,7 +1,7 @@ import path from "path" import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" type Msg = { dir: string diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index 4ca1f1697e..1e900a6020 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { Keybind } from "../src/util/keybind" +import { Keybind } from "../src/util" describe("Keybind.toString", () => { test("should convert simple key to string", () => { diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index c2ba3ac5b0..414d11f8e7 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -3,7 +3,7 @@ import path from "path" import { LSPClient } from "../../src/lsp/client" import { LSPServer } from "../../src/lsp/server" import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" // Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { diff --git a/packages/opencode/test/plugin/install-concurrency.test.ts b/packages/opencode/test/plugin/install-concurrency.test.ts index cf3e8692e1..06ec2fc996 100644 --- a/packages/opencode/test/plugin/install-concurrency.test.ts +++ b/packages/opencode/test/plugin/install-concurrency.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { Process } from "../../src/util/process" -import { Filesystem } from "../../src/util/filesystem" +import { Process } from "../../src/util" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" const root = path.join(import.meta.dir, "../..") diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index 5ce21c4cf4..f125f188a7 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { parse as parseJsonc } from "jsonc-parser" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 4265e83c55..5072c1e748 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../fixture/fixture" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts index 0571740667..3e2d4c6177 100644 --- a/packages/opencode/test/plugin/meta.test.ts +++ b/packages/opencode/test/plugin/meta.test.ts @@ -4,8 +4,8 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../fixture/fixture" -import { Process } from "../../src/util/process" -import { Filesystem } from "../../src/util/filesystem" +import { Process } from "../../src/util" +import { Filesystem } from "../../src/util" const { PluginMeta } = await import("../../src/plugin/meta") const root = path.join(import.meta.dir, "../..") diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 0ddc797faf..ba5df4f1ea 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -78,7 +78,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const { Log } = await import("../src/util/log") +const { Log } = await import("../src/util") const { initProjectors } = await import("../src/server/projectors") Log.init({ diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index d4313c12f1..d645fb25b8 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -5,7 +5,7 @@ import { SessionTable } from "../../src/session/session.sql" import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" import { SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { $ } from "bun" import { tmpdir } from "../fixture/fixture" import { Effect } from "effect" diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index ba253a9205..a579a2335d 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Project } from "../../src/project/project" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 6809e4d17e..03f83601dd 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -8,7 +8,7 @@ import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" import { makeRuntime } from "../../src/effect/run-service" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index a6a93e8091..300a5b9031 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -9,7 +9,7 @@ import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider" import { ProviderID, ModelID } from "../../src/provider/schema" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { Env } from "../../src/env" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a1e374b4f7..c029fd9336 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -4,7 +4,7 @@ import z from "zod" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { Session as SessionNs } from "../../src/session" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 406b3d6d89..c3ee18e73a 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -5,8 +5,8 @@ import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { Filesystem } from "../../src/util/filesystem" -import { Log } from "../../src/util/log" +import { Filesystem } from "../../src/util" +import { Log } from "../../src/util" import { resetDatabase } from "../fixture/db" import { provideInstance, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 301691ae2f..3209ebff35 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -4,7 +4,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "../../src/session" import type { SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 75adb7f9f3..9af60b9bdd 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "../../src/session" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 24ee6a1b43..d558d4324f 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 12552538da..c53448dfd4 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Session as SessionNs } from "../../src/session" import type { SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 7711d31931..ee01932210 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -8,9 +8,9 @@ import { Config } from "../../src/config" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" -import { Token } from "../../src/util/token" +import { Token } from "../../src/util" import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index f25ecc356a..d1d53f605b 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -10,7 +10,7 @@ import { Provider } from "../../src/provider" import { ProviderTransform } from "../../src/provider/transform" import { ModelsDev } from "../../src/provider/models" import { ProviderID, ModelID } from "../../src/provider/schema" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index f728bd3646..804076dd48 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -6,7 +6,7 @@ import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" const root = path.join(__dirname, "../..") Log.init({ print: false }) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 982399d6d1..87ff40c707 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -18,7 +18,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 5ff8bf3424..0a750352a7 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -36,7 +36,7 @@ import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 4f5b19bca0..acf305f3f9 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -8,7 +8,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 679f6166ff..211fcde9a8 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -7,7 +7,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionRevert } from "../../src/session/revert" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { MessageID, PartID, SessionID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 15132a2701..9c4686cba6 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Session as SessionNs } from "../../src/session" import { Bus } from "../../src/bus" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 3681b14f7a..cb7fe4568e 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -22,7 +22,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestLLMServer } from "../lib/llm-server" diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 64266de47a..346705bf22 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer } from "effect" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts index 760d6dc05a..6d7a77d72d 100644 --- a/packages/opencode/test/shell/shell.test.ts +++ b/packages/opencode/test/shell/shell.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Shell } from "../../src/shell/shell" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" const withShell = async (shell: string | undefined, fn: () => void | Promise) => { const prev = process.env.SHELL diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index de356ef154..175500862d 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "../../src/global" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { rm } from "fs/promises" import path from "path" diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 0c480a97c2..3330b497c3 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { provideInstance, tmpdir } from "../fixture/fixture" // Git always outputs /-separated paths internally. Snapshot.patch() joins them diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 19135ba98b..6a3eac15e0 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 727ab74f18..ee8cb53963 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index f14ec33105..fa65068f86 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,7 +13,7 @@ import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" import { Truncate } from "../../src/tool/truncate" import { Tool } from "../../src/tool/tool" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index c9ef0d82a3..d0873046d6 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -3,8 +3,8 @@ import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncate" import { Identifier } from "../../src/id/id" -import { Process } from "../../src/util/process" -import { Filesystem } from "../../src/util/filesystem" +import { Process } from "../../src/util" +import { Filesystem } from "../../src/util" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 3abcf011bc..1f3a66b950 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" describe("filesystem", () => { diff --git a/packages/opencode/test/util/lock.test.ts b/packages/opencode/test/util/lock.test.ts index b877311e39..d51b936484 100644 --- a/packages/opencode/test/util/lock.test.ts +++ b/packages/opencode/test/util/lock.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Lock } from "../../src/util/lock" +import { Lock } from "../../src/util" function tick() { return new Promise((r) => queueMicrotask(r)) diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts index 33e64fcd01..336b16a17b 100644 --- a/packages/opencode/test/util/log.test.ts +++ b/packages/opencode/test/util/log.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { Global } from "../../src/global" -import { Log } from "../../src/util/log" +import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" const log = Global.Path.log diff --git a/packages/opencode/test/util/module.test.ts b/packages/opencode/test/util/module.test.ts index 6f8539bfb7..6725149c74 100644 --- a/packages/opencode/test/util/module.test.ts +++ b/packages/opencode/test/util/module.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Module } from "@opencode-ai/shared/util/module" -import { Filesystem } from "../../src/util/filesystem" +import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" describe("util.module", () => { diff --git a/packages/opencode/test/util/process.test.ts b/packages/opencode/test/util/process.test.ts index 1d08cba6b7..5442025700 100644 --- a/packages/opencode/test/util/process.test.ts +++ b/packages/opencode/test/util/process.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { Process } from "../../src/util/process" +import { Process } from "../../src/util" import { tmpdir } from "../fixture/fixture" function node(script: string) { diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index 56e753d12a..7c9b1e4ac1 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test" -import { Wildcard } from "../../src/util/wildcard" +import { Wildcard } from "../../src/util" test("match handles glob tokens", () => { expect(Wildcard.match("file1.txt", "file?.txt")).toBe(true) From 80f1f1b5b8535b6008af54621665738115346cde Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:27:32 -0400 Subject: [PATCH 48/75] feat: enable type-aware no-floating-promises rule, fix all 177 violations (#22741) --- .oxlintrc.json | 8 +++++++- bun.lock | 15 ++++++++++++++ github/index.ts | 2 +- package.json | 1 + packages/app/src/app.tsx | 4 ++-- .../components/dialog-connect-provider.tsx | 4 ++-- .../app/src/components/dialog-select-file.tsx | 4 ++-- .../src/components/dialog-select-server.tsx | 6 +++--- packages/app/src/components/prompt-input.tsx | 10 +++++----- .../app/src/components/prompt-input/submit.ts | 2 +- .../src/components/session-context-usage.tsx | 2 +- .../session/session-sortable-terminal-tab.tsx | 2 +- packages/app/src/components/terminal.tsx | 2 +- packages/app/src/context/global-sync.tsx | 10 +++++----- packages/app/src/context/layout.tsx | 2 +- packages/app/src/context/terminal.tsx | 4 ++-- packages/app/src/pages/layout.tsx | 20 +++++++++---------- .../src/pages/layout/sidebar-workspace.tsx | 2 +- packages/app/src/pages/session.tsx | 2 +- packages/app/src/pages/session/helpers.ts | 2 +- .../console/app/script/generate-sitemap.ts | 2 +- .../console/app/src/component/spotlight.tsx | 2 +- .../app/src/routes/black/subscribe/[plan].tsx | 2 +- .../console/app/src/routes/download/index.tsx | 2 +- packages/console/app/src/routes/index.tsx | 2 +- packages/console/app/src/routes/temp.tsx | 2 +- .../app/src/routes/zen/util/dataDumper.ts | 4 ++-- packages/desktop/src/entry.tsx | 4 ++-- packages/desktop/src/index.tsx | 2 +- packages/desktop/src/loading.tsx | 2 +- packages/desktop/src/menu.ts | 2 +- packages/desktop/src/webview-zoom.ts | 2 +- packages/enterprise/test-debug.ts | 2 +- packages/opencode/script/postinstall.mjs | 2 +- packages/opencode/src/acp/agent.ts | 4 ++-- packages/opencode/src/cli/cmd/tui/app.tsx | 6 +++--- .../cmd/tui/component/dialog-session-list.tsx | 2 +- .../tui/component/dialog-session-rename.tsx | 2 +- .../cli/cmd/tui/component/error-component.tsx | 4 ++-- .../cli/cmd/tui/component/prompt/index.tsx | 16 +++++++-------- .../opencode/src/cli/cmd/tui/context/kv.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +++--- .../src/cli/cmd/tui/context/theme.tsx | 4 ++-- .../tui/feature-plugins/system/plugins.tsx | 4 ++-- .../cmd/tui/routes/session/dialog-message.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 14 ++++++------- .../cli/cmd/tui/routes/session/permission.tsx | 8 ++++---- .../cli/cmd/tui/routes/session/question.tsx | 6 +++--- packages/opencode/src/config/config.ts | 6 +++--- .../opencode/src/control-plane/workspace.ts | 6 +++--- packages/opencode/src/file/watcher.ts | 6 +++--- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/lsp/index.ts | 4 ++-- packages/opencode/src/plugin/plugin.ts | 2 +- packages/opencode/src/provider/models.ts | 2 +- .../opencode/src/server/instance/session.ts | 18 +++++++++-------- packages/opencode/src/server/proxy.ts | 2 +- packages/opencode/src/storage/db.ts | 6 +++--- packages/opencode/src/sync/sync-event.ts | 6 +++--- packages/opencode/src/util/defer.ts | 2 +- packages/opencode/src/util/log.ts | 2 +- .../test/cli/tui/plugin-lifecycle.test.ts | 2 +- packages/opencode/test/mcp/headers.test.ts | 4 ++-- packages/opencode/test/mcp/lifecycle.test.ts | 10 +++++----- .../test/mcp/oauth-auto-connect.test.ts | 8 ++++---- .../opencode/test/mcp/oauth-browser.test.ts | 10 +++++----- .../test/memory/abort-leak-webfetch.ts | 2 +- .../opencode/test/permission/next.test.ts | 2 +- packages/opencode/test/preload.ts | 2 +- .../test/project/migrate-global.test.ts | 2 +- .../opencode/test/project/project.test.ts | 2 +- .../test/server/global-session-list.test.ts | 2 +- .../test/server/project-init-git.test.ts | 2 +- .../test/server/session-actions.test.ts | 2 +- .../opencode/test/server/session-list.test.ts | 2 +- .../test/server/session-messages.test.ts | 2 +- .../test/server/session-select.test.ts | 2 +- .../opencode/test/session/compaction.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 2 +- .../test/session/messages-pagination.test.ts | 2 +- .../test/session/processor-effect.test.ts | 2 +- .../test/session/prompt-effect.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 6 +++--- .../test/session/revert-compact.test.ts | 2 +- .../opencode/test/session/session.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- .../structured-output-integration.test.ts | 2 +- .../opencode/test/skill/discovery.test.ts | 2 +- .../js/src/gen/core/serverSentEvents.gen.ts | 2 +- .../src/v2/gen/core/serverSentEvents.gen.ts | 2 +- packages/slack/src/index.ts | 4 ++-- packages/ui/src/components/basic-tool.tsx | 2 +- packages/ui/src/components/list.tsx | 2 +- packages/ui/src/components/message-part.tsx | 2 +- packages/ui/src/components/text-field.tsx | 5 +++-- packages/ui/src/components/text-reveal.tsx | 2 +- .../components/thinking-heading.stories.tsx | 2 +- .../ui/src/components/tool-error-card.tsx | 2 +- .../ui/src/components/tool-status-title.tsx | 2 +- packages/ui/src/pierre/worker.ts | 2 +- packages/ui/vite.config.ts | 4 ++-- script/duplicate-pr.ts | 2 +- 103 files changed, 212 insertions(+), 187 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 37d91f4254..e16c8408d6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,7 +30,13 @@ // postMessage target origin not relevant for this codebase "unicorn/require-post-message-target-origin": "off", // Side-effectful constructors are intentional in some places - "no-new": "off" + "no-new": "off", + + // Type-aware: catch unhandled promises + "typescript/no-floating-promises": "warn" + }, + "options": { + "typeAware": true }, "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] } diff --git a/bun.lock b/bun.lock index 705181160a..a011a648fe 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "glob": "13.0.5", "husky": "9.1.7", "oxlint": "1.60.0", + "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", @@ -1680,6 +1681,18 @@ "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P20j3MLqfwIT+94qGU3htC7dWp4pXGZW1p1p7FRUzu1aopq7c9nPCgf0W/WjktqQ57+iuTq9mbSlwWinl6+H1A=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-81TmmuBcPedEA0MwRmObuQuXnCprS1UiHQWGe7pseqNAJzUWXeAPrayqKTACX92VpruJI+yvY0XJrFp11PpcTA=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.21.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sbjBr6zDduX8rNO0PTjhf7VYLCPWqdijWiMPp8e10qu6Tam1GdaVLaLlX8QrNupTgglO1GvqqgY/jcacWL8a6g=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.21.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jNrOcy53R5TJQfrK444Cm60bW9437xDoxPbm3AdvFSo/fhdFMllawc7uZC2Wzr+EAjTkW13K8R4QHzsUdBG9fQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.21.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWeRxJJILDE4b9UqHEWGBxcBc1TUS6zWHhxcyxTZMwf4q3wdKeu0OHYAcwLGJzoSjEIf6FTjyfPiRNil2oqsdg=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.21.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ob9AA9teI8ckPo1whV1smLr5NrqwgBv/8boDbK0YZG+fKgNGRwr1hBj1ORgFWOQaUBv+5njp5A0RAfJJjQ95QQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="], @@ -4100,6 +4113,8 @@ "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="], + "oxlint-tsgolint": ["oxlint-tsgolint@0.21.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.21.0", "@oxlint-tsgolint/darwin-x64": "0.21.0", "@oxlint-tsgolint/linux-arm64": "0.21.0", "@oxlint-tsgolint/linux-x64": "0.21.0", "@oxlint-tsgolint/win32-arm64": "0.21.0", "@oxlint-tsgolint/win32-x64": "0.21.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-HiWPhANwRnN1pZJQ2SgNB3WRR+1etLJHmRzQ/MJhyINsEIaOUCjxhlXJKbEaVUwdnyXwRWqo/P9Fx21lz0/mSg=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="], diff --git a/github/index.ts b/github/index.ts index 4463aa2002..51ee2a46a5 100644 --- a/github/index.ts +++ b/github/index.ts @@ -513,7 +513,7 @@ async function subscribeSessionEvents() { const decoder = new TextDecoder() let text = "" - ;(async () => { + void (async () => { while (true) { try { const { done, value } = await reader.read() diff --git a/package.json b/package.json index 8c5ae91955..5fecc09922 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "glob": "13.0.5", "husky": "9.1.7", "oxlint": "1.60.0", + "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9983548ba0..a2a746c05b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -197,12 +197,12 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { fallback={ { - if (checkMode() === "background") healthCheckActions.refetch() + if (checkMode() === "background") void healthCheckActions.refetch() }} onServerSelected={(key) => { setCheckMode("blocking") server.setActive(key) - healthCheckActions.refetch() + void healthCheckActions.refetch() }} /> } diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 41225d02aa..e305743799 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (loading()) return if (methods().length === 1) { auto = true - selectMethod(0) + void selectMethod(0) } }) @@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) { key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return - selectMethod(index) + void selectMethod(index) }} > {(i) => ( diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index a0347a0399..186906f920 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -348,8 +348,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const open = (path: string) => { const value = file.tab(path) - tabs().open(value) - file.load(path) + void tabs().open(value) + void file.load(path) if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ca4c42a376..dd92edec3e 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -344,7 +344,7 @@ export function DialogSelectServer() { createEffect(() => { items() - refreshHealth() + void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) @@ -498,7 +498,7 @@ export function DialogSelectServer() { async function handleRemove(url: ServerConnection.Key) { server.remove(url) if ((await platform.getDefaultServer?.()) === url) { - platform.setDefaultServer?.(null) + void platform.setDefaultServer?.(null) } } @@ -536,7 +536,7 @@ export function DialogSelectServer() { items={sortedItems} key={(x) => x.http.url} onSelect={(x) => { - if (x) select(x) + if (x) void select(x) }} divider={true} class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8ddb10a906..534215022a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -212,9 +212,9 @@ export const PromptInput: Component = (props) => { if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) - tabs().open(tab) + void tabs().open(tab) tabs().setActive(tab) - Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) } const recent = createMemo(() => { @@ -1139,7 +1139,7 @@ export const PromptInput: Component = (props) => { } if (working()) { - abort() + void abort() event.preventDefault() event.stopPropagation() return @@ -1205,7 +1205,7 @@ export const PromptInput: Component = (props) => { return } if (working()) { - abort() + void abort() event.preventDefault() } return @@ -1245,7 +1245,7 @@ export const PromptInput: Component = (props) => { ) { return } - handleSubmit(event) + void handleSubmit(event) } } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 27e8980431..6805f619c1 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -295,7 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const mode = input.mode() if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { - if (input.working()) abort() + if (input.working()) void abort() return } diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index d7c249ab03..6b7fe4ef7d 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -24,7 +24,7 @@ function openSessionContext(args: { }) { if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") - args.tabs.open("context") + void args.tabs.open("context") args.tabs.setActive("context") } diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index ba697f91af..2d88ed1806 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -44,7 +44,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const close = () => { const count = terminal.all().length - terminal.close(props.terminal.id) + void terminal.close(props.terminal.id) if (count === 1) { props.onClose?.() } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 9b7ef83b28..db7d53f2b6 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -415,7 +415,7 @@ export const Terminal = (props: TerminalProps) => { if (local.autoFocus !== false) focusTerminal() if (typeof document !== "undefined" && document.fonts) { - document.fonts.ready.then(scheduleFit) + void document.fonts.ready.then(scheduleFit) } const onResize = t.onResize((size) => { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index fe5f2f1301..57b76a96f7 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -237,7 +237,7 @@ function createGlobalSync() { }) sessionLoads.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { sessionLoads.delete(directory) children.unpin(directory) }) @@ -273,7 +273,7 @@ function createGlobalSync() { })() booting.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { booting.delete(directory) children.unpin(directory) }) @@ -317,7 +317,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - sdkFor(directory) + void sdkFor(directory) .lsp.status() .then((x) => { setStore("lsp", x.data ?? []) @@ -359,13 +359,13 @@ function createGlobalSync() { eventFrame = undefined eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) }) } else { eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) } void bootstrap() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 87f11d2b64..74ea285310 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -582,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return - globalSync.project.loadSessions(root) + void globalSync.project.loadSessions(root) server.projects.open(root) }, close(directory: string) { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 17355aab9a..31d2d6e04c 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -117,7 +117,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat entry?.value.clear() } - removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, "terminal"), platform) const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { @@ -126,7 +126,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } for (const key of legacy) { - removePersisted({ key }, platform) + void removePersisted({ key }, platform) } } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3ba2659a3b..8fad0bafe3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -956,7 +956,7 @@ export default function Layout(props: ParentProps) { // warm up child store to prevent flicker globalSync.child(target.worktree) - openProject(target.worktree) + void openProject(target.worktree) } function navigateSessionByUnseen(offset: number) { @@ -1094,7 +1094,7 @@ export default function Layout(props: ParentProps) { disabled: !params.dir || !params.id, onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) - if (session) archiveSession(session) + if (session) void archiveSession(session) }, }, { @@ -1360,11 +1360,11 @@ export default function Layout(props: ParentProps) { if (!server.isLocal()) return for (const directory of collectOpenProjectDeepLinks(urls)) { - openProject(directory) + void openProject(directory) } for (const link of collectNewSessionDeepLinks(urls)) { - openProject(link.directory, false) + void openProject(link.directory, false) const slug = base64Encode(link.directory) if (link.prompt) { setSessionHandoff(slug, { prompt: link.prompt }) @@ -1453,11 +1453,11 @@ export default function Layout(props: ParentProps) { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { - openProject(directory, false) + void openProject(directory, false) } - navigateToProject(result[0]) + void navigateToProject(result[0]) } else if (result) { - openProject(result) + void openProject(result) } } @@ -1825,7 +1825,7 @@ export default function Layout(props: ParentProps) { const next = new Set(dirs) for (const directory of next) { if (loadedSessionDirs.has(directory)) continue - globalSync.project.loadSessions(directory) + void globalSync.project.loadSessions(directory) } loadedSessionDirs.clear() @@ -2110,7 +2110,7 @@ export default function Layout(props: ParentProps) { onSave={(next) => { const item = project() if (!item) return - renameProject(item, next) + void renameProject(item, next) }} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" @@ -2242,7 +2242,7 @@ export default function Layout(props: ParentProps) { onClick={() => { const item = project() if (!item) return - createWorkspace(item) + void createWorkspace(item) }} > {language.t("workspace.new")} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 9e00691471..9d74651b94 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -277,7 +277,7 @@ const WorkspaceSessionList = (props: { class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10" size="large" onClick={(e: MouseEvent) => { - props.loadMore() + void props.loadMore() ;(e.currentTarget as HTMLButtonElement).blur() }} > diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 32df997f7f..c63bbc4f93 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -484,7 +484,7 @@ export default function Page() { if (!tab) return const path = file.pathFromTab(tab) - if (path) file.load(path) + if (path) void file.load(path) }) createEffect( diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index f3215f6850..e136ba9991 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -117,7 +117,7 @@ export const createOpenReviewFile = (input: { input.openTab(tab) input.setActive(tab) } - if (maybePromise instanceof Promise) maybePromise.then(open) + if (maybePromise instanceof Promise) void maybePromise.then(open) else open() }) } diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts index 9fd3ba0f0f..1cf64d6e89 100755 --- a/packages/console/app/script/generate-sitemap.ts +++ b/packages/console/app/script/generate-sitemap.ts @@ -105,4 +105,4 @@ async function main() { console.log(`✓ Sitemap generated at ${outputPath}`) } -main() +void main() diff --git a/packages/console/app/src/component/spotlight.tsx b/packages/console/app/src/component/spotlight.tsx index 7043069905..19accb88a6 100644 --- a/packages/console/app/src/component/spotlight.tsx +++ b/packages/console/app/src/component/spotlight.tsx @@ -766,7 +766,7 @@ export default function Spotlight(props: SpotlightProps) { } } - initializeWebGPU() + void initializeWebGPU() onCleanup(() => { if (cleanupFunctionRef) { diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx index 19b56eabe6..52e6408761 100644 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -298,7 +298,7 @@ export default function BlackSubscribe() { // Resolve stripe promise once createEffect(() => { - stripePromise.then((s) => { + void stripePromise.then((s) => { if (s) setStripe(s) }) }) diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index 0278d8622b..b5c202a5ec 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -77,7 +77,7 @@ export default function Download() { const handleCopyClick = (command: string) => (event: Event) => { const button = event.currentTarget as HTMLButtonElement - navigator.clipboard.writeText(command) + void navigator.clipboard.writeText(command) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index b5b12a84bd..ee40ded87b 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -35,7 +35,7 @@ export default function Home() { const button = event.currentTarget as HTMLButtonElement const text = button.textContent if (text) { - navigator.clipboard.writeText(text) + void navigator.clipboard.writeText(text) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/temp.tsx b/packages/console/app/src/routes/temp.tsx index 4eed47857a..6bbabc9ea1 100644 --- a/packages/console/app/src/routes/temp.tsx +++ b/packages/console/app/src/routes/temp.tsx @@ -27,7 +27,7 @@ export default function Home() { const callback = () => { const text = button.textContent if (text) { - navigator.clipboard.writeText(text) + void navigator.clipboard.writeText(text) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/zen/util/dataDumper.ts b/packages/console/app/src/routes/zen/util/dataDumper.ts index b852ca0b5c..bc88c3813d 100644 --- a/packages/console/app/src/routes/zen/util/dataDumper.ts +++ b/packages/console/app/src/routes/zen/util/dataDumper.ts @@ -26,14 +26,14 @@ export function createDataDumper(sessionId: string, requestId: string, projectId const minute = timestamp.substring(10, 12) const second = timestamp.substring(12, 14) - waitUntil( + void waitUntil( Resource.ZenDataNew.put( `data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`, JSON.stringify({ timestamp, ...data }), ), ) - waitUntil( + void waitUntil( Resource.ZenDataNew.put( `meta/${data.modelName}/${sessionId}/${requestId}.json`, JSON.stringify({ timestamp, ...metadata }), diff --git a/packages/desktop/src/entry.tsx b/packages/desktop/src/entry.tsx index b1c9f13f9c..0e43d85fae 100644 --- a/packages/desktop/src/entry.tsx +++ b/packages/desktop/src/entry.tsx @@ -1,5 +1,5 @@ if (location.pathname === "/loading") { - import("./loading") + void import("./loading") } else { - import("./") + void import("./") } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5fe88d501b..d6a0ad74f8 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -410,7 +410,7 @@ const createPlatform = (): Platform => { } let menuTrigger = null as null | ((id: string) => void) -createMenu((id) => { +void createMenu((id) => { menuTrigger?.(id) }) void listenForDeepLinks() diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index a02f1a95e5..bcea016be4 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -48,7 +48,7 @@ render(() => { }) onCleanup(() => { - listener.then((cb) => cb()) + void listener.then((cb) => cb()) timers.forEach(clearTimeout) }) }) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 9005dd702f..837c8c017f 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -186,5 +186,5 @@ export async function createMenu(trigger: (id: string) => void) { }), ], }) - menu.setAsAppMenu() + void menu.setAsAppMenu() } diff --git a/packages/desktop/src/webview-zoom.ts b/packages/desktop/src/webview-zoom.ts index 06f46a3afd..46de208b0e 100644 --- a/packages/desktop/src/webview-zoom.ts +++ b/packages/desktop/src/webview-zoom.ts @@ -17,7 +17,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z const applyZoom = (next: number) => { setWebviewZoom(next) - invoke("plugin:webview|set_webview_zoom", { + void invoke("plugin:webview|set_webview_zoom", { value: next, }) } diff --git a/packages/enterprise/test-debug.ts b/packages/enterprise/test-debug.ts index a2ec4d8cdf..28558dec19 100644 --- a/packages/enterprise/test-debug.ts +++ b/packages/enterprise/test-debug.ts @@ -37,4 +37,4 @@ async function test() { await Share.remove({ id: shareInfo.id, secret: shareInfo.secret }) } -test() +void test() diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 7dcf3958a9..99f8bf4321 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -112,7 +112,7 @@ async function main() { } try { - main() + void main() } catch (error) { console.error("Postinstall script error:", error.message) process.exit(0) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 669462772d..57cce66680 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -242,7 +242,7 @@ export namespace ACP { const newContent = getNewContent(content, diff) if (newContent) { - this.connection.writeTextFile({ + void this.connection.writeTextFile({ sessionId: session.id, path: filepath, content: newContent, @@ -1253,7 +1253,7 @@ export namespace ACP { ) setTimeout(() => { - this.connection.sessionUpdate({ + void this.connection.sessionUpdate({ sessionId, update: { sessionUpdate: "available_commands_update", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 3d5350cb69..e7e9fd9cd2 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -350,7 +350,7 @@ function App(props: { onSnapshot?: () => Promise }) { if (match) { continued = true if (args.fork) { - sdk.client.session.fork({ sessionID: match }).then((result) => { + void sdk.client.session.fork({ sessionID: match }).then((result) => { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { @@ -370,7 +370,7 @@ function App(props: { onSnapshot?: () => Promise }) { createEffect(() => { if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return forked = true - sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { + void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) } else { @@ -818,7 +818,7 @@ function App(props: { onSnapshot?: () => Promise }) { `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, ) - exit() + void exit() }) const plugin = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index a42755bee7..f58b73c9a7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -145,7 +145,7 @@ export function DialogSessionList() { title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { - sdk.client.session.delete({ + void sdk.client.session.delete({ sessionID: option.value, }) setToDelete(undefined) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx index 141340d556..a079941c11 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx @@ -19,7 +19,7 @@ export function DialogSessionRename(props: DialogSessionRenameProps) { title="Rename Session" value={session()?.title} onConfirm={(value) => { - sdk.client.session.update({ + void sdk.client.session.update({ sessionID: props.session, title: value, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index b22163902e..e8758b3d7f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -26,7 +26,7 @@ export function ErrorComponent(props: { useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { - handleExit() + void handleExit() } }) const [copied, setCopied] = createSignal(false) @@ -56,7 +56,7 @@ export function ErrorComponent(props: { issueURL.searchParams.set("opencode-version", Installation.VERSION) const copyIssueURL = () => { - Clipboard.copy(issueURL.toString()).then(() => { + void Clipboard.copy(issueURL.toString()).then(() => { setCopied(true) }) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c361e48c9e..b80c32243f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -235,7 +235,7 @@ export function Prompt(props: PromptProps) { hidden: true, onSelect: (dialog) => { if (!input.focused) return - submit() + void submit() dialog.clear() }, }, @@ -280,7 +280,7 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - sdk.client.session.abort({ + void sdk.client.session.abort({ sessionID: props.sessionID, }) setStore("interrupt", 0) @@ -429,7 +429,7 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPartIndex", new Map()) }, submit() { - submit() + void submit() }, } @@ -604,12 +604,12 @@ export function Prompt(props: PromptProps) { if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { - exit() + void exit() return } const selectedModel = local.model.current() if (!selectedModel) { - promptModelWarning() + void promptModelWarning() return } @@ -660,7 +660,7 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() if (store.mode === "shell") { - sdk.client.session.shell({ + void sdk.client.session.shell({ sessionID, agent: local.agent.current().name, model: { @@ -685,7 +685,7 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - sdk.client.session.command({ + void sdk.client.session.command({ sessionID, command: command.slice(1), arguments: args, @@ -1208,7 +1208,7 @@ export function Prompt(props: PromptProps) { const r = retry() if (!r) return if (isTruncated()) { - DialogAlert.show(dialog, "Retry Error", r.message) + void DialogAlert.show(dialog, "Retry Error", r.message) } } diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index dc0b96c62a..39e976b0e5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -44,7 +44,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ }, set(key: string, value: any) { setStore(key, value) - Filesystem.writeJson(filePath, store) + void Filesystem.writeJson(filePath, store) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 612f2b7177..4c298ec113 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -131,7 +131,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return } state.pending = false - Filesystem.writeJson(filePath, { + void Filesystem.writeJson(filePath, { recent: modelStore.recent, favorite: modelStore.favorite, variant: modelStore.variant, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a0a59199bb..2558f9751f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -111,7 +111,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": - bootstrap() + void bootstrap() break case "permission.replied": { const requests = store.permission[event.properties.sessionID] @@ -336,7 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "lsp.updated": { const workspace = project.workspace.current() - sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? [])) + void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? [])) break } @@ -415,7 +415,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then(() => { if (store.status !== "complete") setStore("status", "partial") // non-blocking - Promise.all([ + void Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 179dc93700..679be8f254 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -329,7 +329,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) function init() { - Promise.allSettled([ + void Promise.allSettled([ resolveSystemTheme(store.mode), getCustomThemes() .then((custom) => { @@ -377,7 +377,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.mode === mode) return setStore("mode", mode) renderer.clearPaletteCache() - resolveSystemTheme(mode) + void resolveSystemTheme(mode) } function pin(mode: "dark" | "light" = store.mode) { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index f391eb24a7..b5edabcf0e 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -78,7 +78,7 @@ function Install(props: { api: TuiPluginApi }) { } setBusy(true) - props.api.plugins + void props.api.plugins .install(mod, { global: global() }) .then((out) => { if (!out.ok) { @@ -188,7 +188,7 @@ function View(props: { api: TuiPluginApi }) { if (!item) return setLock(true) const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x) - task + void task .then((ok) => { if (!ok) { props.api.ui.toast({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index a51a6cfe58..835ac8f5d5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -29,7 +29,7 @@ export function DialogMessage(props: { const msg = message() if (!msg) return - sdk.client.session.revert({ + void sdk.client.session.revert({ sessionID: props.sessionID, messageID: msg.id, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 58b5d6626c..2ea936c898 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -241,7 +241,7 @@ export function Session() { if (kv.get(GO_UPSELL_DONT_SHOW)) return - DialogGoUpsell.show(dialog).then((dontShowAgain) => { + void DialogGoUpsell.show(dialog).then((dontShowAgain) => { if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) }) @@ -272,7 +272,7 @@ export function Session() { useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { - exit() + void exit() } }) @@ -483,7 +483,7 @@ export function Session() { }) return } - sdk.client.session.summarize({ + void sdk.client.session.summarize({ sessionID: route.sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, @@ -529,7 +529,7 @@ export function Session() { const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return - sdk.client.session + void sdk.client.session .revert({ sessionID: route.sessionID, messageID: message.id, @@ -568,13 +568,13 @@ export function Session() { if (!messageID) return const message = messages().find((x) => x.role === "user" && x.id > messageID) if (!message) { - sdk.client.session.unrevert({ + void sdk.client.session.unrevert({ sessionID: route.sessionID, }) prompt?.set({ input: "", parts: [] }) return } - sdk.client.session.revert({ + void sdk.client.session.revert({ sessionID: route.sessionID, messageID: message.id, }) @@ -1966,7 +1966,7 @@ function Task(props: ToolProps) { onMount(() => { if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length) - sync.session.sync(props.metadata.sessionId) + void sync.session.sync(props.metadata.sessionId) }) const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 3554ab44ca..54cc86a40d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -184,7 +184,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - sdk.client.permission.reply({ + void sdk.client.permission.reply({ reply: "always", requestID: props.request.id, }) @@ -194,7 +194,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { { - sdk.client.permission.reply({ + void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, message: message || undefined, @@ -447,13 +447,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { setStore("stage", "reject") return } - sdk.client.permission.reply({ + void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, }) return } - sdk.client.permission.reply({ + void sdk.client.permission.reply({ reply: "once", requestID: props.request.id, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 65989b9f35..3ff95b4bb8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -45,14 +45,14 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - sdk.client.question.reply({ + void sdk.client.question.reply({ requestID: props.request.id, answers, }) } function reject() { - sdk.client.question.reject({ + void sdk.client.question.reject({ requestID: props.request.id, }) } @@ -67,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("custom", inputs) } if (single()) { - sdk.client.question.reply({ + void sdk.client.question.reply({ requestID: props.request.id, answers: [[answer]], }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ee1c755ebc..3da2dd6bdb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -171,7 +171,7 @@ async function loadCommand(dir: string) { ? err.data.message : `Failed to parse command ${item}` const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) return undefined }) @@ -210,7 +210,7 @@ async function loadAgent(dir: string) { ? err.data.message : `Failed to parse agent ${item}` const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) return undefined }) @@ -248,7 +248,7 @@ async function loadMode(dir: string) { ? err.data.message : `Failed to parse mode ${item}` const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) return undefined }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index a0d4c16803..dfd018db7e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -114,7 +114,7 @@ export namespace Workspace { await adaptor.create(config) - startSync(info) + void startSync(info) await waitEvent({ timeout: TIMEOUT, @@ -294,7 +294,7 @@ export namespace Workspace { ) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - for (const space of spaces) startSync(space) + for (const space of spaces) void startSync(space) return spaces } @@ -307,7 +307,7 @@ export namespace Workspace { export const get = fn(WorkspaceID.zod, async (id) => { const space = lookup(id) if (!space) return - startSync(space) + void startSync(space) return space }) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index f11cf88a65..3e3da444a5 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -98,9 +98,9 @@ export namespace FileWatcher { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { if (err) return for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) } }) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 50051b3901..27301e79a7 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -59,7 +59,7 @@ export namespace LSPClient { const exists = diagnostics.has(filePath) diagnostics.set(filePath, params.diagnostics) if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + void Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) connection.onRequest("window/workDoneProgress/create", (params) => { l.info("window/workDoneProgress/create", params) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index a55ac18402..5146c40abe 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -293,7 +293,7 @@ export namespace LSP { const task = schedule(server, root, root + server.id) s.spawning.set(root + server.id, task) - task.finally(() => { + void task.finally(() => { if (s.spawning.get(root + server.id) === task) { s.spawning.delete(root + server.id) } @@ -303,7 +303,7 @@ export namespace LSP { if (!client) continue result.push(client) - Bus.publish(Event.Updated, {}) + void Bus.publish(Event.Updated, {}) } return result diff --git a/packages/opencode/src/plugin/plugin.ts b/packages/opencode/src/plugin/plugin.ts index ec1cf1e313..d1fc60d993 100644 --- a/packages/opencode/src/plugin/plugin.ts +++ b/packages/opencode/src/plugin/plugin.ts @@ -245,7 +245,7 @@ export const layer = Layer.effect( Stream.runForEach((input) => Effect.sync(() => { for (const hook of hooks) { - hook["event"]?.({ event: input as any }) + void hook["event"]?.({ event: input as any }) } }), ), diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 59d629a379..245730e00f 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -172,7 +172,7 @@ export namespace ModelsDev { } if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - ModelsDev.refresh() + void ModelsDev.refresh() setInterval( async () => { await ModelsDev.refresh() diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 1b2755fb8a..06495b628c 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -898,7 +898,7 @@ export const SessionRoutes = lazy(() => const msg = await AppRuntime.runPromise( SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), ) - stream.write(JSON.stringify(msg)) + void stream.write(JSON.stringify(msg)) }) }, ) @@ -926,13 +926,15 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) + void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch( + (err) => { + log.error("prompt_async failed", { sessionID, error: err }) + void Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }, + ) return c.body(null, 204) }, diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 07edcc2bb2..5e36f2cff9 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -76,7 +76,7 @@ const app = (upgrade: UpgradeWebSocket) => queue.length = 0 } remote.onmessage = (event) => { - send(ws, event.data) + void send(ws, event.data) } remote.onerror = () => { ws.close(1011, "proxy error") diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index ee53182f36..7acd458dcd 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -134,7 +134,7 @@ export namespace Database { if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) - for (const effect of effects) effect() + for (const effect of effects) void effect() return result } throw err @@ -146,7 +146,7 @@ export namespace Database { try { ctx.use().effects.push(bound) } catch { - bound() + void bound() } } @@ -165,7 +165,7 @@ export namespace Database { const effects: (() => void | Promise)[] = [] const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) const result = Client().transaction(txCallback, { behavior: options?.behavior }) - for (const effect of effects) effect() + for (const effect of effects) void effect() return result as NotPromise } throw err diff --git a/packages/opencode/src/sync/sync-event.ts b/packages/opencode/src/sync/sync-event.ts index bee7e3c4cf..d4ad860409 100644 --- a/packages/opencode/src/sync/sync-event.ts +++ b/packages/opencode/src/sync/sync-event.ts @@ -142,11 +142,11 @@ function process(def: Def, event: Event, options: { if (options?.publish) { const result = convertEvent(def.type, event.data) if (result instanceof Promise) { - result.then((data) => { - ProjectBus.publish({ type: def.type, properties: def.schema }, data) + void result.then((data) => { + void ProjectBus.publish({ type: def.type, properties: def.schema }, data) }) } else { - ProjectBus.publish({ type: def.type, properties: def.schema }, result) + void ProjectBus.publish({ type: def.type, properties: def.schema }, result) } GlobalBus.emit("event", { diff --git a/packages/opencode/src/util/defer.ts b/packages/opencode/src/util/defer.ts index 8de21528cc..d1c9edc66a 100644 --- a/packages/opencode/src/util/defer.ts +++ b/packages/opencode/src/util/defer.ts @@ -3,7 +3,7 @@ export function defer void | Promise>( ): T extends () => Promise ? { [Symbol.asyncDispose]: () => Promise } : { [Symbol.dispose]: () => void } { return { [Symbol.dispose]() { - fn() + void fn() }, [Symbol.asyncDispose]() { return Promise.resolve(fn()) diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6be9816a87..7c1581bfc0 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -59,7 +59,7 @@ let write = (msg: any) => { export async function init(options: Options) { if (options.level) level = options.level - cleanup(Global.Path.log) + void cleanup(Global.Path.log) if (options.print) return logpath = path.join( Global.Path.log, diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index b22180ef31..078e4484db 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -209,7 +209,7 @@ test( const done = await new Promise((resolve) => { const timer = setTimeout(() => resolve("timeout"), 7000) - TuiPluginRuntime.dispose().then(() => { + void TuiPluginRuntime.dispose().then(() => { clearTimeout(timer) resolve("done") }) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 14c08e3036..175717d056 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -10,7 +10,7 @@ const transportCalls: Array<{ }> = [] // Mock the transport constructors to capture their arguments -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: class MockStreamableHTTP { constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { transportCalls.push({ @@ -25,7 +25,7 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ }, })) -mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: class MockSSE { constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { transportCalls.push({ diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index add7c66d94..31712f1561 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -89,19 +89,19 @@ class MockSSE { } } -mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: MockStdioTransport, })) -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: MockStreamableHTTP, })) -mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: MockSSE, })) -mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ UnauthorizedError: class extends Error { constructor() { super("Unauthorized") @@ -110,7 +110,7 @@ mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ })) // Mock Client that delegates to per-name MockClientState -mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { _state!: MockClientState transport: any diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 89edd09084..8b29f6d1e3 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -22,7 +22,7 @@ let simulateAuthFlow = true let connectSucceedsImmediately = false // Mock the transport constructors to simulate OAuth auto-auth on 401 -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: class MockStreamableHTTP { authProvider: | { @@ -66,7 +66,7 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ }, })) -mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: class MockSSE { constructor(url: URL, options?: { authProvider?: unknown }) { transportCalls.push({ @@ -82,7 +82,7 @@ mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ })) // Mock the MCP SDK Client -mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { async connect(transport: { start: () => Promise }) { await transport.start() @@ -99,7 +99,7 @@ mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ })) // Mock UnauthorizedError in the auth module so instanceof checks work -mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ UnauthorizedError: MockUnauthorizedError, })) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index b6a32b1e1b..3a6df02a15 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -7,7 +7,7 @@ import type { MCP as MCPNS } from "../../src/mcp/index" let openShouldFail = false let openCalledWith: string | undefined -mock.module("open", () => ({ +void mock.module("open", () => ({ default: async (url: string) => { openCalledWith = url @@ -39,7 +39,7 @@ const transportCalls: Array<{ }> = [] // Mock the transport constructors -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: class MockStreamableHTTP { url: string authProvider: { redirectToAuthorization?: (url: URL) => Promise } | undefined @@ -65,7 +65,7 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ }, })) -mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: class MockSSE { constructor(url: URL) { transportCalls.push({ @@ -81,7 +81,7 @@ mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ })) // Mock the MCP SDK Client to trigger OAuth flow -mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { async connect(transport: { start: () => Promise }) { await transport.start() @@ -90,7 +90,7 @@ mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ })) // Mock UnauthorizedError in the auth module -mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ +void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ UnauthorizedError: MockUnauthorizedError, })) diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts index 1286d5f0b3..c3197f8dd5 100644 --- a/packages/opencode/test/memory/abort-leak-webfetch.ts +++ b/packages/opencode/test/memory/abort-leak-webfetch.ts @@ -44,6 +44,6 @@ try { const after = heap() process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline })) } finally { - server.stop(true) + void server.stop(true) process.exit(0) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 805c230f3e..d654d4b876 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -954,7 +954,7 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })) + yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index ba5df4f1ea..7c6f04c796 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -81,7 +81,7 @@ process.env["OPENCODE_DB"] = ":memory:" const { Log } = await import("../src/util") const { initProjectors } = await import("../src/server/projectors") -Log.init({ +void Log.init({ print: false, dev: true, level: "DEBUG", diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index d645fb25b8..c399d8872d 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -10,7 +10,7 @@ import { $ } from "bun" import { tmpdir } from "../fixture/fixture" import { Effect } from "effect" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fn: (svc: Project.Interface) => Effect.Effect) { return Effect.runPromise( diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a579a2335d..4c272b7949 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -12,7 +12,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -Log.init({ print: false }) +void Log.init({ print: false }) const encoder = new TextEncoder() diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index c029fd9336..0edabd8e65 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -7,7 +7,7 @@ import { Session as SessionNs } from "../../src/session" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index c3ee18e73a..a29b4ebb35 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -10,7 +10,7 @@ import { Log } from "../../src/util" import { resetDatabase } from "../fixture/db" import { provideInstance, tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) afterEach(async () => { await resetDatabase() diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 3209ebff35..4be2344aab 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -7,7 +7,7 @@ import type { SessionID } from "../../src/session/schema" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 9af60b9bdd..602d0f2049 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -5,7 +5,7 @@ import { Session as SessionNs } from "../../src/session" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index d558d4324f..50b7658969 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -8,7 +8,7 @@ import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index c53448dfd4..21e07f88a0 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -7,7 +7,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index ee01932210..ee3f645c52 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -27,7 +27,7 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index d1d53f605b..f26bef6052 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -229,7 +229,7 @@ beforeEach(() => { }) afterAll(() => { - state.server?.stop() + void state.server?.stop() }) function createChatStream(text: string) { diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 804076dd48..40ccacc584 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -9,7 +9,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Log } from "../../src/util" const root = path.join(__dirname, "../..") -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 87ff40c707..74ce913077 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -24,7 +24,7 @@ import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" -Log.init({ print: false }) +void Log.init({ print: false }) const summary = Layer.succeed( SessionSummary.Service, diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 0a750352a7..6819da4817 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -44,7 +44,7 @@ import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" -Log.init({ print: false }) +void Log.init({ print: false }) const summary = Layer.succeed( SessionSummary.Service, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index acf305f3f9..2b489da9e9 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -11,7 +11,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" -Log.init({ print: false }) +void Log.init({ print: false }) function run(fx: Effect.Effect) { return Effect.runPromise( @@ -316,7 +316,7 @@ describe("session.prompt regression", () => { ), }) } finally { - server.stop(true) + void server.stop(true) } }) @@ -409,7 +409,7 @@ describe("session.prompt regression", () => { ), }) } finally { - server.stop(true) + void server.stop(true) } }) }) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 211fcde9a8..f28fb94c0b 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -13,7 +13,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -Log.init({ print: false }) +void Log.init({ print: false }) const env = Layer.mergeAll( Session.defaultLayer, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 9c4686cba6..f63ad9beed 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -10,7 +10,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") -Log.init({ print: false }) +void Log.init({ print: false }) function create(input?: SessionNs.CreateInput) { return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index cb7fe4568e..38aed43765 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -57,7 +57,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" -Log.init({ print: false }) +void Log.init({ print: false }) const mcp = Layer.succeed( MCP.Service, diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 346705bf22..fb8d42f077 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -8,7 +8,7 @@ import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") -Log.init({ print: false }) +void Log.init({ print: false }) // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 175500862d..3f82103293 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -42,7 +42,7 @@ beforeAll(async () => { }) afterAll(async () => { - server?.stop() + void server?.stop() await rm(cacheDir, { recursive: true, force: true }) }) diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts index 8f7fac549d..ffc4f16dc1 100644 --- a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -111,7 +111,7 @@ export const createSseClient = ({ const abortHandler = () => { try { - reader.cancel() + void reader.cancel() } catch { // noop } diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts index 056a812593..eecc3c37a0 100644 --- a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -138,7 +138,7 @@ export const createSseClient = ({ const abortHandler = () => { try { - reader.cancel() + void reader.cancel() } catch { // noop } diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 85d6851296..bd5523a2a0 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -20,7 +20,7 @@ const opencode = await createOpencode({ console.log("✅ Opencode server ready") const sessions = new Map() -;(async () => { +void (async () => { const events = await opencode.client.event.subscribe() for await (const event of events.stream) { if (event.type === "message.part.updated") { @@ -29,7 +29,7 @@ const sessions = new Map { + void heightAnim.finished.then(() => { if (!contentRef || !open()) return contentRef.style.overflow = "visible" contentRef.style.height = "auto" diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index b5879624e0..cc5fc0ce5d 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -107,7 +107,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) // Force a refetch even if the value is unchanged. // This is important for programmatic changes like Tab completion. if (prev === value) { - refetch() + void refetch() return } queueMicrotask(() => refetch()) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 81e6a52a26..a47ff18045 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1158,7 +1158,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp onMouseDown={(e) => e.preventDefault()} onClick={(event) => { event.stopPropagation() - handleCopy() + void handleCopy() }} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} /> diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index d10f5d6ace..93b2663bad 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -6,7 +6,8 @@ import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" export interface TextFieldProps - extends ComponentProps, + extends + ComponentProps, Partial< Pick< ComponentProps, @@ -75,7 +76,7 @@ export function TextField(props: TextFieldProps) { } function handleClick() { - if (local.copyable) handleCopy() + if (local.copyable) void handleCopy() } return ( diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index 02bf8084ce..2d2a94e6a3 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -102,7 +102,7 @@ export function TextReveal(props: { requestAnimationFrame(() => setState("ready", true)) return } - fonts.ready.finally(() => { + void fonts.ready.finally(() => { widen(win()) requestAnimationFrame(() => setState("ready", true)) }) diff --git a/packages/ui/src/components/thinking-heading.stories.tsx b/packages/ui/src/components/thinking-heading.stories.tsx index 3a65619ce1..12a06b4d83 100644 --- a/packages/ui/src/components/thinking-heading.stories.tsx +++ b/packages/ui/src/components/thinking-heading.stories.tsx @@ -442,7 +442,7 @@ function AnimatedHeading(props) { onMount(() => { measure() - document.fonts?.ready.finally(() => { + void document.fonts?.ready.finally(() => { measure() requestAnimationFrame(() => setState("ready", true)) }) diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 038870d384..9983e2fe79 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -128,7 +128,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.stopPropagation() - copy() + void copy() }} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")} /> diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 2a58e0e5bb..412d92e3db 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -86,7 +86,7 @@ export function ToolStatusTitle(props: { finish() return } - fonts.ready.finally(() => { + void fonts.ready.finally(() => { measure() finish() }) diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index 1993ad7aa6..d25dee4d9d 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -25,7 +25,7 @@ function createPool(lineDiffType: "none" | "word-alt") { }, ) - pool.initialize() + void pool.initialize() return pool } diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 335084bd64..1e38adede2 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -36,10 +36,10 @@ function providerIconsPlugin() { return { name: "provider-icons-plugin", configureServer() { - fetchProviderIcons() + void fetchProviderIcons() }, buildStart() { - fetchProviderIcons() + void fetchProviderIcons() }, } } diff --git a/script/duplicate-pr.ts b/script/duplicate-pr.ts index b77737c1d4..2ef16cb653 100755 --- a/script/duplicate-pr.ts +++ b/script/duplicate-pr.ts @@ -76,4 +76,4 @@ Examples: } } -main() +void main() From 0beaf04df5d04e09edf58471244243098e34c324 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 03:28:30 +0000 Subject: [PATCH 49/75] chore: generate --- packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts | 2 +- packages/ui/src/components/text-field.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts index eecc3c37a0..056a812593 100644 --- a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -138,7 +138,7 @@ export const createSseClient = ({ const abortHandler = () => { try { - void reader.cancel() + reader.cancel() } catch { // noop } diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index 93b2663bad..82be20f9ea 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -6,8 +6,7 @@ import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" export interface TextFieldProps - extends - ComponentProps, + extends ComponentProps, Partial< Pick< ComponentProps, From a427a28fa9750e5a9bcae3e72cfa582b071c640b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:28:46 -0400 Subject: [PATCH 50/75] feat: unwrap project namespaces to flat exports + barrel (#22743) --- packages/opencode/src/cli/cmd/debug/scrap.ts | 2 +- packages/opencode/src/cli/cmd/stats.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 4 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 4 +- packages/opencode/src/project/index.ts | 2 + packages/opencode/src/project/instance.ts | 2 +- packages/opencode/src/project/project.ts | 842 +++++++++--------- packages/opencode/src/project/vcs.ts | 434 +++++---- .../src/server/instance/experimental.ts | 2 +- .../opencode/src/server/instance/index.ts | 2 +- .../opencode/src/server/instance/project.ts | 2 +- packages/opencode/src/worktree/worktree.ts | 2 +- .../test/project/migrate-global.test.ts | 2 +- .../opencode/test/project/project.test.ts | 2 +- packages/opencode/test/project/vcs.test.ts | 2 +- .../test/server/global-session-list.test.ts | 2 +- 18 files changed, 655 insertions(+), 657 deletions(-) create mode 100644 packages/opencode/src/project/index.ts diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index 464b165d72..300a7b9656 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import { Project } from "../../../project/project" +import { Project } from "../../../project" import { Log } from "../../../util" import { cmd } from "../cmd" diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 527a6ac952..d66ac252fa 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -4,7 +4,7 @@ import { Session } from "../../session" import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" import { SessionTable } from "../../session/session.sql" -import { Project } from "../../project/project" +import { Project } from "../../project" import { Instance } from "../../project/instance" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index dfd018db7e..f38b27e6f8 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -2,7 +2,7 @@ import z from "zod" import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" import { Database, asc, eq, inArray } from "@/storage/db" -import { Project } from "@/project/project" +import { Project } from "@/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { SyncEvent } from "@/sync" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f9f811e711..7608e9c701 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -40,8 +40,8 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { Project } from "@/project/project" -import { Vcs } from "@/project/vcs" +import { Project } from "@/project" +import { Vcs } from "@/project" import { Worktree } from "@/worktree" import { Pty } from "@/pty" import { Installation } from "@/installation" diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index d8400c52ae..7d34b4bd48 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -7,7 +7,7 @@ import { FileWatcher } from "@/file/watcher" import { Format } from "@/format" import { ShareNext } from "@/share/share-next" import { File } from "@/file" -import { Vcs } from "@/project/vcs" +import { Vcs } from "@/project" import { Snapshot } from "@/snapshot" import { Bus } from "@/bus" import { Observability } from "./observability" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index f00d8ffd9b..c88eb8e039 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -3,8 +3,8 @@ import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" import { Snapshot } from "../snapshot" -import { Project } from "./project" -import { Vcs } from "./vcs" +import { Project } from "." +import { Vcs } from "." import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" diff --git a/packages/opencode/src/project/index.ts b/packages/opencode/src/project/index.ts new file mode 100644 index 0000000000..d9f168f6ff --- /dev/null +++ b/packages/opencode/src/project/index.ts @@ -0,0 +1,2 @@ +export * as Vcs from "./vcs" +export * as Project from "./project" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index a8a5218751..b95962ae08 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util" import { LocalContext } from "../util" -import { Project } from "./project" +import { Project } from "." import { WorkspaceContext } from "@/control-plane/workspace-context" export interface InstanceContext { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9c4ed58ce8..99fe88ff16 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -14,474 +14,472 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -export namespace Project { - const log = Log.create({ service: "project" }) +const log = Log.create({ service: "project" }) - export const Info = z - .object({ - id: ProjectID.zod, - worktree: z.string(), - vcs: z.literal("git").optional(), - name: z.string().optional(), - icon: z - .object({ - url: z.string().optional(), - override: z.string().optional(), - color: z.string().optional(), - }) - .optional(), - commands: z - .object({ - start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), - }) - .optional(), - time: z.object({ - created: z.number(), - updated: z.number(), - initialized: z.number().optional(), - }), - sandboxes: z.array(z.string()), - }) - .meta({ - ref: "Project", - }) - export type Info = z.infer - - export const Event = { - Updated: BusEvent.define("project.updated", Info), - } - - type Row = typeof ProjectTable.$inferSelect - - export function fromRow(row: Row): Info { - const icon = - row.icon_url || row.icon_color - ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } - : undefined - return { - id: row.id, - worktree: row.worktree, - vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, - name: row.name ?? undefined, - icon, - time: { - created: row.time_created, - updated: row.time_updated, - initialized: row.time_initialized ?? undefined, - }, - sandboxes: row.sandboxes, - commands: row.commands ?? undefined, - } - } - - export const UpdateInput = z.object({ - projectID: ProjectID.zod, +export const Info = z + .object({ + id: ProjectID.zod, + worktree: z.string(), + vcs: z.literal("git").optional(), name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), + icon: z + .object({ + url: z.string().optional(), + override: z.string().optional(), + color: z.string().optional(), + }) + .optional(), + commands: z + .object({ + start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), + }) + .optional(), + time: z.object({ + created: z.number(), + updated: z.number(), + initialized: z.number().optional(), + }), + sandboxes: z.array(z.string()), }) - export type UpdateInput = z.infer + .meta({ + ref: "Project", + }) +export type Info = z.infer - // --------------------------------------------------------------------------- - // Effect service - // --------------------------------------------------------------------------- +export const Event = { + Updated: BusEvent.define("project.updated", Info), +} - export interface Interface { - readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> - readonly discover: (input: Info) => Effect.Effect - readonly list: () => Effect.Effect - readonly get: (id: ProjectID) => Effect.Effect - readonly update: (input: UpdateInput) => Effect.Effect - readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect - readonly setInitialized: (id: ProjectID) => Effect.Effect - readonly sandboxes: (id: ProjectID) => Effect.Effect - readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect - readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect +type Row = typeof ProjectTable.$inferSelect + +export function fromRow(row: Row): Info { + const icon = + row.icon_url || row.icon_color + ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } + : undefined + return { + id: row.id, + worktree: row.worktree, + vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, + name: row.name ?? undefined, + icon, + time: { + created: row.time_created, + updated: row.time_updated, + initialized: row.time_initialized ?? undefined, + }, + sandboxes: row.sandboxes, + commands: row.commands ?? undefined, } +} - export class Service extends Context.Service()("@opencode/Project") {} +export const UpdateInput = z.object({ + projectID: ProjectID.zod, + name: z.string().optional(), + icon: Info.shape.icon.optional(), + commands: Info.shape.commands.optional(), +}) +export type UpdateInput = z.infer - type GitResult = { code: number; text: string; stderr: string } +// --------------------------------------------------------------------------- +// Effect service +// --------------------------------------------------------------------------- - export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner - > = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const pathSvc = yield* Path.Path - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner +export interface Interface { + readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> + readonly discover: (input: Info) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: ProjectID) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect + readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect + readonly setInitialized: (id: ProjectID) => Effect.Effect + readonly sandboxes: (id: ProjectID) => Effect.Effect + readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect +} - const git = Effect.fnUntraced( - function* (args: string[], opts?: { cwd?: string }) { - const handle = yield* spawner.spawn( - ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), - ) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), +export class Service extends Context.Service()("@opencode/Project") {} + +type GitResult = { code: number; text: string; stderr: string } + +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const pathSvc = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), + ) + + const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + project: data.id, + payload: { type: Event.Updated.type, properties: data }, + }), ) - const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) + const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) - const emitUpdated = (data: Info) => - Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - project: data.id, - payload: { type: Event.Updated.type, properties: data }, - }), - ) + const resolveGitPath = (cwd: string, name: string) => { + if (!name) return cwd + name = name.replace(/[\r\n]+$/, "") + if (!name) return cwd + name = AppFileSystem.windowsPath(name) + if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name) + return pathSvc.resolve(cwd, name) + } - const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) + const scope = yield* Scope.Scope - const resolveGitPath = (cwd: string, name: string) => { - if (!name) return cwd - name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd - name = AppFileSystem.windowsPath(name) - if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name) - return pathSvc.resolve(cwd, name) - } + const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { + return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( + Effect.map((x) => x.trim()), + Effect.map(ProjectID.make), + Effect.catch(() => Effect.void), + ) + }) - const scope = yield* Scope.Scope + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { + log.info("fromDirectory", { directory }) - const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { - return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( - Effect.map((x) => x.trim()), - Effect.map(ProjectID.make), - Effect.catch(() => Effect.void), - ) + // Phase 1: discover git info + type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } + + const data: DiscoveryResult = yield* Effect.gen(function* () { + const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgit = dotgitMatches[0] + + if (!dotgit) { + return { + id: ProjectID.global, + worktree: "/", + sandbox: "/", + vcs: fakeVcs, + } + } + + let sandbox = pathSvc.dirname(dotgit) + const gitBinary = yield* Effect.sync(() => which("git")) + let id = yield* readCachedProjectId(dotgit) + + if (!gitBinary) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + + const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox }) + if (commonDir.code !== 0) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + const worktree = (() => { + const common = resolveGitPath(sandbox, commonDir.text.trim()) + return common === sandbox ? sandbox : pathSvc.dirname(common) + })() + + if (id == null) { + id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + } + + if (!id) { + const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox }) + const roots = revList.text + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + + id = roots[0] ? ProjectID.make(roots[0]) : undefined + if (id) { + yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) + } + } + + if (!id) { + return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const } + } + + const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox }) + if (topLevel.code !== 0) { + return { + id, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + sandbox = resolveGitPath(sandbox, topLevel.text.trim()) + + return { id, sandbox, worktree, vcs: "git" as const } }) - const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { - log.info("fromDirectory", { directory }) - - // Phase 1: discover git info - type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } - - const data: DiscoveryResult = yield* Effect.gen(function* () { - const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) - const dotgit = dotgitMatches[0] - - if (!dotgit) { - return { - id: ProjectID.global, - worktree: "/", - sandbox: "/", - vcs: fakeVcs, - } + // Phase 2: upsert + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) + const existing = row + ? fromRow(row) + : { + id: data.id, + worktree: data.worktree, + vcs: data.vcs, + sandboxes: [] as string[], + time: { created: Date.now(), updated: Date.now() }, } - let sandbox = pathSvc.dirname(dotgit) - const gitBinary = yield* Effect.sync(() => which("git")) - let id = yield* readCachedProjectId(dotgit) + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) + yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - if (!gitBinary) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: fakeVcs, - } - } + const result: Info = { + ...existing, + worktree: data.worktree, + vcs: data.vcs, + time: { ...existing.time, updated: Date.now() }, + } + if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) + result.sandboxes.push(data.sandbox) + result.sandboxes = yield* Effect.forEach( + result.sandboxes, + (s) => + fs.exists(s).pipe( + Effect.orDie, + Effect.map((exists) => (exists ? s : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) - const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox }) - if (commonDir.code !== 0) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: fakeVcs, - } - } - const worktree = (() => { - const common = resolveGitPath(sandbox, commonDir.text.trim()) - return common === sandbox ? sandbox : pathSvc.dirname(common) - })() - - if (id == null) { - id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) - } - - if (!id) { - const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox }) - const roots = revList.text - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted() - - id = roots[0] ? ProjectID.make(roots[0]) : undefined - if (id) { - yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) - } - } - - if (!id) { - return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const } - } - - const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox }) - if (topLevel.code !== 0) { - return { - id, - worktree: sandbox, - sandbox, - vcs: fakeVcs, - } - } - sandbox = resolveGitPath(sandbox, topLevel.text.trim()) - - return { id, sandbox, worktree, vcs: "git" as const } - }) - - // Phase 2: upsert - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row - ? fromRow(row) - : { - id: data.id, - worktree: data.worktree, - vcs: data.vcs, - sandboxes: [] as string[], - time: { created: Date.now(), updated: Date.now() }, - } - - if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) - yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - - const result: Info = { - ...existing, - worktree: data.worktree, - vcs: data.vcs, - time: { ...existing.time, updated: Date.now() }, - } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = yield* Effect.forEach( - result.sandboxes, - (s) => - fs.exists(s).pipe( - Effect.orDie, - Effect.map((exists) => (exists ? s : undefined)), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) - - yield* db((d) => - d - .insert(ProjectTable) - .values({ - id: result.id, + yield* db((d) => + d + .insert(ProjectTable) + .values({ + id: result.id, + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: result.worktree, vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, icon_color: result.icon?.color, - time_created: result.time.created, time_updated: result.time.updated, time_initialized: result.time.initialized, sandboxes: result.sandboxes, commands: result.commands, - }) - .onConflictDoUpdate({ - target: ProjectTable.id, - set: { - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - }, - }) + }, + }) + .run(), + ) + + if (data.id !== ProjectID.global) { + yield* db((d) => + d + .update(SessionTable) + .set({ project_id: data.id }) + .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) .run(), ) + } - if (data.id !== ProjectID.global) { - yield* db((d) => - d - .update(SessionTable) - .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) - .run(), - ) - } + yield* emitUpdated(result) + return { project: result, sandbox: data.sandbox } + }) - yield* emitUpdated(result) - return { project: result, sandbox: data.sandbox } - }) + const discover = Effect.fn("Project.discover")(function* (input: Info) { + if (input.vcs !== "git") return + if (input.icon?.override) return + if (input.icon?.url) return - const discover = Effect.fn("Project.discover")(function* (input: Info) { - if (input.vcs !== "git") return - if (input.icon?.override) return - if (input.icon?.url) return + const matches = yield* fs + .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + cwd: input.worktree, + absolute: true, + include: "file", + }) + .pipe(Effect.orDie) + const shortest = matches.sort((a, b) => a.length - b.length)[0] + if (!shortest) return - const matches = yield* fs - .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { - cwd: input.worktree, - absolute: true, - include: "file", + const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie) + const base64 = Buffer.from(buffer).toString("base64") + const mime = AppFileSystem.mimeType(shortest) + const url = `data:${mime};base64,${base64}` + yield* update({ projectID: input.id, icon: { url } }) + }) + + const list = Effect.fn("Project.list")(function* () { + return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) + }) + + const get = Effect.fn("Project.get")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + return row ? fromRow(row) : undefined + }) + + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), }) - .pipe(Effect.orDie) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${input.projectID}`) + const data = fromRow(result) + yield* emitUpdated(data) + return data + }) - const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie) - const base64 = Buffer.from(buffer).toString("base64") - const mime = AppFileSystem.mimeType(shortest) - const url = `data:${mime};base64,${base64}` - yield* update({ projectID: input.id, icon: { url } }) - }) + const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") + const result = yield* git(["init", "--quiet"], { cwd: input.directory }) + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") + } + const { project } = yield* fromDirectory(input.directory) + return project + }) - const list = Effect.fn("Project.list")(function* () { - return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) - }) + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { + yield* db((d) => + d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), + ) + }) - const get = Effect.fn("Project.get")(function* (id: ProjectID) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - return row ? fromRow(row) : undefined - }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) return [] + const data = fromRow(row) + return yield* Effect.forEach( + data.sandboxes, + (dir) => + fs.isDir(dir).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? dir : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + }) - const update = Effect.fn("Project.update")(function* (input: UpdateInput) { - const result = yield* db((d) => - d - .update(ProjectTable) - .set({ - name: input.name, - icon_url: input.icon?.url, - icon_color: input.icon?.color, - commands: input.commands, - time_updated: Date.now(), - }) - .where(eq(ProjectTable.id, input.projectID)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) - const data = fromRow(result) - yield* emitUpdated(data) - return data - }) + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = [...row.sandboxes] + if (!sboxes.includes(directory)) sboxes.push(directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) - const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { - if (input.project.vcs === "git") return input.project - if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") - const result = yield* git(["init", "--quiet"], { cwd: input.directory }) - if (result.code !== 0) { - throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") - } - const { project } = yield* fromDirectory(input.directory) - return project - }) + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = row.sandboxes.filter((s) => s !== directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) - const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { - yield* db((d) => - d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), - ) - }) + return Service.of({ + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), +) - const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return [] - const data = fromRow(row) - return yield* Effect.forEach( - data.sandboxes, - (dir) => - fs.isDir(dir).pipe( - Effect.orDie, - Effect.map((ok) => (ok ? dir : undefined)), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) - }) +export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), +) - const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sboxes = [...row.sandboxes] - if (!sboxes.includes(directory)) sboxes.push(directory) - const result = yield* db((d) => - d - .update(ProjectTable) - .set({ sandboxes: sboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${id}`) - yield* emitUpdated(fromRow(result)) - }) - - const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sboxes = row.sandboxes.filter((s) => s !== directory) - const result = yield* db((d) => - d - .update(ProjectTable) - .set({ sandboxes: sboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${id}`) - yield* emitUpdated(fromRow(result)) - }) - - return Service.of({ - fromDirectory, - discover, - list, - get, - update, - initGit, - setInitialized, - sandboxes, - addSandbox, - removeSandbox, - }) - }), +export function list() { + return Database.use((db) => + db + .select() + .from(ProjectTable) + .all() + .map((row) => fromRow(row)), + ) +} + +export function get(id: ProjectID): Info | undefined { + const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) return undefined + return fromRow(row) +} + +export function setInitialized(id: ProjectID) { + Database.use((db) => + db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), ) - - export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodePath.layer), - ) - - export function list() { - return Database.use((db) => - db - .select() - .from(ProjectTable) - .all() - .map((row) => fromRow(row)), - ) - } - - export function get(id: ProjectID): Info | undefined { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return undefined - return fromRow(row) - } - - export function setInitialized(id: ProjectID) { - Database.use((db) => - db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), - ) - } } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index cb0b46adcb..559371859f 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -11,223 +11,221 @@ import { Log } from "@/util" import { Instance } from "./instance" import z from "zod" -export namespace Vcs { - const log = Log.create({ service: "vcs" }) +const log = Log.create({ service: "vcs" }) - const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length - } - - const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") - }) - - const nums = (list: Git.Stat[]) => - new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) - - const merge = (...lists: Git.Item[][]) => { - const out = new Map() - lists.flat().forEach((item) => { - if (!out.has(item.file)) out.set(item.file, item) - }) - return [...out.values()] - } - - const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string | undefined, - list: Git.Item[], - map: Map, - ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) - }) - - const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string | undefined, - ) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) - }) - - const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string, - ) { - const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { - concurrency: 3, - }) - return yield* files( - fs, - git, - cwd, - ref, - merge( - list, - extra.filter((item) => item.code === "??"), - ), - nums(stats), - ) - }) - - export const Mode = z.enum(["git", "branch"]) - export type Mode = z.infer - - export const Event = { - BranchUpdated: BusEvent.define( - "vcs.branch.updated", - z.object({ - branch: z.string().optional(), - }), - ), - } - - export const Info = z - .object({ - branch: z.string().optional(), - default_branch: z.string().optional(), - }) - .meta({ - ref: "VcsInfo", - }) - export type Info = z.infer - - export const FileDiff = z - .object({ - file: z.string(), - patch: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "VcsFileDiff", - }) - export type FileDiff = z.infer - - export interface Interface { - readonly init: () => Effect.Effect - readonly branch: () => Effect.Effect - readonly defaultBranch: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect - } - - interface State { - current: string | undefined - root: Git.Base | undefined - } - - export class Service extends Context.Service()("@opencode/Vcs") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const git = yield* Git.Service - const bus = yield* Bus.Service - - const state = yield* InstanceState.make( - Effect.fn("Vcs.state")(function* (ctx) { - if (ctx.project.vcs !== "git") { - return { current: undefined, root: undefined } - } - - const get = Effect.fnUntraced(function* () { - return yield* git.branch(ctx.directory) - }) - const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { - concurrency: 2, - }) - const value = { current, root } - log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - - yield* bus.subscribe(FileWatcher.Event.Updated).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) - - return value - }), - ) - - return Service.of({ - init: Effect.fn("Vcs.init")(function* () { - yield* InstanceState.get(state) - }), - branch: Effect.fn("Vcs.branch")(function* () { - return yield* InstanceState.use(state, (x) => x.current) - }), - defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { - return yield* InstanceState.use(state, (x) => x.root?.name) - }), - diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { - const value = yield* InstanceState.get(state) - if (Instance.project.vcs !== "git") return [] - if (mode === "git") { - return yield* track( - fs, - git, - Instance.directory, - (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined, - ) - } - - if (!value.root) return [] - if (value.current && value.current === value.root.name) return [] - const ref = yield* git.mergeBase(Instance.directory, value.root.ref) - if (!ref) return [] - return yield* compare(fs, git, Instance.directory, ref) - }), - }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), - ) +const count = (text: string) => { + if (!text) return 0 + if (!text.endsWith("\n")) return text.split("\n").length + return text.slice(0, -1).split("\n").length } + +const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { + const full = path.join(cwd, file) + if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" + const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + if (Buffer.from(buf).includes(0)) return "" + return Buffer.from(buf).toString("utf8") +}) + +const nums = (list: Git.Stat[]) => + new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) + +const merge = (...lists: Git.Item[][]) => { + const out = new Map() + lists.flat().forEach((item) => { + if (!out.has(item.file)) out.set(item.file, item) + }) + return [...out.values()] +} + +const files = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string | undefined, + list: Git.Item[], + map: Map, +) { + const base = ref ? yield* git.prefix(cwd) : "" + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + const next = yield* Effect.forEach( + list, + (item) => + Effect.gen(function* () { + const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) + const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) + const stat = map.get(item.file) + return { + file: item.file, + patch: patch(item.file, before, after), + additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), + deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), + status: item.status, + } satisfies FileDiff + }), + { concurrency: 8 }, + ) + return next.toSorted((a, b) => a.file.localeCompare(b.file)) +}) + +const track = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string | undefined, +) { + if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) + const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) + return yield* files(fs, git, cwd, ref, list, nums(stats)) +}) + +const compare = Effect.fnUntraced(function* ( + fs: AppFileSystem.Interface, + git: Git.Interface, + cwd: string, + ref: string, +) { + const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { + concurrency: 3, + }) + return yield* files( + fs, + git, + cwd, + ref, + merge( + list, + extra.filter((item) => item.code === "??"), + ), + nums(stats), + ) +}) + +export const Mode = z.enum(["git", "branch"]) +export type Mode = z.infer + +export const Event = { + BranchUpdated: BusEvent.define( + "vcs.branch.updated", + z.object({ + branch: z.string().optional(), + }), + ), +} + +export const Info = z + .object({ + branch: z.string().optional(), + default_branch: z.string().optional(), + }) + .meta({ + ref: "VcsInfo", + }) +export type Info = z.infer + +export const FileDiff = z + .object({ + file: z.string(), + patch: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "VcsFileDiff", + }) +export type FileDiff = z.infer + +export interface Interface { + readonly init: () => Effect.Effect + readonly branch: () => Effect.Effect + readonly defaultBranch: () => Effect.Effect + readonly diff: (mode: Mode) => Effect.Effect +} + +interface State { + current: string | undefined + root: Git.Base | undefined +} + +export class Service extends Context.Service()("@opencode/Vcs") {} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const bus = yield* Bus.Service + + const state = yield* InstanceState.make( + Effect.fn("Vcs.state")(function* (ctx) { + if (ctx.project.vcs !== "git") { + return { current: undefined, root: undefined } + } + + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + log.info("initialized", { branch: value.current, default_branch: value.root?.name }) + + yield* bus.subscribe(FileWatcher.Event.Updated).pipe( + Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), + Stream.runForEach((_evt) => + Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + Effect.forkScoped, + ) + + return value + }), + ) + + return Service.of({ + init: Effect.fn("Vcs.init")(function* () { + yield* InstanceState.get(state) + }), + branch: Effect.fn("Vcs.branch")(function* () { + return yield* InstanceState.use(state, (x) => x.current) + }), + defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { + return yield* InstanceState.use(state, (x) => x.root?.name) + }), + diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { + const value = yield* InstanceState.get(state) + if (Instance.project.vcs !== "git") return [] + if (mode === "git") { + return yield* track( + fs, + git, + Instance.directory, + (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined, + ) + } + + if (!value.root) return [] + if (value.current && value.current === value.root.name) return [] + const ref = yield* git.mergeBase(Instance.directory, value.root.ref) + if (!ref) return [] + return yield* compare(fs, git, Instance.directory, ref) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), +) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 6e1a47ed20..610d67df08 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -5,7 +5,7 @@ import { ProviderID, ModelID } from "../../provider/schema" import { ToolRegistry } from "../../tool/registry" import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" -import { Project } from "../../project/project" +import { Project } from "../../project" import { MCP } from "../../mcp" import { Session } from "../../session" import { Config } from "../../config" diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 874790f1cc..9ef6da63ac 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -6,7 +6,7 @@ import z from "zod" import { Format } from "../../format" import { TuiRoutes } from "./tui" import { Instance } from "../../project/instance" -import { Vcs } from "../../project/vcs" +import { Vcs } from "../../project" import { Agent } from "../../agent/agent" import { Skill } from "../../skill" import { Global } from "../../global" diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index 7a8e0353a2..eea741596d 100644 --- a/packages/opencode/src/server/instance/project.ts +++ b/packages/opencode/src/server/instance/project.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "../../project/instance" -import { Project } from "../../project/project" +import { Project } from "../../project" import z from "zod" import { ProjectID } from "../../project/schema" import { errors } from "../error" diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts index 86ef95f0e6..8eea6445aa 100644 --- a/packages/opencode/src/worktree/worktree.ts +++ b/packages/opencode/src/worktree/worktree.ts @@ -3,7 +3,7 @@ import { NamedError } from "@opencode-ai/shared/util/error" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" -import { Project } from "../project/project" +import { Project } from "../project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index c399d8872d..a63ac1cd98 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Project } from "../../src/project/project" +import { Project } from "../../src/project" import { Database, eq } from "../../src/storage/db" import { SessionTable } from "../../src/session/session.sql" import { ProjectTable } from "../../src/project/project.sql" diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 4c272b7949..4dc9ee5efa 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Project } from "../../src/project/project" +import { Project } from "../../src/project" import { Log } from "../../src/util" import { $ } from "bun" import path from "path" diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 5461de5c33..8f0eaecc27 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -8,7 +8,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" -import { Vcs } from "../../src/project/vcs" +import { Vcs } from "../../src/project" // Skip in CI — native @parcel/watcher binding needed const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 0edabd8e65..d0f71b8fd3 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" -import { Project } from "../../src/project/project" +import { Project } from "../../src/project" import { Session as SessionNs } from "../../src/session" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" From 581d5208ca0317dd0f441bc50eeda8e1ad614529 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:28:46 -0400 Subject: [PATCH 51/75] feat: unwrap share namespaces to flat exports + barrel (#22744) --- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/import.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 4 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 2 +- .../opencode/src/server/instance/session.ts | 2 +- packages/opencode/src/share/index.ts | 2 + packages/opencode/src/share/session.ts | 100 ++- packages/opencode/src/share/share-next.ts | 614 +++++++++--------- .../opencode/test/share/share-next.test.ts | 2 +- 10 files changed, 365 insertions(+), 367 deletions(-) create mode 100644 packages/opencode/src/share/index.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index d7863c5486..822d78770e 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -21,7 +21,7 @@ import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" -import { SessionShare } from "@/share/session" +import { SessionShare } from "@/share" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index bb8a1f63f3..38d2376bc5 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -7,7 +7,7 @@ import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { Instance } from "../../project/instance" -import { ShareNext } from "../../share/share-next" +import { ShareNext } from "../../share" import { EOL } from "os" import { Filesystem } from "../../util" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 7608e9c701..495cf9eea8 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -45,8 +45,8 @@ import { Vcs } from "@/project" import { Worktree } from "@/worktree" import { Pty } from "@/pty" import { Installation } from "@/installation" -import { ShareNext } from "@/share/share-next" -import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share" +import { SessionShare } from "@/share" export const AppLayer = Layer.mergeAll( AppFileSystem.defaultLayer, diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 7d34b4bd48..9be456b095 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -5,7 +5,7 @@ import { Plugin } from "@/plugin" import { LSP } from "@/lsp" import { FileWatcher } from "@/file/watcher" import { Format } from "@/format" -import { ShareNext } from "@/share/share-next" +import { ShareNext } from "@/share" import { File } from "@/file" import { Vcs } from "@/project" import { Snapshot } from "@/snapshot" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index c88eb8e039..27ed35b7f0 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,7 +10,7 @@ import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util" import { FileWatcher } from "@/file/watcher" -import { ShareNext } from "@/share/share-next" +import { ShareNext } from "@/share" import * as Effect from "effect/Effect" export const InstanceBootstrap = Effect.gen(function* () { diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 06495b628c..1511e99e8d 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -9,7 +9,7 @@ import { SessionPrompt } from "../../session/prompt" import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" -import { SessionShare } from "@/share/session" +import { SessionShare } from "@/share" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" diff --git a/packages/opencode/src/share/index.ts b/packages/opencode/src/share/index.ts new file mode 100644 index 0000000000..534375a0ac --- /dev/null +++ b/packages/opencode/src/share/index.ts @@ -0,0 +1,2 @@ +export * as ShareNext from "./share-next" +export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 0a673f81c6..71fa17c889 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -4,56 +4,54 @@ import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" import { Config } from "../config" import { Flag } from "../flag/flag" -import { ShareNext } from "./share-next" +import { ShareNext } from "." -export namespace SessionShare { - export interface Interface { - readonly create: (input?: Session.CreateInput) => Effect.Effect - readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> - readonly unshare: (sessionID: SessionID) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/SessionShare") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const session = yield* Session.Service - const shareNext = yield* ShareNext.Service - const scope = yield* Scope.Scope - - const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { - const conf = yield* cfg.get() - if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") - const result = yield* shareNext.create(sessionID) - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), - ) - return result - }) - - const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { - yield* shareNext.remove(sessionID) - yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) - }) - - const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { - const result = yield* session.create(input) - if (result.parentID) return result - const conf = yield* cfg.get() - if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - return result - }) - - return Service.of({ create, share, unshare }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(ShareNext.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Config.defaultLayer), - ) +export interface Interface { + readonly create: (input?: Session.CreateInput) => Effect.Effect + readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> + readonly unshare: (sessionID: SessionID) => Effect.Effect } + +export class Service extends Context.Service()("@opencode/SessionShare") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const session = yield* Session.Service + const shareNext = yield* ShareNext.Service + const scope = yield* Scope.Scope + + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + const conf = yield* cfg.get() + if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") + const result = yield* shareNext.create(sessionID) + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), + ) + return result + }) + + const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* shareNext.remove(sessionID) + yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + }) + + const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { + const result = yield* session.create(input) + if (result.parentID) return result + const conf = yield* cfg.get() + if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result + yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) + return result + }) + + return Service.of({ create, share, unshare }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Config.defaultLayer), +) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index bcb1fcc962..a7656e840c 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -14,337 +14,335 @@ import { Config } from "@/config" import { Log } from "@/util" import { SessionShareTable } from "./share.sql" -export namespace ShareNext { - const log = Log.create({ service: "share-next" }) - const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" +const log = Log.create({ service: "share-next" }) +const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" - export type Api = { - create: string - sync: (shareID: string) => string - remove: (shareID: string) => string - data: (shareID: string) => string - } +export type Api = { + create: string + sync: (shareID: string) => string + remove: (shareID: string) => string + data: (shareID: string) => string +} - export type Req = { - headers: Record - api: Api - baseUrl: string - } +export type Req = { + headers: Record + api: Api + baseUrl: string +} - const ShareSchema = Schema.Struct({ - id: Schema.String, - url: Schema.String, - secret: Schema.String, - }) - export type Share = typeof ShareSchema.Type +const ShareSchema = Schema.Struct({ + id: Schema.String, + url: Schema.String, + secret: Schema.String, +}) +export type Share = typeof ShareSchema.Type - type State = { - queue: Map }> - scope: Scope.Closeable - } +type State = { + queue: Map }> + scope: Scope.Closeable +} - type Data = - | { - type: "session" - data: SDK.Session - } - | { - type: "message" - data: SDK.Message - } - | { - type: "part" - data: SDK.Part - } - | { - type: "session_diff" - data: SDK.SnapshotFileDiff[] - } - | { - type: "model" - data: SDK.Model[] - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly url: () => Effect.Effect - readonly request: () => Effect.Effect - readonly create: (sessionID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/ShareNext") {} - - const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - - function api(resource: string): Api { - return { - create: `/api/${resource}`, - sync: (shareID) => `/api/${resource}/${shareID}/sync`, - remove: (shareID) => `/api/${resource}/${shareID}`, - data: (shareID) => `/api/${resource}/${shareID}/data`, +type Data = + | { + type: "session" + data: SDK.Session } - } - - const legacyApi = api("share") - const consoleApi = api("shares") - - function key(item: Data) { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `part/${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" + | { + type: "message" + data: SDK.Message } + | { + type: "part" + data: SDK.Part + } + | { + type: "session_diff" + data: SDK.SnapshotFileDiff[] + } + | { + type: "model" + data: SDK.Model[] + } + +export interface Interface { + readonly init: () => Effect.Effect + readonly url: () => Effect.Effect + readonly request: () => Effect.Effect + readonly create: (sessionID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ShareNext") {} + +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +function api(resource: string): Api { + return { + create: `/api/${resource}`, + sync: (shareID) => `/api/${resource}/${shareID}/sync`, + remove: (shareID) => `/api/${resource}/${shareID}`, + data: (shareID) => `/api/${resource}/${shareID}/data`, } +} - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const account = yield* Account.Service - const bus = yield* Bus.Service - const cfg = yield* Config.Service - const http = yield* HttpClient.HttpClient - const httpOk = HttpClient.filterStatusOk(http) - const provider = yield* Provider.Service - const session = yield* Session.Service +const legacyApi = api("share") +const consoleApi = api("shares") - function sync(sessionID: SessionID, data: Data[]): Effect.Effect { - return Effect.gen(function* () { - if (disabled) return - const s = yield* InstanceState.get(state) - const existing = s.queue.get(sessionID) - if (existing) { - for (const item of data) { - existing.data.set(key(item), item) - } - return +function key(item: Data) { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `part/${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const account = yield* Account.Service + const bus = yield* Bus.Service + const cfg = yield* Config.Service + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(http) + const provider = yield* Provider.Service + const session = yield* Session.Service + + function sync(sessionID: SessionID, data: Data[]): Effect.Effect { + return Effect.gen(function* () { + if (disabled) return + const s = yield* InstanceState.get(state) + const existing = s.queue.get(sessionID) + if (existing) { + for (const item of data) { + existing.data.set(key(item), item) } - - const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, { data: next }) - yield* flush(sessionID).pipe( - Effect.delay(1000), - Effect.catchCause((cause) => - Effect.sync(() => { - log.error("share flush failed", { sessionID, cause }) - }), - ), - Effect.forkIn(s.scope), - ) - }) - } - - const state: InstanceState.InstanceState = yield* InstanceState.make( - Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make() } - - yield* Effect.addFinalizer(() => - Scope.close(cache.scope, Exit.void).pipe( - Effect.andThen( - Effect.sync(() => { - cache.queue.clear() - }), - ), - ), - ) - - if (disabled) return cache - - const watch = ( - def: D, - fn: (evt: { properties: any }) => Effect.Effect, - ) => - bus.subscribe(def as never).pipe( - Stream.runForEach((evt) => - fn(evt).pipe( - Effect.catchCause((cause) => - Effect.sync(() => { - log.error("share subscriber failed", { type: def.type, cause }) - }), - ), - ), - ), - Effect.forkScoped, - ) - - yield* watch(Session.Event.Updated, (evt) => - Effect.gen(function* () { - const info = yield* session.get(evt.properties.sessionID) - yield* sync(info.id, [{ type: "session", data: info }]) - }), - ) - yield* watch(MessageV2.Event.Updated, (evt) => - Effect.gen(function* () { - const info = evt.properties.info - yield* sync(info.sessionID, [{ type: "message", data: info }]) - if (info.role !== "user") return - const model = yield* provider.getModel(info.model.providerID, info.model.modelID) - yield* sync(info.sessionID, [{ type: "model", data: [model] }]) - }), - ) - yield* watch(MessageV2.Event.PartUpdated, (evt) => - sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), - ) - yield* watch(Session.Event.Diff, (evt) => - sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), - ) - yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) - - return cache - }), - ) - - const request = Effect.fn("ShareNext.request")(function* () { - const headers: Record = {} - const active = yield* account.active() - if (Option.isNone(active) || !active.value.active_org_id) { - const baseUrl = (yield* cfg.get()).enterprise?.url ?? "https://opncd.ai" - return { headers, api: legacyApi, baseUrl } satisfies Req + return } - const token = yield* account.token(active.value.id) - if (Option.isNone(token)) { - throw new Error("No active account token available for sharing") - } - - headers.authorization = `Bearer ${token.value}` - headers["x-org-id"] = active.value.active_org_id - return { headers, api: consoleApi, baseUrl: active.value.url } satisfies Req - }) - - const get = Effect.fnUntraced(function* (sessionID: SessionID) { - const row = yield* db((db) => - db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), - ) - if (!row) return - return { id: row.id, secret: row.secret, url: row.url } satisfies Share - }) - - const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { - if (disabled) return - const s = yield* InstanceState.get(state) - const queued = s.queue.get(sessionID) - if (!queued) return - - s.queue.delete(sessionID) - - const share = yield* get(sessionID) - if (!share) return - - const req = yield* request() - const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }), - Effect.flatMap((r) => http.execute(r)), - ) - - if (res.status >= 400) { - log.warn("failed to sync share", { sessionID, shareID: share.id, status: res.status }) - } - }) - - const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) { - log.info("full sync", { sessionID }) - const info = yield* session.get(sessionID) - const diffs = yield* session.diff(sessionID) - const messages = yield* Effect.sync(() => Array.from(MessageV2.stream(sessionID))) - const models = yield* Effect.forEach( - Array.from( - new Map( - messages - .filter((msg) => msg.info.role === "user") - .map((msg) => (msg.info as SDK.UserMessage).model) - .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), - ).values(), - ), - (item) => provider.getModel(ProviderID.make(item.providerID), ModelID.make(item.modelID)), - { concurrency: 8 }, - ) - - yield* sync(sessionID, [ - { type: "session", data: info }, - ...messages.map((item) => ({ type: "message" as const, data: item.info })), - ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), - { type: "session_diff", data: diffs }, - { type: "model", data: models }, - ]) - }) - - const init = Effect.fn("ShareNext.init")(function* () { - if (disabled) return - yield* InstanceState.get(state) - }) - - const url = Effect.fn("ShareNext.url")(function* () { - return (yield* request()).baseUrl - }) - - const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { - if (disabled) return { id: "", url: "", secret: "" } - log.info("creating share", { sessionID }) - const req = yield* request() - const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ sessionID }), - Effect.flatMap((r) => httpOk.execute(r)), - Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), - ) - yield* db((db) => - db - .insert(SessionShareTable) - .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) - .onConflictDoUpdate({ - target: SessionShareTable.session_id, - set: { id: result.id, secret: result.secret, url: result.url }, - }) - .run(), - ) - const s = yield* InstanceState.get(state) - yield* full(sessionID).pipe( + const next = new Map(data.map((item) => [key(item), item])) + s.queue.set(sessionID, { data: next }) + yield* flush(sessionID).pipe( + Effect.delay(1000), Effect.catchCause((cause) => Effect.sync(() => { - log.error("share full sync failed", { sessionID, cause }) + log.error("share flush failed", { sessionID, cause }) }), ), Effect.forkIn(s.scope), ) - return result }) + } - const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { - if (disabled) return - log.info("removing share", { sessionID }) - const share = yield* get(sessionID) - if (!share) return + const state: InstanceState.InstanceState = yield* InstanceState.make( + Effect.fn("ShareNext.state")(function* (_ctx) { + const cache: State = { queue: new Map(), scope: yield* Scope.make() } - const req = yield* request() - yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret }), - Effect.flatMap((r) => httpOk.execute(r)), + yield* Effect.addFinalizer(() => + Scope.close(cache.scope, Exit.void).pipe( + Effect.andThen( + Effect.sync(() => { + cache.queue.clear() + }), + ), + ), ) - yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) - }) + if (disabled) return cache - return Service.of({ init, url, request, create, remove }) - }), - ) + const watch = ( + def: D, + fn: (evt: { properties: any }) => Effect.Effect, + ) => + bus.subscribe(def as never).pipe( + Stream.runForEach((evt) => + fn(evt).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("share subscriber failed", { type: def.type, cause }) + }), + ), + ), + ), + Effect.forkScoped, + ) - export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), - Layer.provide(Account.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - ) -} + yield* watch(Session.Event.Updated, (evt) => + Effect.gen(function* () { + const info = yield* session.get(evt.properties.sessionID) + yield* sync(info.id, [{ type: "session", data: info }]) + }), + ) + yield* watch(MessageV2.Event.Updated, (evt) => + Effect.gen(function* () { + const info = evt.properties.info + yield* sync(info.sessionID, [{ type: "message", data: info }]) + if (info.role !== "user") return + const model = yield* provider.getModel(info.model.providerID, info.model.modelID) + yield* sync(info.sessionID, [{ type: "model", data: [model] }]) + }), + ) + yield* watch(MessageV2.Event.PartUpdated, (evt) => + sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), + ) + yield* watch(Session.Event.Diff, (evt) => + sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + ) + yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) + + return cache + }), + ) + + const request = Effect.fn("ShareNext.request")(function* () { + const headers: Record = {} + const active = yield* account.active() + if (Option.isNone(active) || !active.value.active_org_id) { + const baseUrl = (yield* cfg.get()).enterprise?.url ?? "https://opncd.ai" + return { headers, api: legacyApi, baseUrl } satisfies Req + } + + const token = yield* account.token(active.value.id) + if (Option.isNone(token)) { + throw new Error("No active account token available for sharing") + } + + headers.authorization = `Bearer ${token.value}` + headers["x-org-id"] = active.value.active_org_id + return { headers, api: consoleApi, baseUrl: active.value.url } satisfies Req + }) + + const get = Effect.fnUntraced(function* (sessionID: SessionID) { + const row = yield* db((db) => + db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), + ) + if (!row) return + return { id: row.id, secret: row.secret, url: row.url } satisfies Share + }) + + const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { + if (disabled) return + const s = yield* InstanceState.get(state) + const queued = s.queue.get(sessionID) + if (!queued) return + + s.queue.delete(sessionID) + + const share = yield* get(sessionID) + if (!share) return + + const req = yield* request() + const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( + HttpClientRequest.setHeaders(req.headers), + HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }), + Effect.flatMap((r) => http.execute(r)), + ) + + if (res.status >= 400) { + log.warn("failed to sync share", { sessionID, shareID: share.id, status: res.status }) + } + }) + + const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) { + log.info("full sync", { sessionID }) + const info = yield* session.get(sessionID) + const diffs = yield* session.diff(sessionID) + const messages = yield* Effect.sync(() => Array.from(MessageV2.stream(sessionID))) + const models = yield* Effect.forEach( + Array.from( + new Map( + messages + .filter((msg) => msg.info.role === "user") + .map((msg) => (msg.info as SDK.UserMessage).model) + .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), + ).values(), + ), + (item) => provider.getModel(ProviderID.make(item.providerID), ModelID.make(item.modelID)), + { concurrency: 8 }, + ) + + yield* sync(sessionID, [ + { type: "session", data: info }, + ...messages.map((item) => ({ type: "message" as const, data: item.info })), + ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), + { type: "session_diff", data: diffs }, + { type: "model", data: models }, + ]) + }) + + const init = Effect.fn("ShareNext.init")(function* () { + if (disabled) return + yield* InstanceState.get(state) + }) + + const url = Effect.fn("ShareNext.url")(function* () { + return (yield* request()).baseUrl + }) + + const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { + if (disabled) return { id: "", url: "", secret: "" } + log.info("creating share", { sessionID }) + const req = yield* request() + const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( + HttpClientRequest.setHeaders(req.headers), + HttpClientRequest.bodyJson({ sessionID }), + Effect.flatMap((r) => httpOk.execute(r)), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), + ) + yield* db((db) => + db + .insert(SessionShareTable) + .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) + .onConflictDoUpdate({ + target: SessionShareTable.session_id, + set: { id: result.id, secret: result.secret, url: result.url }, + }) + .run(), + ) + const s = yield* InstanceState.get(state) + yield* full(sessionID).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("share full sync failed", { sessionID, cause }) + }), + ), + Effect.forkIn(s.scope), + ) + return result + }) + + const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { + if (disabled) return + log.info("removing share", { sessionID }) + const share = yield* get(sessionID) + if (!share) return + + const req = yield* request() + yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( + HttpClientRequest.setHeaders(req.headers), + HttpClientRequest.bodyJson({ secret: share.secret }), + Effect.flatMap((r) => httpOk.execute(r)), + ) + + yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + }) + + return Service.of({ init, url, request, create, remove }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Account.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Session.defaultLayer), +) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 8150e03623..ac3f7b79e0 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -12,7 +12,7 @@ import { Config } from "../../src/config" import { Provider } from "../../src/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" -import { ShareNext } from "../../src/share/share-next" +import { ShareNext } from "../../src/share" import { SessionShareTable } from "../../src/share/share.sql" import { Database, eq } from "../../src/storage/db" import { provideTmpdirInstance } from "../fixture/fixture" From d4cfbd020da730ad8e9d72ffe61d6496d48ccf30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:29:12 -0400 Subject: [PATCH 52/75] feat: unwrap effect namespaces to flat exports + barrel (#22745) --- packages/opencode/src/effect/app-runtime.ts | 2 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/effect/index.ts | 3 + .../opencode/src/effect/instance-state.ts | 2 +- packages/opencode/src/effect/logger.ts | 122 +++--- packages/opencode/src/effect/observability.ts | 134 ++++--- packages/opencode/src/effect/run-service.ts | 2 +- packages/opencode/src/effect/runner.ts | 362 +++++++++--------- .../src/server/instance/httpapi/server.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/run-state.ts | 4 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/skill.ts | 2 +- .../test/effect/app-runtime-logger.test.ts | 2 +- packages/opencode/test/effect/runner.test.ts | 2 +- 16 files changed, 322 insertions(+), 325 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 495cf9eea8..bd27df3435 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,6 +1,6 @@ import { Layer, ManagedRuntime } from "effect" import { attach, memoMap } from "./run-service" -import { Observability } from "./observability" +import { Observability } from "." import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 9be456b095..208a83bf85 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -10,7 +10,7 @@ import { File } from "@/file" import { Vcs } from "@/project" import { Snapshot } from "@/snapshot" import { Bus } from "@/bus" -import { Observability } from "./observability" +import { Observability } from "." export const BootstrapLayer = Layer.mergeAll( Plugin.defaultLayer, diff --git a/packages/opencode/src/effect/index.ts b/packages/opencode/src/effect/index.ts index d10afdff2b..410ce00c22 100644 --- a/packages/opencode/src/effect/index.ts +++ b/packages/opencode/src/effect/index.ts @@ -1,2 +1,5 @@ export * as InstanceState from "./instance-state" export * as EffectBridge from "./bridge" +export * as Runner from "./runner" +export * as Observability from "./observability" +export * as EffectLogger from "./logger" diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index b6249db049..d71f82df97 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,5 +1,5 @@ import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" import { Instance, type InstanceContext } from "@/project/instance" import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" diff --git a/packages/opencode/src/effect/logger.ts b/packages/opencode/src/effect/logger.ts index 7f21084ddc..97b614fc0a 100644 --- a/packages/opencode/src/effect/logger.ts +++ b/packages/opencode/src/effect/logger.ts @@ -1,67 +1,65 @@ import { Cause, Effect, Logger, References } from "effect" import { Log } from "@/util" -export namespace EffectLogger { - type Fields = Record +type Fields = Record - export interface Handle { - readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly with: (extra: Fields) => Handle - } - - const clean = (input?: Fields): Fields => - Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null)) - - const text = (input: unknown): string => { - if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") - return input === undefined ? "" : String(input) - } - - const call = (run: (msg?: unknown) => Effect.Effect, base: Fields, msg?: unknown, extra?: Fields) => { - const ann = clean({ ...base, ...extra }) - const fx = run(msg) - return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx - } - - export const logger = Logger.make((opts) => { - const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) - const now = opts.date.getTime() - for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { - extra[`logSpan.${key}`] = `${now - start}ms` - } - if (opts.cause.reasons.length > 0) { - extra.cause = Cause.pretty(opts.cause) - } - - const svc = typeof extra.service === "string" ? extra.service : undefined - if (svc) delete extra.service - const log = svc ? Log.create({ service: svc }) : Log.Default - const msg = text(opts.message) - - switch (opts.logLevel) { - case "Trace": - case "Debug": - return log.debug(msg, extra) - case "Warn": - return log.warn(msg, extra) - case "Error": - case "Fatal": - return log.error(msg, extra) - default: - return log.info(msg, extra) - } - }) - - export const layer = Logger.layer([logger], { mergeWithExisting: false }) - - export const create = (base: Fields = {}): Handle => ({ - debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), - info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), - warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), - error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), - with: (extra) => create({ ...base, ...extra }), - }) +export interface Handle { + readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly with: (extra: Fields) => Handle } + +const clean = (input?: Fields): Fields => + Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null)) + +const text = (input: unknown): string => { + if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") + return input === undefined ? "" : String(input) +} + +const call = (run: (msg?: unknown) => Effect.Effect, base: Fields, msg?: unknown, extra?: Fields) => { + const ann = clean({ ...base, ...extra }) + const fx = run(msg) + return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx +} + +export const logger = Logger.make((opts) => { + const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) + const now = opts.date.getTime() + for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { + extra[`logSpan.${key}`] = `${now - start}ms` + } + if (opts.cause.reasons.length > 0) { + extra.cause = Cause.pretty(opts.cause) + } + + const svc = typeof extra.service === "string" ? extra.service : undefined + if (svc) delete extra.service + const log = svc ? Log.create({ service: svc }) : Log.Default + const msg = text(opts.message) + + switch (opts.logLevel) { + case "Trace": + case "Debug": + return log.debug(msg, extra) + case "Warn": + return log.warn(msg, extra) + case "Error": + case "Fatal": + return log.error(msg, extra) + default: + return log.info(msg, extra) + } +}) + +export const layer = Logger.layer([logger], { mergeWithExisting: false }) + +export const create = (base: Fields = {}): Handle => ({ + debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), + info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), + warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), + error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), + with: (extra) => create({ ...base, ...extra }), +}) diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index f79306bf1e..4e8ae22217 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -1,80 +1,78 @@ import { Effect, Layer, Logger } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" import { Flag } from "@/flag/flag" import { CHANNEL, VERSION } from "@/installation/meta" -export namespace Observability { - const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT - export const enabled = !!base +const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT +export const enabled = !!base - const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS - ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( - (acc, x) => { - const [key, ...value] = x.split("=") - acc[key] = value.join("=") - return acc - }, - {} as Record, - ) - : undefined +const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS + ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( + (acc, x) => { + const [key, ...value] = x.split("=") + acc[key] = value.join("=") + return acc + }, + {} as Record, + ) + : undefined - const resource = { - serviceName: "opencode", - serviceVersion: VERSION, - attributes: { - "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, - "opencode.client": Flag.OPENCODE_CLIENT, - }, - } +const resource = { + serviceName: "opencode", + serviceVersion: VERSION, + attributes: { + "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, + "opencode.client": Flag.OPENCODE_CLIENT, + }, +} - const logs = Logger.layer( - [ - EffectLogger.logger, - OtlpLogger.make({ - url: `${base}/v1/logs`, - resource, +const logs = Logger.layer( + [ + EffectLogger.logger, + OtlpLogger.make({ + url: `${base}/v1/logs`, + resource, + headers, + }), + ], + { mergeWithExisting: false }, +).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) + +const traces = async () => { + const NodeSdk = await import("@effect/opentelemetry/NodeSdk") + const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") + const SdkBase = await import("@opentelemetry/sdk-trace-base") + + // @effect/opentelemetry creates a NodeTracerProvider but never calls + // register(), so the global @opentelemetry/api context manager stays + // as the no-op default. Non-Effect code (like the AI SDK) that calls + // tracer.startActiveSpan() relies on context.active() to find the + // parent span — without a real context manager every span starts a + // new trace. Registering AsyncLocalStorageContextManager fixes this. + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") + const { context } = await import("@opentelemetry/api") + const mgr = new AsyncLocalStorageContextManager() + mgr.enable() + context.setGlobalContextManager(mgr) + + return NodeSdk.layer(() => ({ + resource, + spanProcessor: new SdkBase.BatchSpanProcessor( + new OTLP.OTLPTraceExporter({ + url: `${base}/v1/traces`, headers, }), - ], - { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) - - const traces = async () => { - const NodeSdk = await import("@effect/opentelemetry/NodeSdk") - const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") - const SdkBase = await import("@opentelemetry/sdk-trace-base") - - // @effect/opentelemetry creates a NodeTracerProvider but never calls - // register(), so the global @opentelemetry/api context manager stays - // as the no-op default. Non-Effect code (like the AI SDK) that calls - // tracer.startActiveSpan() relies on context.active() to find the - // parent span — without a real context manager every span starts a - // new trace. Registering AsyncLocalStorageContextManager fixes this. - const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") - const { context } = await import("@opentelemetry/api") - const mgr = new AsyncLocalStorageContextManager() - mgr.enable() - context.setGlobalContextManager(mgr) - - return NodeSdk.layer(() => ({ - resource, - spanProcessor: new SdkBase.BatchSpanProcessor( - new OTLP.OTLPTraceExporter({ - url: `${base}/v1/traces`, - headers, - }), - ), - })) - } - - export const layer = !base - ? EffectLogger.layer - : Layer.unwrap( - Effect.gen(function* () { - const trace = yield* Effect.promise(traces) - return Layer.mergeAll(trace, logs) - }), - ) + ), + })) } + +export const layer = !base + ? EffectLogger.layer + : Layer.unwrap( + Effect.gen(function* () { + const trace = yield* Effect.promise(traces) + return Layer.mergeAll(trace, logs) + }), + ) diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 9553e7a3aa..a9d653b108 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" -import { Observability } from "./observability" +import { Observability } from "." import { WorkspaceContext } from "@/control-plane/workspace-context" import type { InstanceContext } from "@/project/instance" diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 38c45a6342..925c268f8e 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,208 +1,206 @@ import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { - readonly state: Runner.State + readonly state: State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } -export namespace Runner { - export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} +export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} - interface RunHandle { - id: number - done: Deferred.Deferred - fiber: Fiber.Fiber +interface RunHandle { + id: number + done: Deferred.Deferred + fiber: Fiber.Fiber +} + +interface ShellHandle { + id: number + fiber: Fiber.Fiber +} + +interface PendingHandle { + id: number + done: Deferred.Deferred + work: Effect.Effect +} + +export type State = + | { readonly _tag: "Idle" } + | { readonly _tag: "Running"; readonly run: RunHandle } + | { readonly _tag: "Shell"; readonly shell: ShellHandle } + | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly run: PendingHandle } + +export const make = ( + scope: Scope.Scope, + opts?: { + onIdle?: Effect.Effect + onBusy?: Effect.Effect + onInterrupt?: Effect.Effect + busy?: () => never + }, +): Runner => { + const ref = SynchronizedRef.makeUnsafe>({ _tag: "Idle" }) + const idle = opts?.onIdle ?? Effect.void + const busy = opts?.onBusy ?? Effect.void + const onInterrupt = opts?.onInterrupt + let ids = 0 + + const state = () => SynchronizedRef.getUnsafe(ref) + const next = () => { + ids += 1 + return ids } - interface ShellHandle { - id: number - fiber: Fiber.Fiber - } + const complete = (done: Deferred.Deferred, exit: Exit.Exit) => + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid) + : Deferred.done(done, exit).pipe(Effect.asVoid) - interface PendingHandle { - id: number - done: Deferred.Deferred - work: Effect.Effect - } + const idleIfCurrent = () => + SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) - export type State = - | { readonly _tag: "Idle" } - | { readonly _tag: "Running"; readonly run: RunHandle } - | { readonly _tag: "Shell"; readonly shell: ShellHandle } - | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly run: PendingHandle } + const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit) => + SynchronizedRef.modify( + ref, + (st) => + [ + Effect.gen(function* () { + if (st._tag === "Running" && st.run.id === id) yield* idle + yield* complete(done, exit) + }), + st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st, + ] as const, + ).pipe(Effect.flatten) - export const make = ( - scope: Scope.Scope, - opts?: { - onIdle?: Effect.Effect - onBusy?: Effect.Effect - onInterrupt?: Effect.Effect - busy?: () => never - }, - ): Runner => { - const ref = SynchronizedRef.makeUnsafe>({ _tag: "Idle" }) - const idle = opts?.onIdle ?? Effect.void - const busy = opts?.onBusy ?? Effect.void - const onInterrupt = opts?.onInterrupt - let ids = 0 - - const state = () => SynchronizedRef.getUnsafe(ref) - const next = () => { - ids += 1 - return ids - } - - const complete = (done: Deferred.Deferred, exit: Exit.Exit) => - Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid) - : Deferred.done(done, exit).pipe(Effect.asVoid) - - const idleIfCurrent = () => - SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) - - const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit) => - SynchronizedRef.modify( - ref, - (st) => - [ - Effect.gen(function* () { - if (st._tag === "Running" && st.run.id === id) yield* idle - yield* complete(done, exit) - }), - st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st, - ] as const, - ).pipe(Effect.flatten) - - const startRun = (work: Effect.Effect, done: Deferred.Deferred) => - Effect.gen(function* () { - const id = next() - const fiber = yield* work.pipe( - Effect.onExit((exit) => finishRun(id, done, exit)), - Effect.forkIn(scope), - ) - return { id, done, fiber } satisfies RunHandle - }) - - const finishShell = (id: number) => - SynchronizedRef.modifyEffect( - ref, - Effect.fnUntraced(function* (st) { - if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const - if (st._tag === "ShellThenRun" && st.shell.id === id) { - const run = yield* startRun(st.run.work, st.run.done) - return [Effect.void, { _tag: "Running", run }] as const - } - return [Effect.void, st] as const - }), - ).pipe(Effect.flatten) - - const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) - - const ensureRunning = (work: Effect.Effect) => - SynchronizedRef.modifyEffect( - ref, - Effect.fnUntraced(function* (st) { - switch (st._tag) { - case "Running": - case "ShellThenRun": - return [Deferred.await(st.run.done), st] as const - case "Shell": { - const run = { - id: next(), - done: yield* Deferred.make(), - work, - } satisfies PendingHandle - return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const - } - case "Idle": { - const done = yield* Deferred.make() - const run = yield* startRun(work, done) - return [Deferred.await(done), { _tag: "Running", run }] as const - } - } - }), - ).pipe( - Effect.flatten, - Effect.catch( - (e): Effect.Effect => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)), - ), + const startRun = (work: Effect.Effect, done: Deferred.Deferred) => + Effect.gen(function* () { + const id = next() + const fiber = yield* work.pipe( + Effect.onExit((exit) => finishRun(id, done, exit)), + Effect.forkIn(scope), ) + return { id, done, fiber } satisfies RunHandle + }) - const startShell = (work: Effect.Effect) => - SynchronizedRef.modifyEffect( - ref, - Effect.fnUntraced(function* (st) { - if (st._tag !== "Idle") { - return [ - Effect.sync(() => { - if (opts?.busy) opts.busy() - throw new Error("Runner is busy") - }), - st, - ] as const + const finishShell = (id: number) => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const + if (st._tag === "ShellThenRun" && st.shell.id === id) { + const run = yield* startRun(st.run.work, st.run.done) + return [Effect.void, { _tag: "Running", run }] as const + } + return [Effect.void, st] as const + }), + ).pipe(Effect.flatten) + + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) + + const ensureRunning = (work: Effect.Effect) => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + switch (st._tag) { + case "Running": + case "ShellThenRun": + return [Deferred.await(st.run.done), st] as const + case "Shell": { + const run = { + id: next(), + done: yield* Deferred.make(), + work, + } satisfies PendingHandle + return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const } - yield* busy - const id = next() - const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber } satisfies ShellHandle - return [ - Effect.gen(function* () { - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt - return yield* Effect.failCause(exit.cause) - }), - { _tag: "Shell", shell }, - ] as const - }), - ).pipe(Effect.flatten) + case "Idle": { + const done = yield* Deferred.make() + const run = yield* startRun(work, done) + return [Deferred.await(done), { _tag: "Running", run }] as const + } + } + }), + ).pipe( + Effect.flatten, + Effect.catch( + (e): Effect.Effect => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)), + ), + ) - const cancel = SynchronizedRef.modify(ref, (st) => { - switch (st._tag) { - case "Idle": - return [Effect.void, st] as const - case "Running": + const startShell = (work: Effect.Effect) => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + if (st._tag !== "Idle") { return [ - Effect.gen(function* () { - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) - yield* idleIfCurrent() + Effect.sync(() => { + if (opts?.busy) opts.busy() + throw new Error("Runner is busy") }), - { _tag: "Idle" } as const, + st, ] as const - case "Shell": - return [ - Effect.gen(function* () { - yield* stopShell(st.shell) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - case "ShellThenRun": - return [ - Effect.gen(function* () { - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - yield* stopShell(st.shell) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - } - }).pipe(Effect.flatten) + } + yield* busy + const id = next() + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber } satisfies ShellHandle + return [ + Effect.gen(function* () { + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + return yield* Effect.failCause(exit.cause) + }), + { _tag: "Shell", shell }, + ] as const + }), + ).pipe(Effect.flatten) - return { - get state() { - return state() - }, - get busy() { - return state()._tag !== "Idle" - }, - ensureRunning, - startShell, - cancel, + const cancel = SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": + return [ + Effect.gen(function* () { + yield* Fiber.interrupt(st.run.fiber) + yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "Shell": + return [ + Effect.gen(function* () { + yield* stopShell(st.shell) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "ShellThenRun": + return [ + Effect.gen(function* () { + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* stopShell(st.shell) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const } + }).pipe(Effect.flatten) + + return { + get state() { + return state() + }, + get busy() { + return state()._tag !== "Idle" + }, + ensureRunning, + startShell, + cancel, } } diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 62ffb5940d..299a177f50 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -3,7 +3,7 @@ import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unsta import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { Observability } from "@/effect/observability" +import { Observability } from "@/effect" import { memoMap } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { InstanceBootstrap } from "@/project/bootstrap" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2a501167a5..f4a7235e15 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,7 +15,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a072633aa7..157533af0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -44,7 +44,7 @@ import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 922daf1178..179f287fa8 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,5 +1,5 @@ import { InstanceState } from "@/effect" -import { Runner } from "@/effect/runner" +import { Runner } from "@/effect" import { Effect, Layer, Scope, Context } from "effect" import { Session } from "." import { MessageV2 } from "./message-v2" @@ -32,7 +32,7 @@ export namespace SessionRunState { const state = yield* InstanceState.make( Effect.fn("SessionRunState.state")(function* () { const scope = yield* Scope.Scope - const runners = new Map>() + const runners = new Map>() yield* Effect.addFinalizer( Effect.fnUntraced(function* () { yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index c91b698038..810206f817 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,6 +1,6 @@ import path from "path" import { Effect } from "effect" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import type { Tool } from "./tool" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index d5f3787ed6..eaec667e58 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "url" import z from "zod" import { Effect } from "effect" import * as Stream from "effect/Stream" -import { EffectLogger } from "@/effect/logger" +import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import { Tool } from "./tool" diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index 91f367ff3e..8d5649a20c 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -3,7 +3,7 @@ import { Context, Effect, Layer, Logger } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" import { EffectBridge } from "../../src/effect" import { InstanceRef } from "../../src/effect/instance-ref" -import { EffectLogger } from "../../src/effect/logger" +import { EffectLogger } from "../../src/effect" import { makeRuntime } from "../../src/effect/run-service" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index a91df76ebf..241e7c2a88 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" -import { Runner } from "../../src/effect/runner" +import { Runner } from "../../src/effect" import { it } from "../lib/effect" describe("Runner", () => { From 1ca257e356e404a659d6ee39d5e26a41e729ca54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:29:14 -0400 Subject: [PATCH 53/75] feat: unwrap config namespaces to flat exports + barrel (#22746) --- packages/opencode/script/schema.ts | 2 +- packages/opencode/src/cli/cmd/plug.ts | 2 +- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- packages/opencode/src/cli/cmd/tui/attach.ts | 2 +- .../src/cli/cmd/tui/context/keybind.tsx | 2 +- .../src/cli/cmd/tui/context/tui-config.tsx | 2 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 2 +- .../src/cli/cmd/tui/plugin/runtime.ts | 2 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../opencode/src/cli/cmd/tui/util/scroll.ts | 2 +- packages/opencode/src/cli/error.ts | 2 +- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/config/index.ts | 3 + packages/opencode/src/config/markdown.ts | 182 +++++---- packages/opencode/src/config/paths.ts | 312 ++++++++------- packages/opencode/src/config/tui-migrate.ts | 2 +- packages/opencode/src/config/tui.ts | 370 +++++++++--------- packages/opencode/src/plugin/install.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/skill/skill.ts | 2 +- .../opencode/test/cli/tui/plugin-add.test.ts | 2 +- .../test/cli/tui/plugin-install.test.ts | 2 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 2 +- .../test/cli/tui/plugin-loader-pure.test.ts | 2 +- .../test/cli/tui/plugin-loader.test.ts | 2 +- .../test/cli/tui/plugin-toggle.test.ts | 2 +- packages/opencode/test/cli/tui/thread.test.ts | 2 +- .../opencode/test/config/markdown.test.ts | 2 +- packages/opencode/test/config/tui.test.ts | 2 +- packages/opencode/test/fixture/tui-runtime.ts | 2 +- 30 files changed, 459 insertions(+), 462 deletions(-) diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 4ea68d9bbb..4aa27423ba 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { Config } from "../src/config" -import { TuiConfig } from "../src/config/tui" +import { TuiConfig } from "../src/config" function generate(schema: z.ZodType) { const result = z.toJSONSchema(schema, { diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 42d06ff47f..9dfda16d64 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,7 +1,7 @@ import { intro, log, outro, spinner } from "@clack/prompts" import type { Argv } from "yargs" -import { ConfigPaths } from "../../config/paths" +import { ConfigPaths } from "../../config" import { Global } from "../../global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e7e9fd9cd2..9e96d5dcbc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d..9fcbf4c1f3 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -2,7 +2,7 @@ import { cmd } from "../cmd" import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Instance } from "@/project/instance" import { existsSync } from "fs" diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 9c883aa205..b1dcdd7808 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" import { Keybind } from "@/util" import { pipe, mapValues } from "remeda" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" import type { ParsedKey, Renderable } from "@opentui/core" import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 62dbf1ebd1..cfe59ba803 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -1,4 +1,4 @@ -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 3af70d8c25..42988fcb1f 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" import { createPluginKeybind } from "../context/plugin-keybinds" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index dd873b753a..da003607c4 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -14,7 +14,7 @@ import path from "path" import { fileURLToPath } from "url" import { Config } from "@/config" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3aaa5a54f8..89b32d166e 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -13,7 +13,7 @@ import { Filesystem } from "@/util" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config/tui" +import { TuiConfig } from "@/config" import { Instance } from "@/project/instance" import { writeHeapSnapshot } from "v8" diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index 9b9398f302..d27bdb90ce 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -1,5 +1,5 @@ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core" -import type { TuiConfig } from "@/config/tui" +import type { TuiConfig } from "@/config" export class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 6ba110d34f..735f1a721e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,5 +1,5 @@ import { AccountServiceError, AccountTransportError } from "@/account" -import { ConfigMarkdown } from "@/config/markdown" +import { ConfigMarkdown } from "@/config" import { errorFormat } from "@/util/error" import { Config } from "../config" import { MCP } from "../mcp" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3da2dd6bdb..04801098b4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -21,7 +21,7 @@ import { import { Instance, type InstanceContext } from "../project/instance" import { LSPServer } from "../lsp/server" import { Installation } from "@/installation" -import { ConfigMarkdown } from "./markdown" +import { ConfigMarkdown } from "." import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" @@ -29,7 +29,7 @@ import { Event } from "../server/event" import { Glob } from "@opencode-ai/shared/util/glob" import { Account } from "@/account" import { isRecord } from "@/util/record" -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index 60e39c3163..d878fc99a2 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -1 +1,4 @@ export * as Config from "./config" +export * as ConfigMarkdown from "./markdown" +export * as ConfigPaths from "./paths" +export * as TuiConfig from "./tui" diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 8b5392be5e..7cad692665 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -3,97 +3,95 @@ import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "../util" -export namespace ConfigMarkdown { - export const FILE_REGEX = /(?" || value === "|" || value.startsWith('"') || value.startsWith("'")) { - result.push(line) - continue - } - - // if value contains a colon, convert to block scalar - if (value.includes(":")) { - result.push(`${key}: |-`) - result.push(` ${value}`) - continue - } - - result.push(line) - } - - const processed = result.join("\n") - return content.replace(frontmatter, () => processed) - } - - export async function parse(filePath: string) { - const template = await Filesystem.readText(filePath) - - try { - const md = matter(template) - return md - } catch { - try { - return matter(fallbackSanitization(template)) - } catch (err) { - throw new FrontmatterError( - { - path: filePath, - message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, - }, - { cause: err }, - ) - } - } - } - - export const FrontmatterError = NamedError.create( - "ConfigFrontmatterError", - z.object({ - path: z.string(), - message: z.string(), - }), - ) +export function files(template: string) { + return Array.from(template.matchAll(FILE_REGEX)) } + +export function shell(template: string) { + return Array.from(template.matchAll(SHELL_REGEX)) +} + +// other coding agents like claude code allow invalid yaml in their +// frontmatter, we need to fallback to a more permissive parser for those cases +export function fallbackSanitization(content: string): string { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return content + + const frontmatter = match[1] + const lines = frontmatter.split(/\r?\n/) + const result: string[] = [] + + for (const line of lines) { + // skip comments and empty lines + if (line.trim().startsWith("#") || line.trim() === "") { + result.push(line) + continue + } + + // skip lines that are continuations (indented) + if (line.match(/^\s+/)) { + result.push(line) + continue + } + + // match key: value pattern + const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/) + if (!kvMatch) { + result.push(line) + continue + } + + const key = kvMatch[1] + const value = kvMatch[2].trim() + + // skip if value is empty, already quoted, or uses block scalar + if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) { + result.push(line) + continue + } + + // if value contains a colon, convert to block scalar + if (value.includes(":")) { + result.push(`${key}: |-`) + result.push(` ${value}`) + continue + } + + result.push(line) + } + + const processed = result.join("\n") + return content.replace(frontmatter, () => processed) +} + +export async function parse(filePath: string) { + const template = await Filesystem.readText(filePath) + + try { + const md = matter(template) + return md + } catch { + try { + return matter(fallbackSanitization(template)) + } catch (err) { + throw new FrontmatterError( + { + path: filePath, + message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, + }, + { cause: err }, + ) + } + } +} + +export const FrontmatterError = NamedError.create( + "ConfigFrontmatterError", + z.object({ + path: z.string(), + message: z.string(), + }), +) diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index c5eb105c9d..82dde2df9f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -7,161 +7,159 @@ import { Filesystem } from "@/util" import { Flag } from "@/flag/flag" import { Global } from "@/global" -export namespace ConfigPaths { - export async function projectFiles(name: string, directory: string, worktree: string) { - return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) - } - - export async function directories(directory: string, worktree: string) { - return [ - Global.Path.config, - ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: directory, - stop: worktree, - }), - ) - : []), - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: Global.Path.home, - stop: Global.Path.home, - }), - )), - ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), - ] - } - - export function fileInDirectory(dir: string, name: string) { - return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] - } - - export const JsonError = NamedError.create( - "ConfigJsonError", - z.object({ - path: z.string(), - message: z.string().optional(), - }), - ) - - export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), - ) - - /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ - export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) - } - - type ParseSource = string | { source: string; dir: string } - - function source(input: ParseSource) { - return typeof input === "string" ? input : input.source - } - - function dir(input: ParseSource) { - return typeof input === "string" ? path.dirname(input) : input.dir - } - - /** Apply {env:VAR} and {file:path} substitutions to config text. */ - async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { - text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return process.env[varName] || "" - }) - - const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) - if (!fileMatches.length) return text - - const configDir = dir(input) - const configSource = source(input) - let out = "" - let cursor = 0 - - for (const match of fileMatches) { - const token = match[0] - const index = match.index! - out += text.slice(cursor, index) - - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { - out += token - cursor = index + token.length - continue - } - - let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) - } - - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - const fileContent = ( - await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" - - const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configSource, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) - }) - ).trim() - - out += JSON.stringify(fileContent).slice(1, -1) - cursor = index + token.length - } - - out += text.slice(cursor) - return out - } - - /** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ - export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") { - const configSource = source(input) - text = await substitute(text, input, missing) - - const errors: JsoncParseError[] = [] - const data = parseJsonc(text, errors, { allowTrailingComma: true }) - if (errors.length) { - const lines = text.split("\n") - const errorDetails = errors - .map((e) => { - const beforeOffset = text.substring(0, e.offset).split("\n") - const line = beforeOffset.length - const column = beforeOffset[beforeOffset.length - 1].length + 1 - const problemLine = lines[line - 1] - - const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` - if (!problemLine) return error - - return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` - }) - .join("\n") - - throw new JsonError({ - path: configSource, - message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, - }) - } - - return data - } +export async function projectFiles(name: string, directory: string, worktree: string) { + return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) +} + +export async function directories(directory: string, worktree: string) { + return [ + Global.Path.config, + ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: directory, + stop: worktree, + }), + ) + : []), + ...(await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + }), + )), + ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), + ] +} + +export function fileInDirectory(dir: string, name: string) { + return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] +} + +export const JsonError = NamedError.create( + "ConfigJsonError", + z.object({ + path: z.string(), + message: z.string().optional(), + }), +) + +export const InvalidError = NamedError.create( + "ConfigInvalidError", + z.object({ + path: z.string(), + issues: z.custom().optional(), + message: z.string().optional(), + }), +) + +/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ +export async function readFile(filepath: string) { + return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return + throw new JsonError({ path: filepath }, { cause: err }) + }) +} + +type ParseSource = string | { source: string; dir: string } + +function source(input: ParseSource) { + return typeof input === "string" ? input : input.source +} + +function dir(input: ParseSource) { + return typeof input === "string" ? path.dirname(input) : input.dir +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return process.env[varName] || "" + }) + + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + if (!fileMatches.length) return text + + const configDir = dir(input) + const configSource = source(input) + let out = "" + let cursor = 0 + + for (const match of fileMatches) { + const token = match[0] + const index = match.index! + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + if (filePath.startsWith("~/")) { + filePath = path.join(os.homedir(), filePath.slice(2)) + } + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const fileContent = ( + await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { + if (missing === "empty") return "" + + const errMsg = `bad file reference: "${token}"` + if (error.code === "ENOENT") { + throw new InvalidError( + { + path: configSource, + message: errMsg + ` ${resolvedPath} does not exist`, + }, + { cause: error }, + ) + } + throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) + }) + ).trim() + + out += JSON.stringify(fileContent).slice(1, -1) + cursor = index + token.length + } + + out += text.slice(cursor) + return out +} + +/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ +export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + const configSource = source(input) + text = await substitute(text, input, missing) + + const errors: JsoncParseError[] = [] + const data = parseJsonc(text, errors, { allowTrailingComma: true }) + if (errors.length) { + const lines = text.split("\n") + const errorDetails = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") + + throw new JsonError({ + path: configSource, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, + }) + } + + return data } diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index f9d37e479e..18cee554d5 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -2,7 +2,7 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" import z from "zod" -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import { TuiInfo, TuiOptions } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index c1e2b6e6b4..43f1bce460 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -3,7 +3,7 @@ import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { Config } from "." -import { ConfigPaths } from "./paths" +import { ConfigPaths } from "." import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" import { Flag } from "@/flag/flag" @@ -14,201 +14,199 @@ import { InstanceState } from "@/effect" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -export namespace TuiConfig { - const log = Log.create({ service: "tui.config" }) +const log = Log.create({ service: "tui.config" }) - export const Info = TuiInfo +export const Info = TuiInfo - type Acc = { - result: Info - } +type Acc = { + result: Info +} - type State = { - config: Info - deps: Array> - } +type State = { + config: Info + deps: Array> +} - export type Info = z.output & { - // Internal resolved plugin list used by runtime loading. - plugin_origins?: Config.PluginOrigin[] - } +export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: Config.PluginOrigin[] +} - export interface Interface { - readonly get: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect - } +export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} - export class Service extends Context.Service()("@opencode/TuiConfig") {} +export class Service extends Context.Service()("@opencode/TuiConfig") {} - function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { - if (AppFileSystem.contains(ctx.directory, file)) return "local" - if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" - return "global" - } +function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { + if (AppFileSystem.contains(ctx.directory, file)) return "local" + if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" + return "global" +} - function customPath() { - return Flag.OPENCODE_TUI_CONFIG - } +function customPath() { + return Flag.OPENCODE_TUI_CONFIG +} - function normalize(raw: Record) { - const data = { ...raw } - if (!("tui" in data)) return data - if (!isRecord(data.tui)) { - delete data.tui - return data - } - - const tui = data.tui +function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { delete data.tui - return { - ...tui, - ...data, - } - } - - async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = Config.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins - } - - async function loadState(ctx: { directory: string; worktree: string }) { - let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree) - const custom = customPath() - const managed = Config.managedConfigDir() - await migrateTuiConfig({ directories, custom, managed }) - // Re-compute after migration since migrateTuiConfig may have created new tui.json files - projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - - const acc: Acc = { - result: {}, - } - - for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - await mergeFile(acc, file, ctx) - } - - if (custom) { - await mergeFile(acc, custom, ctx) - log.debug("loaded custom tui config", { path: custom }) - } - - for (const file of projectFiles) { - await mergeFile(acc, file, ctx) - } - - const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) - - for (const dir of dirs) { - if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue - for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - if (existsSync(managed)) { - for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - const keybinds = { ...acc.result.keybinds } - if (process.platform === "win32") { - // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. - keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( - ",", - ) - } - acc.result.keybinds = Config.Keybinds.parse(keybinds) - - return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], - } - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const state = yield* InstanceState.make( - Effect.fn("TuiConfig.state")(function* (ctx) { - const data = yield* Effect.promise(() => loadState(ctx)) - const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), { - concurrency: "unbounded", - }) - return { config: data.config, deps } - }), - ) - - const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config)) - - const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => - InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ), - ) - - return Service.of({ get, waitForDependencies }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get() { - return runPromise((svc) => svc.get()) - } - - export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) - } - - async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) - } - - async function load(text: string, configFilepath: string): Promise { - const raw = await ConfigPaths.parseText(text, configFilepath, "empty") - if (!isRecord(raw)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - const normalized = normalize(raw) - - const parsed = Info.safeParse(normalized) - if (!parsed.success) { - log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) - return {} - } - - const data = parsed.data - if (data.plugin) { - for (let i = 0; i < data.plugin.length; i++) { - data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath) - } - } - return data } + + const tui = data.tui + delete data.tui + return { + ...tui, + ...data, + } +} + +async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) { + const data = await loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = Config.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins +} + +async function loadState(ctx: { directory: string; worktree: string }) { + let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) + const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree) + const custom = customPath() + const managed = Config.managedConfigDir() + await migrateTuiConfig({ directories, custom, managed }) + // Re-compute after migration since migrateTuiConfig may have created new tui.json files + projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) + + const acc: Acc = { + result: {}, + } + + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + await mergeFile(acc, file, ctx) + } + + if (custom) { + await mergeFile(acc, custom, ctx) + log.debug("loaded custom tui config", { path: custom }) + } + + for (const file of projectFiles) { + await mergeFile(acc, file, ctx) + } + + const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) + + for (const dir of dirs) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + await mergeFile(acc, file, ctx) + } + } + + if (existsSync(managed)) { + for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { + await mergeFile(acc, file, ctx) + } + } + + const keybinds = { ...acc.result.keybinds } + if (process.platform === "win32") { + // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. + keybinds.terminal_suspend = "none" + keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( + ",", + ) + } + acc.result.keybinds = Config.Keybinds.parse(keybinds) + + return { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const state = yield* InstanceState.make( + Effect.fn("TuiConfig.state")(function* (ctx) { + const data = yield* Effect.promise(() => loadState(ctx)) + const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), { + concurrency: "unbounded", + }) + return { config: data.config, deps } + }), + ) + + const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + InstanceState.useEffect(state, (s) => + Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), + ), + ) + + return Service.of({ get, waitForDependencies }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function get() { + return runPromise((svc) => svc.get()) +} + +export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) +} + +async function loadFile(filepath: string): Promise { + const text = await ConfigPaths.readFile(filepath) + if (!text) return {} + return load(text, filepath).catch((error) => { + log.warn("failed to load tui config", { path: filepath, error }) + return {} + }) +} + +async function load(text: string, configFilepath: string): Promise { + const raw = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!isRecord(raw)) return {} + + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const normalized = normalize(raw) + + const parsed = Info.safeParse(normalized) + if (!parsed.success) { + log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) + return {} + } + + const data = parsed.data + if (data.plugin) { + for (let i = 0; i < data.plugin.length; i++) { + data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data } diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 0a6256d6f2..8b7e30c40e 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -7,7 +7,7 @@ import { printParseErrorCode, } from "jsonc-parser" -import { ConfigPaths } from "@/config/paths" +import { ConfigPaths } from "@/config" import { Global } from "@/global" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 157533af0a..1d4bb66bc5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -30,7 +30,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as Stream from "effect/Stream" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" -import { ConfigMarkdown } from "../config/markdown" +import { ConfigMarkdown } from "../config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index ef9f661cb5..f8ff7b8f5f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -12,7 +12,7 @@ import { Global } from "@/global" import { Permission } from "@/permission" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Config } from "../config" -import { ConfigMarkdown } from "../config/markdown" +import { ConfigMarkdown } from "../config" import { Glob } from "@opencode-ai/shared/util/glob" import { Log } from "../util" import { Discovery } from "./discovery" diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 748f291728..11865beddd 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index 290a7eea13..bd490ac4f9 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 68c3df4475..7020ac7426 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index f92d742924..25233adaa5 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 8446570cc3..4dc2aeccd4 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { Global } from "../../../src/global" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Filesystem } from "../../../src/util" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 10ddfe8e1c..3f04e3c6fa 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 1c5c7e65e4..7b781c49e8 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -8,7 +8,7 @@ import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/config/tui" +import { TuiConfig } from "../../../src/config" import { Instance } from "../../../src/project/instance" const stop = new Error("stop") diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index 865af21077..b807850c39 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "bun:test" -import { ConfigMarkdown } from "../../src/config/markdown" +import { ConfigMarkdown } from "../../src/config" describe("ConfigMarkdown: normal template", () => { const template = `This is a @valid/path/to/a/file and it should also match at diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index c80905cd1d..62587d2704 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config" -import { TuiConfig } from "../../src/config/tui" +import { TuiConfig } from "../../src/config" import { Global } from "../../src/global" import { Filesystem } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index fdd3b6cfff..493b23f7e8 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,6 +1,6 @@ import { spyOn } from "bun:test" import path from "path" -import { TuiConfig } from "../../src/config/tui" +import { TuiConfig } from "../../src/config" type PluginSpec = string | [string, Record] From f24207844f84d43536b1ac5655e6f3cb80237f9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:30:49 -0400 Subject: [PATCH 54/75] feat: unwrap storage namespaces to flat exports + barrel (#22747) --- packages/opencode/src/account/repo.ts | 2 +- packages/opencode/src/cli/cmd/db.ts | 4 +- packages/opencode/src/cli/cmd/import.ts | 2 +- packages/opencode/src/cli/cmd/stats.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 8 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/index.ts | 4 +- packages/opencode/src/node.ts | 4 +- .../opencode/src/permission/permission.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/server/error.ts | 2 +- packages/opencode/src/server/fence.ts | 2 +- packages/opencode/src/server/instance/pty.ts | 2 +- packages/opencode/src/server/instance/sync.ts | 2 +- packages/opencode/src/server/middleware.ts | 2 +- packages/opencode/src/server/projectors.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/projectors.ts | 2 +- packages/opencode/src/session/revert.ts | 2 +- packages/opencode/src/session/session.ts | 6 +- packages/opencode/src/session/summary.ts | 2 +- packages/opencode/src/session/todo.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/storage/db.ts | 244 +++--- packages/opencode/src/storage/index.ts | 26 + .../opencode/src/storage/json-migration.ts | 802 +++++++++--------- packages/opencode/src/storage/storage.ts | 566 ++++++------ packages/opencode/src/sync/sync-event.ts | 2 +- packages/opencode/src/worktree/worktree.ts | 2 +- packages/opencode/test/account/repo.test.ts | 2 +- .../opencode/test/account/service.test.ts | 2 +- packages/opencode/test/fixture/db.ts | 2 +- packages/opencode/test/preload.ts | 2 +- .../test/project/migrate-global.test.ts | 4 +- .../opencode/test/share/share-next.test.ts | 2 +- packages/opencode/test/storage/db.test.ts | 2 +- .../test/storage/json-migration.test.ts | 2 +- .../opencode/test/storage/storage.test.ts | 2 +- packages/opencode/test/sync/index.test.ts | 2 +- 40 files changed, 874 insertions(+), 854 deletions(-) create mode 100644 packages/opencode/src/storage/index.ts diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index b2b084c08d..5d8a8e33f6 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm" import { Effect, Layer, Option, Schema, Context } from "effect" -import { Database } from "@/storage/db" +import { Database } from "@/storage" import { AccountStateTable, AccountTable } from "./account.sql" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" import { normalizeServerUrl } from "./url" diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index d2a2ca5706..235b59793f 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -1,11 +1,11 @@ import type { Argv } from "yargs" import { spawn } from "child_process" -import { Database } from "../../storage/db" +import { Database } from "../../storage" import { drizzle } from "drizzle-orm/bun-sqlite" import { Database as BunDatabase } from "bun:sqlite" import { UI } from "../ui" import { cmd } from "./cmd" -import { JsonMigration } from "../../storage/json-migration" +import { JsonMigration } from "../../storage" import { EOL } from "os" import { errorMessage } from "../../util/error" diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 38d2376bc5..8da254f159 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -4,7 +4,7 @@ import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" -import { Database } from "../../storage/db" +import { Database } from "../../storage" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { Instance } from "../../project/instance" import { ShareNext } from "../../share" diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index d66ac252fa..34af56ad7a 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -2,7 +2,7 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" -import { Database } from "../../storage/db" +import { Database } from "../../storage" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project" import { Instance } from "../../project/instance" diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f38b27e6f8..b43fe848ba 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,7 +1,7 @@ import z from "zod" import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" -import { Database, asc, eq, inArray } from "@/storage/db" +import { Database, asc, eq, inArray } from "@/storage" import { Project } from "@/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -114,7 +114,7 @@ export namespace Workspace { await adaptor.create(config) - void startSync(info) + startSync(info) await waitEvent({ timeout: TIMEOUT, @@ -294,7 +294,7 @@ export namespace Workspace { ) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - for (const space of spaces) void startSync(space) + for (const space of spaces) startSync(space) return spaces } @@ -307,7 +307,7 @@ export namespace Workspace { export const get = fn(WorkspaceID.zod, async (id) => { const space = lookup(id) if (!space) return - void startSync(space) + startSync(space) return space }) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index bd27df3435..3e28183448 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -12,7 +12,7 @@ import { Ripgrep } from "@/file/ripgrep" import { FileTime } from "@/file/time" import { File } from "@/file" import { FileWatcher } from "@/file/watcher" -import { Storage } from "@/storage/storage" +import { Storage } from "@/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" import { Provider } from "@/provider" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ab3ccb712a..d9f4651fbf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -31,8 +31,8 @@ import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" -import { JsonMigration } from "./storage/json-migration" -import { Database } from "./storage/db" +import { JsonMigration } from "./storage" +import { Database } from "./storage" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index a30783fb21..1cb30d8082 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -2,5 +2,5 @@ export { Config } from "./config" export { Server } from "./server/server" export { bootstrap } from "./cli/bootstrap" export { Log } from "./util" -export { Database } from "./storage/db" -export { JsonMigration } from "./storage/json-migration" +export { Database } from "./storage" +export { JsonMigration } from "./storage" diff --git a/packages/opencode/src/permission/permission.ts b/packages/opencode/src/permission/permission.ts index a8463510c4..fe7fb85455 100644 --- a/packages/opencode/src/permission/permission.ts +++ b/packages/opencode/src/permission/permission.ts @@ -5,7 +5,7 @@ import { InstanceState } from "@/effect" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" -import { Database, eq } from "@/storage/db" +import { Database, eq } from "@/storage" import { zod } from "@/util/effect-zod" import { Log } from "@/util" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 99fe88ff16..050951a606 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,5 +1,5 @@ import z from "zod" -import { and, Database, eq } from "../storage/db" +import { and, Database, eq } from "../storage" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import { Log } from "../util" diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index cc5fa96187..73d28e7350 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -1,6 +1,6 @@ import { resolver } from "hono-openapi" import z from "zod" -import { NotFoundError } from "../storage/db" +import { NotFoundError } from "../storage" export const ERRORS = { 400: { diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index 87771745c8..b461a9dac2 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Database, inArray } from "@/storage/db" +import { Database, inArray } from "@/storage" import { EventSequenceTable } from "@/sync/event.sql" import { Workspace } from "@/control-plane/workspace" import type { WorkspaceID } from "@/control-plane/schema" diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/instance/pty.ts index 3cb8dbfe2e..7943725120 100644 --- a/packages/opencode/src/server/instance/pty.ts +++ b/packages/opencode/src/server/instance/pty.ts @@ -6,7 +6,7 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" -import { NotFoundError } from "../../storage/db" +import { NotFoundError } from "../../storage" import { errors } from "../error" export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/instance/sync.ts index 2513e519ee..633e77f10e 100644 --- a/packages/opencode/src/server/instance/sync.ts +++ b/packages/opencode/src/server/instance/sync.ts @@ -2,7 +2,7 @@ import z from "zod" import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import { SyncEvent } from "@/sync" -import { Database, asc, and, not, or, lte, eq } from "@/storage/db" +import { Database, asc, and, not, or, lte, eq } from "@/storage" import { EventTable } from "@/sync/event.sql" import { lazy } from "@/util/lazy" import { Log } from "@/util" diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index e0958196a5..b67d15f550 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -1,6 +1,6 @@ import { Provider } from "../provider" import { NamedError } from "@opencode-ai/shared/util/error" -import { NotFoundError } from "../storage/db" +import { NotFoundError } from "../storage" import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" import type { ErrorHandler, MiddlewareHandler } from "hono" diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index eb85a8017f..cfecce5265 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -3,7 +3,7 @@ import sessionProjectors from "../session/projectors" import { SyncEvent } from "@/sync" import { Session } from "@/session" import { SessionTable } from "@/session/session.sql" -import { Database, eq } from "@/storage/db" +import { Database, eq } from "@/storage" export function initProjectors() { SyncEvent.init({ diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 72b9963215..5ad80b6b02 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -11,7 +11,7 @@ import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config" -import { NotFoundError } from "@/storage/db" +import { NotFoundError } from "@/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f4a7235e15..5dcf0dcd1c 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -6,7 +6,7 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { SyncEvent } from "../sync" -import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" +import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage" import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 1e092b07e0..9a36ef5b3b 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,4 +1,4 @@ -import { NotFoundError, eq, and } from "../storage/db" +import { NotFoundError, eq, and } from "../storage" import { SyncEvent } from "@/sync" import { Session } from "." import { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 383fe08e87..93d0e6219c 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -2,7 +2,7 @@ import z from "zod" import { Effect, Layer, Context } from "effect" import { Bus } from "../bus" import { Snapshot } from "../snapshot" -import { Storage } from "@/storage/storage" +import { Storage } from "@/storage" import { SyncEvent } from "../sync" import { Log } from "../util" import { Session } from "." diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a4bf446a1a..9ebddf8dee 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -8,12 +8,12 @@ import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "../flag/flag" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage" import { SyncEvent } from "../sync" -import type { SQL } from "../storage/db" +import type { SQL } from "../storage" import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" -import { Storage } from "@/storage/storage" +import { Storage } from "@/storage" import { Log } from "../util" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 2c973c5df7..21203c326b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,7 +2,7 @@ import z from "zod" import { Effect, Layer, Context } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" -import { Storage } from "@/storage/storage" +import { Storage } from "@/storage" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 1fd9cbaa5a..eec2bb3a30 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -3,7 +3,7 @@ import { Bus } from "@/bus" import { SessionID } from "./schema" import { Effect, Layer, Context } from "effect" import z from "zod" -import { Database, eq, asc } from "../storage/db" +import { Database, eq, asc } from "../storage" import { TodoTable } from "./session.sql" export namespace Todo { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index a7656e840c..1991e75ff6 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -9,7 +9,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" -import { Database, eq } from "@/storage/db" +import { Database, eq } from "@/storage" import { Config } from "@/config" import { Log } from "@/util" import { SessionShareTable } from "./share.sql" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 7acd458dcd..1b6b2d9b37 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -27,148 +27,146 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) -export namespace Database { - export function getChannelPath() { - if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) - return path.join(Global.Path.data, "opencode.db") - const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") - return path.join(Global.Path.data, `opencode-${safe}.db`) +export function getChannelPath() { + if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + return path.join(Global.Path.data, "opencode.db") + const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") + return path.join(Global.Path.data, `opencode-${safe}.db`) +} + +export const Path = iife(() => { + if (Flag.OPENCODE_DB) { + if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB + return path.join(Global.Path.data, Flag.OPENCODE_DB) } + return getChannelPath() +}) - export const Path = iife(() => { - if (Flag.OPENCODE_DB) { - if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB - return path.join(Global.Path.data, Flag.OPENCODE_DB) - } - return getChannelPath() - }) +export type Transaction = SQLiteTransaction<"sync", void> - export type Transaction = SQLiteTransaction<"sync", void> +type Client = SQLiteBunDatabase - type Client = SQLiteBunDatabase +type Journal = { sql: string; timestamp: number; name: string }[] - type Journal = { sql: string; timestamp: number; name: string }[] +function time(tag: string) { + const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) + if (!match) return 0 + return Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ) +} - function time(tag: string) { - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) - if (!match) return 0 - return Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) - } +function migrations(dir: string): Journal { + const dirs = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) - function migrations(dir: string): Journal { - const dirs = readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - - const sql = dirs - .map((name) => { - const file = path.join(dir, name, "migration.sql") - if (!existsSync(file)) return - return { - sql: readFileSync(file, "utf-8"), - timestamp: time(name), - name, - } - }) - .filter(Boolean) as Journal - - return sql.sort((a, b) => a.timestamp - b.timestamp) - } - - export const Client = lazy(() => { - log.info("opening database", { path: Path }) - - const db = init(Path) - - db.run("PRAGMA journal_mode = WAL") - db.run("PRAGMA synchronous = NORMAL") - db.run("PRAGMA busy_timeout = 5000") - db.run("PRAGMA cache_size = -64000") - db.run("PRAGMA foreign_keys = ON") - db.run("PRAGMA wal_checkpoint(PASSIVE)") - - // Apply schema migrations - const entries = - typeof OPENCODE_MIGRATIONS !== "undefined" - ? OPENCODE_MIGRATIONS - : migrations(path.join(import.meta.dirname, "../../migration")) - if (entries.length > 0) { - log.info("applying migrations", { - count: entries.length, - mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", - }) - if (Flag.OPENCODE_SKIP_MIGRATIONS) { - for (const item of entries) { - item.sql = "select 1;" - } + const sql = dirs + .map((name) => { + const file = path.join(dir, name, "migration.sql") + if (!existsSync(file)) return + return { + sql: readFileSync(file, "utf-8"), + timestamp: time(name), + name, } - migrate(db, entries) - } + }) + .filter(Boolean) as Journal - return db - }) + return sql.sort((a, b) => a.timestamp - b.timestamp) +} - export function close() { - Client().$client.close() - Client.reset() - } +export const Client = lazy(() => { + log.info("opening database", { path: Path }) - export type TxOrDb = Transaction | Client + const db = init(Path) - const ctx = LocalContext.create<{ - tx: TxOrDb - effects: (() => void | Promise)[] - }>("database") + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = NORMAL") + db.run("PRAGMA busy_timeout = 5000") + db.run("PRAGMA cache_size = -64000") + db.run("PRAGMA foreign_keys = ON") + db.run("PRAGMA wal_checkpoint(PASSIVE)") - export function use(callback: (trx: TxOrDb) => T): T { - try { - return callback(ctx.use().tx) - } catch (err) { - if (err instanceof LocalContext.NotFound) { - const effects: (() => void | Promise)[] = [] - const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) - for (const effect of effects) void effect() - return result + // Apply schema migrations + const entries = + typeof OPENCODE_MIGRATIONS !== "undefined" + ? OPENCODE_MIGRATIONS + : migrations(path.join(import.meta.dirname, "../../migration")) + if (entries.length > 0) { + log.info("applying migrations", { + count: entries.length, + mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", + }) + if (Flag.OPENCODE_SKIP_MIGRATIONS) { + for (const item of entries) { + item.sql = "select 1;" } - throw err } + migrate(db, entries) } - export function effect(fn: () => any | Promise) { - const bound = InstanceState.bind(fn) - try { - ctx.use().effects.push(bound) - } catch { - void bound() - } - } + return db +}) - type NotPromise = T extends Promise ? never : T +export function close() { + Client().$client.close() + Client.reset() +} - export function transaction( - callback: (tx: TxOrDb) => NotPromise, - options?: { - behavior?: "deferred" | "immediate" | "exclusive" - }, - ): NotPromise { - try { - return callback(ctx.use().tx) - } catch (err) { - if (err instanceof LocalContext.NotFound) { - const effects: (() => void | Promise)[] = [] - const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) - const result = Client().transaction(txCallback, { behavior: options?.behavior }) - for (const effect of effects) void effect() - return result as NotPromise - } - throw err +export type TxOrDb = Transaction | Client + +const ctx = LocalContext.create<{ + tx: TxOrDb + effects: (() => void | Promise)[] +}>("database") + +export function use(callback: (trx: TxOrDb) => T): T { + try { + return callback(ctx.use().tx) + } catch (err) { + if (err instanceof LocalContext.NotFound) { + const effects: (() => void | Promise)[] = [] + const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) + for (const effect of effects) effect() + return result } + throw err + } +} + +export function effect(fn: () => any | Promise) { + const bound = InstanceState.bind(fn) + try { + ctx.use().effects.push(bound) + } catch { + bound() + } +} + +type NotPromise = T extends Promise ? never : T + +export function transaction( + callback: (tx: TxOrDb) => NotPromise, + options?: { + behavior?: "deferred" | "immediate" | "exclusive" + }, +): NotPromise { + try { + return callback(ctx.use().tx) + } catch (err) { + if (err instanceof LocalContext.NotFound) { + const effects: (() => void | Promise)[] = [] + const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) + const result = Client().transaction(txCallback, { behavior: options?.behavior }) + for (const effect of effects) effect() + return result as NotPromise + } + throw err } } diff --git a/packages/opencode/src/storage/index.ts b/packages/opencode/src/storage/index.ts new file mode 100644 index 0000000000..212c9eecfd --- /dev/null +++ b/packages/opencode/src/storage/index.ts @@ -0,0 +1,26 @@ +export * as JsonMigration from "./json-migration" +export * as Database from "./db" +export * as Storage from "./storage" +export { + asc, + eq, + and, + or, + inArray, + desc, + not, + sql, + isNull, + isNotNull, + count, + like, + exists, + between, + gt, + gte, + lt, + lte, + ne, +} from "drizzle-orm" +export type { SQL } from "drizzle-orm" +export { NotFoundError } from "./storage" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 4bf75f5a1c..4803d452fe 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -10,47 +10,24 @@ import { existsSync } from "fs" import { Filesystem } from "../util" import { Glob } from "@opencode-ai/shared/util/glob" -export namespace JsonMigration { - const log = Log.create({ service: "json-migration" }) +const log = Log.create({ service: "json-migration" }) - export type Progress = { - current: number - total: number - label: string - } +export type Progress = { + current: number + total: number + label: string +} - type Options = { - progress?: (event: Progress) => void - } +type Options = { + progress?: (event: Progress) => void +} - export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase, options?: Options) { - const storageDir = path.join(Global.Path.data, "storage") +export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase, options?: Options) { + const storageDir = path.join(Global.Path.data, "storage") - if (!existsSync(storageDir)) { - log.info("storage directory does not exist, skipping migration") - return { - projects: 0, - sessions: 0, - messages: 0, - parts: 0, - todos: 0, - permissions: 0, - shares: 0, - errors: [] as string[], - } - } - - log.info("starting json to sqlite migration", { storageDir }) - const start = performance.now() - - // const db = drizzle({ client: sqlite }) - - // Optimize SQLite for bulk inserts - db.run("PRAGMA journal_mode = WAL") - db.run("PRAGMA synchronous = OFF") - db.run("PRAGMA cache_size = 10000") - db.run("PRAGMA temp_store = MEMORY") - const stats = { + if (!existsSync(storageDir)) { + log.info("storage directory does not exist, skipping migration") + return { projects: 0, sessions: 0, messages: 0, @@ -60,370 +37,391 @@ export namespace JsonMigration { shares: 0, errors: [] as string[], } - const orphans = { - sessions: 0, - todos: 0, - permissions: 0, - shares: 0, - } - const errs = stats.errors - - const batchSize = 1000 - const now = Date.now() - - async function list(pattern: string) { - return Glob.scan(pattern, { cwd: storageDir, absolute: true }) - } - - async function read(files: string[], start: number, end: number) { - const count = end - start - // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill - const tasks = new Array(count) - for (let i = 0; i < count; i++) { - tasks[i] = Filesystem.readJson(files[start + i]) - } - const results = await Promise.allSettled(tasks) - // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill - const items = new Array(count) - for (let i = 0; i < results.length; i++) { - const result = results[i] - if (result.status === "fulfilled") { - items[i] = result.value - continue - } - errs.push(`failed to read ${files[start + i]}: ${result.reason}`) - } - return items - } - - function insert(values: any[], table: any, label: string) { - if (values.length === 0) return 0 - try { - db.insert(table).values(values).onConflictDoNothing().run() - return values.length - } catch (e) { - errs.push(`failed to migrate ${label} batch: ${e}`) - return 0 - } - } - - // Pre-scan all files upfront to avoid repeated glob operations - log.info("scanning files...") - const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([ - list("project/*.json"), - list("session/*/*.json"), - list("message/*/*.json"), - list("part/*/*.json"), - list("todo/*.json"), - list("permission/*.json"), - list("session_share/*.json"), - ]) - - log.info("file scan complete", { - projects: projectFiles.length, - sessions: sessionFiles.length, - messages: messageFiles.length, - parts: partFiles.length, - todos: todoFiles.length, - permissions: permFiles.length, - shares: shareFiles.length, - }) - - const total = Math.max( - 1, - projectFiles.length + - sessionFiles.length + - messageFiles.length + - partFiles.length + - todoFiles.length + - permFiles.length + - shareFiles.length, - ) - const progress = options?.progress - let current = 0 - const step = (label: string, count: number) => { - current = Math.min(total, current + count) - progress?.({ current, total, label }) - } - - progress?.({ current, total, label: "starting" }) - - db.run("BEGIN TRANSACTION") - - // Migrate projects first (no FK deps) - // Derive all IDs from file paths, not JSON content - const projectIds = new Set() - const projectValues = [] as any[] - for (let i = 0; i < projectFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, projectFiles.length) - const batch = await read(projectFiles, i, end) - projectValues.length = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const id = path.basename(projectFiles[i + j], ".json") - projectIds.add(id) - projectValues.push({ - id, - worktree: data.worktree ?? "/", - vcs: data.vcs, - name: data.name ?? undefined, - icon_url: data.icon?.url, - icon_color: data.icon?.color, - time_created: data.time?.created ?? now, - time_updated: data.time?.updated ?? now, - time_initialized: data.time?.initialized, - sandboxes: data.sandboxes ?? [], - commands: data.commands, - }) - } - stats.projects += insert(projectValues, ProjectTable, "project") - step("projects", end - i) - } - log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) }) - - // Migrate sessions (depends on projects) - // Derive all IDs from directory/file paths, not JSON content, since earlier - // migrations may have moved sessions to new directories without updating the JSON - const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file))) - const sessionIds = new Set() - const sessionValues = [] as any[] - for (let i = 0; i < sessionFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, sessionFiles.length) - const batch = await read(sessionFiles, i, end) - sessionValues.length = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const id = path.basename(sessionFiles[i + j], ".json") - const projectID = sessionProjects[i + j] - if (!projectIds.has(projectID)) { - orphans.sessions++ - continue - } - sessionIds.add(id) - sessionValues.push({ - id, - project_id: projectID, - parent_id: data.parentID ?? null, - slug: data.slug ?? "", - directory: data.directory ?? "", - title: data.title ?? "", - version: data.version ?? "", - share_url: data.share?.url ?? null, - summary_additions: data.summary?.additions ?? null, - summary_deletions: data.summary?.deletions ?? null, - summary_files: data.summary?.files ?? null, - summary_diffs: data.summary?.diffs ?? null, - revert: data.revert ?? null, - permission: data.permission ?? null, - time_created: data.time?.created ?? now, - time_updated: data.time?.updated ?? now, - time_compacting: data.time?.compacting ?? null, - time_archived: data.time?.archived ?? null, - }) - } - stats.sessions += insert(sessionValues, SessionTable, "session") - step("sessions", end - i) - } - log.info("migrated sessions", { count: stats.sessions }) - if (orphans.sessions > 0) { - log.warn("skipped orphaned sessions", { count: orphans.sessions }) - } - - // Migrate messages using pre-scanned file map - const allMessageFiles = [] as string[] - const allMessageSessions = [] as string[] - const messageSessions = new Map() - for (const file of messageFiles) { - const sessionID = path.basename(path.dirname(file)) - if (!sessionIds.has(sessionID)) continue - allMessageFiles.push(file) - allMessageSessions.push(sessionID) - } - - for (let i = 0; i < allMessageFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, allMessageFiles.length) - const batch = await read(allMessageFiles, i, end) - // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill - const values = new Array(batch.length) - let count = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const file = allMessageFiles[i + j] - const id = path.basename(file, ".json") - const sessionID = allMessageSessions[i + j] - messageSessions.set(id, sessionID) - const rest = data - delete rest.id - delete rest.sessionID - values[count++] = { - id, - session_id: sessionID, - time_created: data.time?.created ?? now, - time_updated: data.time?.updated ?? now, - data: rest, - } - } - values.length = count - stats.messages += insert(values, MessageTable, "message") - step("messages", end - i) - } - log.info("migrated messages", { count: stats.messages }) - - // Migrate parts using pre-scanned file map - for (let i = 0; i < partFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, partFiles.length) - const batch = await read(partFiles, i, end) - // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill - const values = new Array(batch.length) - let count = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const file = partFiles[i + j] - const id = path.basename(file, ".json") - const messageID = path.basename(path.dirname(file)) - const sessionID = messageSessions.get(messageID) - if (!sessionID) { - errs.push(`part missing message session: ${file}`) - continue - } - if (!sessionIds.has(sessionID)) continue - const rest = data - delete rest.id - delete rest.messageID - delete rest.sessionID - values[count++] = { - id, - message_id: messageID, - session_id: sessionID, - time_created: data.time?.created ?? now, - time_updated: data.time?.updated ?? now, - data: rest, - } - } - values.length = count - stats.parts += insert(values, PartTable, "part") - step("parts", end - i) - } - log.info("migrated parts", { count: stats.parts }) - - // Migrate todos - const todoSessions = todoFiles.map((file) => path.basename(file, ".json")) - for (let i = 0; i < todoFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, todoFiles.length) - const batch = await read(todoFiles, i, end) - const values = [] as any[] - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const sessionID = todoSessions[i + j] - if (!sessionIds.has(sessionID)) { - orphans.todos++ - continue - } - if (!Array.isArray(data)) { - errs.push(`todo not an array: ${todoFiles[i + j]}`) - continue - } - for (let position = 0; position < data.length; position++) { - const todo = data[position] - if (!todo?.content || !todo?.status || !todo?.priority) continue - values.push({ - session_id: sessionID, - content: todo.content, - status: todo.status, - priority: todo.priority, - position, - time_created: now, - time_updated: now, - }) - } - } - stats.todos += insert(values, TodoTable, "todo") - step("todos", end - i) - } - log.info("migrated todos", { count: stats.todos }) - if (orphans.todos > 0) { - log.warn("skipped orphaned todos", { count: orphans.todos }) - } - - // Migrate permissions - const permProjects = permFiles.map((file) => path.basename(file, ".json")) - const permValues = [] as any[] - for (let i = 0; i < permFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, permFiles.length) - const batch = await read(permFiles, i, end) - permValues.length = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const projectID = permProjects[i + j] - if (!projectIds.has(projectID)) { - orphans.permissions++ - continue - } - permValues.push({ project_id: projectID, data }) - } - stats.permissions += insert(permValues, PermissionTable, "permission") - step("permissions", end - i) - } - log.info("migrated permissions", { count: stats.permissions }) - if (orphans.permissions > 0) { - log.warn("skipped orphaned permissions", { count: orphans.permissions }) - } - - // Migrate session shares - const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) - const shareValues = [] as any[] - for (let i = 0; i < shareFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, shareFiles.length) - const batch = await read(shareFiles, i, end) - shareValues.length = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const sessionID = shareSessions[i + j] - if (!sessionIds.has(sessionID)) { - orphans.shares++ - continue - } - if (!data?.id || !data?.secret || !data?.url) { - errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`) - continue - } - shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url }) - } - stats.shares += insert(shareValues, SessionShareTable, "session_share") - step("shares", end - i) - } - log.info("migrated session shares", { count: stats.shares }) - if (orphans.shares > 0) { - log.warn("skipped orphaned session shares", { count: orphans.shares }) - } - - db.run("COMMIT") - - log.info("json migration complete", { - projects: stats.projects, - sessions: stats.sessions, - messages: stats.messages, - parts: stats.parts, - todos: stats.todos, - permissions: stats.permissions, - shares: stats.shares, - errorCount: stats.errors.length, - duration: Math.round(performance.now() - start), - }) - - if (stats.errors.length > 0) { - log.warn("migration errors", { errors: stats.errors.slice(0, 20) }) - } - - progress?.({ current: total, total, label: "complete" }) - - return stats } + + log.info("starting json to sqlite migration", { storageDir }) + const start = performance.now() + + // const db = drizzle({ client: sqlite }) + + // Optimize SQLite for bulk inserts + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = OFF") + db.run("PRAGMA cache_size = 10000") + db.run("PRAGMA temp_store = MEMORY") + const stats = { + projects: 0, + sessions: 0, + messages: 0, + parts: 0, + todos: 0, + permissions: 0, + shares: 0, + errors: [] as string[], + } + const orphans = { + sessions: 0, + todos: 0, + permissions: 0, + shares: 0, + } + const errs = stats.errors + + const batchSize = 1000 + const now = Date.now() + + async function list(pattern: string) { + return Glob.scan(pattern, { cwd: storageDir, absolute: true }) + } + + async function read(files: string[], start: number, end: number) { + const count = end - start + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill + const tasks = new Array(count) + for (let i = 0; i < count; i++) { + tasks[i] = Filesystem.readJson(files[start + i]) + } + const results = await Promise.allSettled(tasks) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill + const items = new Array(count) + for (let i = 0; i < results.length; i++) { + const result = results[i] + if (result.status === "fulfilled") { + items[i] = result.value + continue + } + errs.push(`failed to read ${files[start + i]}: ${result.reason}`) + } + return items + } + + function insert(values: any[], table: any, label: string) { + if (values.length === 0) return 0 + try { + db.insert(table).values(values).onConflictDoNothing().run() + return values.length + } catch (e) { + errs.push(`failed to migrate ${label} batch: ${e}`) + return 0 + } + } + + // Pre-scan all files upfront to avoid repeated glob operations + log.info("scanning files...") + const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([ + list("project/*.json"), + list("session/*/*.json"), + list("message/*/*.json"), + list("part/*/*.json"), + list("todo/*.json"), + list("permission/*.json"), + list("session_share/*.json"), + ]) + + log.info("file scan complete", { + projects: projectFiles.length, + sessions: sessionFiles.length, + messages: messageFiles.length, + parts: partFiles.length, + todos: todoFiles.length, + permissions: permFiles.length, + shares: shareFiles.length, + }) + + const total = Math.max( + 1, + projectFiles.length + + sessionFiles.length + + messageFiles.length + + partFiles.length + + todoFiles.length + + permFiles.length + + shareFiles.length, + ) + const progress = options?.progress + let current = 0 + const step = (label: string, count: number) => { + current = Math.min(total, current + count) + progress?.({ current, total, label }) + } + + progress?.({ current, total, label: "starting" }) + + db.run("BEGIN TRANSACTION") + + // Migrate projects first (no FK deps) + // Derive all IDs from file paths, not JSON content + const projectIds = new Set() + const projectValues = [] as any[] + for (let i = 0; i < projectFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, projectFiles.length) + const batch = await read(projectFiles, i, end) + projectValues.length = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const id = path.basename(projectFiles[i + j], ".json") + projectIds.add(id) + projectValues.push({ + id, + worktree: data.worktree ?? "/", + vcs: data.vcs, + name: data.name ?? undefined, + icon_url: data.icon?.url, + icon_color: data.icon?.color, + time_created: data.time?.created ?? now, + time_updated: data.time?.updated ?? now, + time_initialized: data.time?.initialized, + sandboxes: data.sandboxes ?? [], + commands: data.commands, + }) + } + stats.projects += insert(projectValues, ProjectTable, "project") + step("projects", end - i) + } + log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) }) + + // Migrate sessions (depends on projects) + // Derive all IDs from directory/file paths, not JSON content, since earlier + // migrations may have moved sessions to new directories without updating the JSON + const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file))) + const sessionIds = new Set() + const sessionValues = [] as any[] + for (let i = 0; i < sessionFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, sessionFiles.length) + const batch = await read(sessionFiles, i, end) + sessionValues.length = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const id = path.basename(sessionFiles[i + j], ".json") + const projectID = sessionProjects[i + j] + if (!projectIds.has(projectID)) { + orphans.sessions++ + continue + } + sessionIds.add(id) + sessionValues.push({ + id, + project_id: projectID, + parent_id: data.parentID ?? null, + slug: data.slug ?? "", + directory: data.directory ?? "", + title: data.title ?? "", + version: data.version ?? "", + share_url: data.share?.url ?? null, + summary_additions: data.summary?.additions ?? null, + summary_deletions: data.summary?.deletions ?? null, + summary_files: data.summary?.files ?? null, + summary_diffs: data.summary?.diffs ?? null, + revert: data.revert ?? null, + permission: data.permission ?? null, + time_created: data.time?.created ?? now, + time_updated: data.time?.updated ?? now, + time_compacting: data.time?.compacting ?? null, + time_archived: data.time?.archived ?? null, + }) + } + stats.sessions += insert(sessionValues, SessionTable, "session") + step("sessions", end - i) + } + log.info("migrated sessions", { count: stats.sessions }) + if (orphans.sessions > 0) { + log.warn("skipped orphaned sessions", { count: orphans.sessions }) + } + + // Migrate messages using pre-scanned file map + const allMessageFiles = [] as string[] + const allMessageSessions = [] as string[] + const messageSessions = new Map() + for (const file of messageFiles) { + const sessionID = path.basename(path.dirname(file)) + if (!sessionIds.has(sessionID)) continue + allMessageFiles.push(file) + allMessageSessions.push(sessionID) + } + + for (let i = 0; i < allMessageFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, allMessageFiles.length) + const batch = await read(allMessageFiles, i, end) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill + const values = new Array(batch.length) + let count = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const file = allMessageFiles[i + j] + const id = path.basename(file, ".json") + const sessionID = allMessageSessions[i + j] + messageSessions.set(id, sessionID) + const rest = data + delete rest.id + delete rest.sessionID + values[count++] = { + id, + session_id: sessionID, + time_created: data.time?.created ?? now, + time_updated: data.time?.updated ?? now, + data: rest, + } + } + values.length = count + stats.messages += insert(values, MessageTable, "message") + step("messages", end - i) + } + log.info("migrated messages", { count: stats.messages }) + + // Migrate parts using pre-scanned file map + for (let i = 0; i < partFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, partFiles.length) + const batch = await read(partFiles, i, end) + // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill + const values = new Array(batch.length) + let count = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const file = partFiles[i + j] + const id = path.basename(file, ".json") + const messageID = path.basename(path.dirname(file)) + const sessionID = messageSessions.get(messageID) + if (!sessionID) { + errs.push(`part missing message session: ${file}`) + continue + } + if (!sessionIds.has(sessionID)) continue + const rest = data + delete rest.id + delete rest.messageID + delete rest.sessionID + values[count++] = { + id, + message_id: messageID, + session_id: sessionID, + time_created: data.time?.created ?? now, + time_updated: data.time?.updated ?? now, + data: rest, + } + } + values.length = count + stats.parts += insert(values, PartTable, "part") + step("parts", end - i) + } + log.info("migrated parts", { count: stats.parts }) + + // Migrate todos + const todoSessions = todoFiles.map((file) => path.basename(file, ".json")) + for (let i = 0; i < todoFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, todoFiles.length) + const batch = await read(todoFiles, i, end) + const values = [] as any[] + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const sessionID = todoSessions[i + j] + if (!sessionIds.has(sessionID)) { + orphans.todos++ + continue + } + if (!Array.isArray(data)) { + errs.push(`todo not an array: ${todoFiles[i + j]}`) + continue + } + for (let position = 0; position < data.length; position++) { + const todo = data[position] + if (!todo?.content || !todo?.status || !todo?.priority) continue + values.push({ + session_id: sessionID, + content: todo.content, + status: todo.status, + priority: todo.priority, + position, + time_created: now, + time_updated: now, + }) + } + } + stats.todos += insert(values, TodoTable, "todo") + step("todos", end - i) + } + log.info("migrated todos", { count: stats.todos }) + if (orphans.todos > 0) { + log.warn("skipped orphaned todos", { count: orphans.todos }) + } + + // Migrate permissions + const permProjects = permFiles.map((file) => path.basename(file, ".json")) + const permValues = [] as any[] + for (let i = 0; i < permFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, permFiles.length) + const batch = await read(permFiles, i, end) + permValues.length = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const projectID = permProjects[i + j] + if (!projectIds.has(projectID)) { + orphans.permissions++ + continue + } + permValues.push({ project_id: projectID, data }) + } + stats.permissions += insert(permValues, PermissionTable, "permission") + step("permissions", end - i) + } + log.info("migrated permissions", { count: stats.permissions }) + if (orphans.permissions > 0) { + log.warn("skipped orphaned permissions", { count: orphans.permissions }) + } + + // Migrate session shares + const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) + const shareValues = [] as any[] + for (let i = 0; i < shareFiles.length; i += batchSize) { + const end = Math.min(i + batchSize, shareFiles.length) + const batch = await read(shareFiles, i, end) + shareValues.length = 0 + for (let j = 0; j < batch.length; j++) { + const data = batch[j] + if (!data) continue + const sessionID = shareSessions[i + j] + if (!sessionIds.has(sessionID)) { + orphans.shares++ + continue + } + if (!data?.id || !data?.secret || !data?.url) { + errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`) + continue + } + shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url }) + } + stats.shares += insert(shareValues, SessionShareTable, "session_share") + step("shares", end - i) + } + log.info("migrated session shares", { count: stats.shares }) + if (orphans.shares > 0) { + log.warn("skipped orphaned session shares", { count: orphans.shares }) + } + + db.run("COMMIT") + + log.info("json migration complete", { + projects: stats.projects, + sessions: stats.sessions, + messages: stats.messages, + parts: stats.parts, + todos: stats.todos, + permissions: stats.permissions, + shares: stats.shares, + errorCount: stats.errors.length, + duration: Math.round(performance.now() - start), + }) + + if (stats.errors.length > 0) { + log.warn("migration errors", { errors: stats.errors.slice(0, 20) }) + } + + progress?.({ current: total, total, label: "complete" }) + + return stats } diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index f4793c6204..b1685e689b 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -7,327 +7,325 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { Git } from "@/git" -export namespace Storage { - const log = Log.create({ service: "storage" }) +const log = Log.create({ service: "storage" }) - type Migration = ( - dir: string, - fs: AppFileSystem.Interface, - git: Git.Interface, - ) => Effect.Effect +type Migration = ( + dir: string, + fs: AppFileSystem.Interface, + git: Git.Interface, +) => Effect.Effect - export const NotFoundError = NamedError.create( - "NotFoundError", - z.object({ - message: z.string(), +export const NotFoundError = NamedError.create( + "NotFoundError", + z.object({ + message: z.string(), + }), +) + +export type Error = AppFileSystem.Error | InstanceType + +const RootFile = Schema.Struct({ + path: Schema.optional( + Schema.Struct({ + root: Schema.optional(Schema.String), }), - ) + ), +}) - export type Error = AppFileSystem.Error | InstanceType +const SessionFile = Schema.Struct({ + id: Schema.String, +}) - const RootFile = Schema.Struct({ - path: Schema.optional( - Schema.Struct({ - root: Schema.optional(Schema.String), - }), - ), - }) +const MessageFile = Schema.Struct({ + id: Schema.String, +}) - const SessionFile = Schema.Struct({ - id: Schema.String, - }) +const DiffFile = Schema.Struct({ + additions: Schema.Number, + deletions: Schema.Number, +}) - const MessageFile = Schema.Struct({ - id: Schema.String, - }) +const SummaryFile = Schema.Struct({ + id: Schema.String, + projectID: Schema.String, + summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }), +}) - const DiffFile = Schema.Struct({ - additions: Schema.Number, - deletions: Schema.Number, - }) +const decodeRoot = Schema.decodeUnknownOption(RootFile) +const decodeSession = Schema.decodeUnknownOption(SessionFile) +const decodeMessage = Schema.decodeUnknownOption(MessageFile) +const decodeSummary = Schema.decodeUnknownOption(SummaryFile) - const SummaryFile = Schema.Struct({ - id: Schema.String, - projectID: Schema.String, - summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }), - }) +export interface Interface { + readonly remove: (key: string[]) => Effect.Effect + readonly read: (key: string[]) => Effect.Effect + readonly update: (key: string[], fn: (draft: T) => void) => Effect.Effect + readonly write: (key: string[], content: T) => Effect.Effect + readonly list: (prefix: string[]) => Effect.Effect +} - const decodeRoot = Schema.decodeUnknownOption(RootFile) - const decodeSession = Schema.decodeUnknownOption(SessionFile) - const decodeMessage = Schema.decodeUnknownOption(MessageFile) - const decodeSummary = Schema.decodeUnknownOption(SummaryFile) +export class Service extends Context.Service()("@opencode/Storage") {} - export interface Interface { - readonly remove: (key: string[]) => Effect.Effect - readonly read: (key: string[]) => Effect.Effect - readonly update: (key: string[], fn: (draft: T) => void) => Effect.Effect - readonly write: (key: string[], content: T) => Effect.Effect - readonly list: (prefix: string[]) => Effect.Effect +function file(dir: string, key: string[]) { + return path.join(dir, ...key) + ".json" +} + +function missing(err: unknown) { + if (!err || typeof err !== "object") return false + if ("code" in err && err.code === "ENOENT") return true + if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) { + return err.reason._tag === "NotFound" } + return false +} - export class Service extends Context.Service()("@opencode/Storage") {} +function parseMigration(text: string) { + const value = Number.parseInt(text, 10) + return Number.isNaN(value) ? 0 : value +} - function file(dir: string, key: string[]) { - return path.join(dir, ...key) + ".json" - } +const MIGRATIONS: Migration[] = [ + Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) { + const project = path.resolve(dir, "../project") + if (!(yield* fs.isDir(project))) return + const projectDirs = yield* fs.glob("*", { + cwd: project, + include: "all", + }) + for (const projectDir of projectDirs) { + const full = path.join(project, projectDir) + if (!(yield* fs.isDir(full))) continue + log.info(`migrating project ${projectDir}`) + let projectID = projectDir + let worktree = "/" - function missing(err: unknown) { - if (!err || typeof err !== "object") return false - if ("code" in err && err.code === "ENOENT") return true - if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) { - return err.reason._tag === "NotFound" - } - return false - } - - function parseMigration(text: string) { - const value = Number.parseInt(text, 10) - return Number.isNaN(value) ? 0 : value - } - - const MIGRATIONS: Migration[] = [ - Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) { - const project = path.resolve(dir, "../project") - if (!(yield* fs.isDir(project))) return - const projectDirs = yield* fs.glob("*", { - cwd: project, - include: "all", - }) - for (const projectDir of projectDirs) { - const full = path.join(project, projectDir) - if (!(yield* fs.isDir(full))) continue - log.info(`migrating project ${projectDir}`) - let projectID = projectDir - let worktree = "/" - - if (projectID !== "global") { - for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", { - cwd: full, - absolute: true, - })) { - const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" }) - const root = Option.isSome(json) ? json.value.path?.root : undefined - if (!root) continue - worktree = root - break - } - if (!worktree) continue - if (!(yield* fs.isDir(worktree))) continue - const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { - cwd: worktree, - }) - const [id] = result - .text() - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted() - if (!id) continue - projectID = id - - yield* fs.writeWithDirs( - path.join(dir, "project", projectID + ".json"), - JSON.stringify( - { - id, - vcs: "git", - worktree, - time: { - created: Date.now(), - initialized: Date.now(), - }, - }, - null, - 2, - ), - ) - - log.info(`migrating sessions for project ${projectID}`) - for (const sessionFile of yield* fs.glob("storage/session/info/*.json", { - cwd: full, - absolute: true, - })) { - const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) - log.info("copying", { sessionFile, dest }) - const session = yield* fs.readJson(sessionFile) - const info = decodeSession(session, { onExcessProperty: "preserve" }) - yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2)) - if (Option.isNone(info)) continue - log.info(`migrating messages for session ${info.value.id}`) - for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, { - cwd: full, - absolute: true, - })) { - const next = path.join(dir, "message", info.value.id, path.basename(msgFile)) - log.info("copying", { - msgFile, - dest: next, - }) - const message = yield* fs.readJson(msgFile) - const item = decodeMessage(message, { onExcessProperty: "preserve" }) - yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2)) - if (Option.isNone(item)) continue - - log.info(`migrating parts for message ${item.value.id}`) - for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, { - cwd: full, - absolute: true, - })) { - const out = path.join(dir, "part", item.value.id, path.basename(partFile)) - const part = yield* fs.readJson(partFile) - log.info("copying", { - partFile, - dest: out, - }) - yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2)) - } - } - } + if (projectID !== "global") { + for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", { + cwd: full, + absolute: true, + })) { + const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" }) + const root = Option.isSome(json) ? json.value.path?.root : undefined + if (!root) continue + worktree = root + break } - } - }), - Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) { - for (const item of yield* fs.glob("session/*/*.json", { - cwd: dir, - absolute: true, - })) { - const raw = yield* fs.readJson(item) - const session = decodeSummary(raw, { onExcessProperty: "preserve" }) - if (Option.isNone(session)) continue - const diffs = session.value.summary.diffs + if (!worktree) continue + if (!(yield* fs.isDir(worktree))) continue + const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) + const [id] = result + .text() + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + if (!id) continue + projectID = id + yield* fs.writeWithDirs( - path.join(dir, "session_diff", session.value.id + ".json"), - JSON.stringify(diffs, null, 2), - ) - yield* fs.writeWithDirs( - path.join(dir, "session", session.value.projectID, session.value.id + ".json"), + path.join(dir, "project", projectID + ".json"), JSON.stringify( { - ...(raw as Record), - summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + id, + vcs: "git", + worktree, + time: { + created: Date.now(), + initialized: Date.now(), }, }, null, 2, ), ) - } - }), - ] - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const git = yield* Git.Service - const locks = yield* RcMap.make({ - lookup: () => TxReentrantLock.make(), - idleTimeToLive: 0, - }) - const state = yield* Effect.cached( - Effect.gen(function* () { - const dir = path.join(Global.Path.data, "storage") - const marker = path.join(dir, "migration") - const migration = yield* fs.readFileString(marker).pipe( - Effect.map(parseMigration), - Effect.catchIf(missing, () => Effect.succeed(0)), - Effect.orElseSucceed(() => 0), - ) - for (let i = migration; i < MIGRATIONS.length; i++) { - log.info("running migration", { index: i }) - const step = MIGRATIONS[i]! - const exit = yield* Effect.exit(step(dir, fs, git)) - if (Exit.isFailure(exit)) { - log.error("failed to run migration", { index: i, cause: exit.cause }) - break + log.info(`migrating sessions for project ${projectID}`) + for (const sessionFile of yield* fs.glob("storage/session/info/*.json", { + cwd: full, + absolute: true, + })) { + const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) + log.info("copying", { sessionFile, dest }) + const session = yield* fs.readJson(sessionFile) + const info = decodeSession(session, { onExcessProperty: "preserve" }) + yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2)) + if (Option.isNone(info)) continue + log.info(`migrating messages for session ${info.value.id}`) + for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, { + cwd: full, + absolute: true, + })) { + const next = path.join(dir, "message", info.value.id, path.basename(msgFile)) + log.info("copying", { + msgFile, + dest: next, + }) + const message = yield* fs.readJson(msgFile) + const item = decodeMessage(message, { onExcessProperty: "preserve" }) + yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2)) + if (Option.isNone(item)) continue + + log.info(`migrating parts for message ${item.value.id}`) + for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, { + cwd: full, + absolute: true, + })) { + const out = path.join(dir, "part", item.value.id, path.basename(partFile)) + const part = yield* fs.readJson(partFile) + log.info("copying", { + partFile, + dest: out, + }) + yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2)) } - yield* fs.writeWithDirs(marker, String(i + 1)) } - return { dir } + } + } + } + }), + Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) { + for (const item of yield* fs.glob("session/*/*.json", { + cwd: dir, + absolute: true, + })) { + const raw = yield* fs.readJson(item) + const session = decodeSummary(raw, { onExcessProperty: "preserve" }) + if (Option.isNone(session)) continue + const diffs = session.value.summary.diffs + yield* fs.writeWithDirs( + path.join(dir, "session_diff", session.value.id + ".json"), + JSON.stringify(diffs, null, 2), + ) + yield* fs.writeWithDirs( + path.join(dir, "session", session.value.projectID, session.value.id + ".json"), + JSON.stringify( + { + ...(raw as Record), + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + }, + }, + null, + 2, + ), + ) + } + }), +] + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const locks = yield* RcMap.make({ + lookup: () => TxReentrantLock.make(), + idleTimeToLive: 0, + }) + const state = yield* Effect.cached( + Effect.gen(function* () { + const dir = path.join(Global.Path.data, "storage") + const marker = path.join(dir, "migration") + const migration = yield* fs.readFileString(marker).pipe( + Effect.map(parseMigration), + Effect.catchIf(missing, () => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), + ) + for (let i = migration; i < MIGRATIONS.length; i++) { + log.info("running migration", { index: i }) + const step = MIGRATIONS[i]! + const exit = yield* Effect.exit(step(dir, fs, git)) + if (Exit.isFailure(exit)) { + log.error("failed to run migration", { index: i, cause: exit.cause }) + break + } + yield* fs.writeWithDirs(marker, String(i + 1)) + } + return { dir } + }), + ) + + const fail = (target: string): Effect.Effect> => + Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) + + const wrap = (target: string, body: Effect.Effect) => + body.pipe(Effect.catchIf(missing, () => fail(target))) + + const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) { + yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2)) + }) + + const withResolved = ( + key: string[], + fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const target = file((yield* state).dir, key) + return yield* fn(target, yield* RcMap.get(locks, target)) }), ) - const fail = (target: string): Effect.Effect> => - Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) + const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) { + yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))), + ) + }) - const wrap = (target: string, body: Effect.Effect) => - body.pipe(Effect.catchIf(missing, () => fail(target))) - - const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) { - yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2)) - }) - - const withResolved = ( - key: string[], - fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, - ): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const target = file((yield* state).dir, key) - return yield* fn(target, yield* RcMap.get(locks, target)) - }), + const read: Interface["read"] = (key: string[]) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))), ) + return value as T + }) - const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) { - yield* withResolved(key, (target, rw) => - TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))), + const update: Interface["update"] = (key: string[], fn: (draft: T) => void) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock( + rw, + Effect.gen(function* () { + const content = yield* wrap(target, fs.readJson(target)) + fn(content as T) + yield* writeJson(target, content) + return content + }), + ), ) + return value as T }) - const read: Interface["read"] = (key: string[]) => - Effect.gen(function* () { - const value = yield* withResolved(key, (target, rw) => - TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))), - ) - return value as T - }) - - const update: Interface["update"] = (key: string[], fn: (draft: T) => void) => - Effect.gen(function* () { - const value = yield* withResolved(key, (target, rw) => - TxReentrantLock.withWriteLock( - rw, - Effect.gen(function* () { - const content = yield* wrap(target, fs.readJson(target)) - fn(content as T) - yield* writeJson(target, content) - return content - }), - ), - ) - return value as T - }) - - const write: Interface["write"] = (key: string[], content: unknown) => - Effect.gen(function* () { - yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content))) - }) - - const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) { - const dir = (yield* state).dir - const cwd = path.join(dir, ...prefix) - const result = yield* fs - .glob("**/*", { - cwd, - include: "file", - }) - .pipe(Effect.catch(() => Effect.succeed([]))) - return result - .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]) - .toSorted((a, b) => a.join("/").localeCompare(b.join("/"))) + const write: Interface["write"] = (key: string[], content: unknown) => + Effect.gen(function* () { + yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content))) }) - return Service.of({ - remove, - read, - update, - write, - list, - }) - }), - ) + const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) { + const dir = (yield* state).dir + const cwd = path.join(dir, ...prefix) + const result = yield* fs + .glob("**/*", { + cwd, + include: "file", + }) + .pipe(Effect.catch(() => Effect.succeed([]))) + return result + .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]) + .toSorted((a, b) => a.join("/").localeCompare(b.join("/"))) + }) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) -} + return Service.of({ + remove, + read, + update, + write, + list, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) diff --git a/packages/opencode/src/sync/sync-event.ts b/packages/opencode/src/sync/sync-event.ts index d4ad860409..db487ddd24 100644 --- a/packages/opencode/src/sync/sync-event.ts +++ b/packages/opencode/src/sync/sync-event.ts @@ -1,6 +1,6 @@ import z from "zod" import type { ZodObject } from "zod" -import { Database, eq } from "@/storage/db" +import { Database, eq } from "@/storage" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts index 8eea6445aa..d4fab2030b 100644 --- a/packages/opencode/src/worktree/worktree.ts +++ b/packages/opencode/src/worktree/worktree.ts @@ -4,7 +4,7 @@ import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project" -import { Database, eq } from "../storage/db" +import { Database, eq } from "../storage" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import { Log } from "../util" diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 2f17d1b22f..93d0481521 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Option } from "effect" import { AccountRepo } from "../../src/account/repo" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" -import { Database } from "../../src/storage/db" +import { Database } from "../../src/storage" import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 28592a0988..053fd2a0ed 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -15,7 +15,7 @@ import { RefreshToken, UserCode, } from "../../src/account/schema" -import { Database } from "../../src/storage/db" +import { Database } from "../../src/storage" import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index f11f0b9036..581739a6f9 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,6 +1,6 @@ import { rm } from "fs/promises" import { Instance } from "../../src/project/instance" -import { Database } from "../../src/storage/db" +import { Database } from "../../src/storage" export async function resetDatabase() { await Instance.disposeAll().catch(() => undefined) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 7c6f04c796..a2592286ad 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -10,7 +10,7 @@ import { afterAll } from "bun:test" const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(async () => { - const { Database } = await import("../src/storage/db") + const { Database } = await import("../src/storage") Database.close() const busy = (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index a63ac1cd98..8c9982afb8 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Project } from "../../src/project" -import { Database, eq } from "../../src/storage/db" +import { Database, eq } from "../../src/storage" import { SessionTable } from "../../src/session/session.sql" import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" @@ -10,7 +10,7 @@ import { $ } from "bun" import { tmpdir } from "../fixture/fixture" import { Effect } from "effect" -void Log.init({ print: false }) +Log.init({ print: false }) function run(fn: (svc: Project.Interface) => Effect.Effect) { return Effect.runPromise( diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index ac3f7b79e0..2359f06a31 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -14,7 +14,7 @@ import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "../../src/share" import { SessionShareTable } from "../../src/share/share.sql" -import { Database, eq } from "../../src/storage/db" +import { Database, eq } from "../../src/storage" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index f6b6055595..7edc862c4c 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Global } from "../../src/global" import { Installation } from "../../src/installation" -import { Database } from "../../src/storage/db" +import { Database } from "../../src/storage" describe("Database.Path", () => { test("returns database path for the current channel", () => { diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index e76401ae75..019faf061c 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -5,7 +5,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator" import path from "path" import fs from "fs/promises" import { readFileSync, readdirSync } from "fs" -import { JsonMigration } from "../../src/storage/json-migration" +import { JsonMigration } from "../../src/storage" import { Global } from "../../src/global" import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index 60b458bb30..c35244bb7a 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Git } from "../../src/git" import { Global } from "../../src/global" -import { Storage } from "../../src/storage/storage" +import { Storage } from "../../src/storage" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 5304f4ea8b..2ba716cac0 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -4,7 +4,7 @@ import z from "zod" import { Bus } from "../../src/bus" import { Instance } from "../../src/project/instance" import { SyncEvent } from "../../src/sync" -import { Database } from "../../src/storage/db" +import { Database } from "../../src/storage" import { EventTable } from "../../src/sync/event.sql" import { Identifier } from "../../src/id/id" import { Flag } from "../../src/flag/flag" From 509bc11f81430575c58887960a02e63fa0107c03 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:30:52 -0400 Subject: [PATCH 55/75] feat: unwrap lsp namespaces to flat exports + barrel (#22748) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/lsp/client.ts | 408 +- packages/opencode/src/lsp/index.ts | 540 +-- packages/opencode/src/lsp/lsp.ts | 535 +++ packages/opencode/src/lsp/server.ts | 3618 +++++++++--------- packages/opencode/test/lsp/client.test.ts | 4 +- packages/opencode/test/lsp/index.test.ts | 2 +- packages/opencode/test/lsp/lifecycle.test.ts | 2 +- 8 files changed, 2554 insertions(+), 2557 deletions(-) create mode 100644 packages/opencode/src/lsp/lsp.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 04801098b4..d8cfd5e48f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,7 +19,7 @@ import { printParseErrorCode, } from "jsonc-parser" import { Instance, type InstanceContext } from "../project/instance" -import { LSPServer } from "../lsp/server" +import { LSPServer } from "../lsp" import { Installation } from "@/installation" import { ConfigMarkdown } from "." import { existsSync } from "fs" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 27301e79a7..fed2bf5c99 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -8,7 +8,7 @@ import { Log } from "../util" import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" -import type { LSPServer } from "./server" +import type { LSPServer } from "." import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" @@ -16,237 +16,235 @@ import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 -export namespace LSPClient { - const log = Log.create({ service: "lsp.client" }) +const log = Log.create({ service: "lsp.client" }) - export type Info = NonNullable>> +export type Info = NonNullable>> - export type Diagnostic = VSCodeDiagnostic +export type Diagnostic = VSCodeDiagnostic - export const InitializeError = NamedError.create( - "LSPInitializeError", +export const InitializeError = NamedError.create( + "LSPInitializeError", + z.object({ + serverID: z.string(), + }), +) + +export const Event = { + Diagnostics: BusEvent.define( + "lsp.client.diagnostics", z.object({ serverID: z.string(), + path: z.string(), }), + ), +} + +export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + const l = log.clone().tag("serverID", input.serverID) + l.info("starting client") + + const connection = createMessageConnection( + new StreamMessageReader(input.server.process.stdout as any), + new StreamMessageWriter(input.server.process.stdin as any), ) - export const Event = { - Diagnostics: BusEvent.define( - "lsp.client.diagnostics", - z.object({ - serverID: z.string(), - path: z.string(), - }), - ), + const diagnostics = new Map() + connection.onNotification("textDocument/publishDiagnostics", (params) => { + const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) + l.info("textDocument/publishDiagnostics", { + path: filePath, + count: params.diagnostics.length, + }) + const exists = diagnostics.has(filePath) + diagnostics.set(filePath, params.diagnostics) + if (!exists && input.serverID === "typescript") return + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + }) + connection.onRequest("window/workDoneProgress/create", (params) => { + l.info("window/workDoneProgress/create", params) + return null + }) + connection.onRequest("workspace/configuration", async () => { + // Return server initialization options + return [input.server.initialization ?? {}] + }) + connection.onRequest("client/registerCapability", async () => {}) + connection.onRequest("client/unregisterCapability", async () => {}) + connection.onRequest("workspace/workspaceFolders", async () => [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ]) + connection.listen() + + l.info("sending initialize") + await withTimeout( + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ], + initializationOptions: { + ...input.server.initialization, + }, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, + }, + }, + }, + }), + 45_000, + ).catch((err) => { + l.error("initialize error", { error: err }) + throw new InitializeError( + { serverID: input.serverID }, + { + cause: err, + }, + ) + }) + + await connection.sendNotification("initialized", {}) + + if (input.server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: input.server.initialization, + }) } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") + const files: { + [path: string]: number + } = {} - const connection = createMessageConnection( - new StreamMessageReader(input.server.process.stdout as any), - new StreamMessageWriter(input.server.process.stdin as any), - ) - - const diagnostics = new Map() - connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) - l.info("textDocument/publishDiagnostics", { - path: filePath, - count: params.diagnostics.length, - }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - void Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) - }) - connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) - return null - }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] - }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) - connection.onRequest("workspace/workspaceFolders", async () => [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ]) - connection.listen() - - l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, - }, - publishDiagnostics: { - versionSupport: true, - }, - }, - }, - }), - 45_000, - ).catch((err) => { - l.error("initialize error", { error: err }) - throw new InitializeError( - { serverID: input.serverID }, - { - cause: err, - }, - ) - }) - - await connection.sendNotification("initialized", {}) - - if (input.server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: input.server.initialization, - }) - } - - const files: { - [path: string]: number - } = {} - - const result = { - root: input.root, - get serverID() { - return input.serverID - }, - get connection() { - return connection - }, - notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) - const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - - const version = files[input.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 2, // Changed - }, - ], - }) - - const next = version + 1 - files[input.path] = next - log.info("textDocument/didChange", { - path: input.path, - version: next, - }) - await connection.sendNotification("textDocument/didChange", { - textDocument: { - uri: pathToFileURL(input.path).href, - version: next, - }, - contentChanges: [{ text }], - }) - return - } + const result = { + root: input.root, + get serverID() { + return input.serverID + }, + get connection() { + return connection + }, + notify: { + async open(input: { path: string }) { + input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + const text = await Filesystem.readText(input.path) + const extension = path.extname(input.path) + const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" + const version = files[input.path] + if (version !== undefined) { log.info("workspace/didChangeWatchedFiles", input) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(input.path).href, - type: 1, // Created + type: 2, // Changed }, ], }) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) - await connection.sendNotification("textDocument/didOpen", { + const next = version + 1 + files[input.path] = next + log.info("textDocument/didChange", { + path: input.path, + version: next, + }) + await connection.sendNotification("textDocument/didChange", { textDocument: { uri: pathToFileURL(input.path).href, - languageId, - version: 0, - text, + version: next, }, + contentChanges: [{ text }], }) - files[input.path] = 0 return - }, + } + + log.info("workspace/didChangeWatchedFiles", input) + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(input.path).href, + type: 1, // Created + }, + ], + }) + + log.info("textDocument/didOpen", input) + diagnostics.delete(input.path) + await connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(input.path).href, + languageId, + version: 0, + text, + }, + }) + files[input.path] = 0 + return }, - get diagnostics() { - return diagnostics - }, - async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), - ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() + }, + get diagnostics() { + return diagnostics + }, + async waitForDiagnostics(input: { path: string }) { + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + ) + log.info("waiting for diagnostics", { path: normalizedPath }) + let unsub: () => void + let debounceTimer: ReturnType | undefined + return await withTimeout( + new Promise((resolve) => { + unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { + // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + log.info("got diagnostics", { path: normalizedPath }) + unsub?.() + resolve() + }, DIAGNOSTICS_DEBOUNCE_MS) + } }) - }, - async shutdown() { - l.info("shutting down") - connection.end() - connection.dispose() - await Process.stop(input.server.process) - l.info("shutdown") - }, - } - - l.info("initialized") - - return result + }), + 3000, + ) + .catch(() => {}) + .finally(() => { + if (debounceTimer) clearTimeout(debounceTimer) + unsub?.() + }) + }, + async shutdown() { + l.info("shutting down") + connection.end() + connection.dispose() + await Process.stop(input.server.process) + l.info("shutdown") + }, } + + l.info("initialized") + + return result } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 5146c40abe..9fc06fa21b 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -1,537 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Log } from "../util" -import { LSPClient } from "./client" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" -import { LSPServer } from "./server" -import z from "zod" -import { Config } from "../config" -import { Instance } from "../project/instance" -import { Flag } from "@/flag/flag" -import { Process } from "../util" -import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" -import { InstanceState } from "@/effect" - -export namespace LSP { - const log = Log.create({ service: "lsp" }) - - export const Event = { - Updated: BusEvent.define("lsp.updated", z.object({})), - } - - export const Range = z - .object({ - start: z.object({ - line: z.number(), - character: z.number(), - }), - end: z.object({ - line: z.number(), - character: z.number(), - }), - }) - .meta({ - ref: "Range", - }) - export type Range = z.infer - - export const Symbol = z - .object({ - name: z.string(), - kind: z.number(), - location: z.object({ - uri: z.string(), - range: Range, - }), - }) - .meta({ - ref: "Symbol", - }) - export type Symbol = z.infer - - export const DocumentSymbol = z - .object({ - name: z.string(), - detail: z.string().optional(), - kind: z.number(), - range: Range, - selectionRange: Range, - }) - .meta({ - ref: "DocumentSymbol", - }) - export type DocumentSymbol = z.infer - - export const Status = z - .object({ - id: z.string(), - name: z.string(), - root: z.string(), - status: z.union([z.literal("connected"), z.literal("error")]), - }) - .meta({ - ref: "LSPStatus", - }) - export type Status = z.infer - - enum SymbolKind { - File = 1, - Module = 2, - Namespace = 3, - Package = 4, - Class = 5, - Method = 6, - Property = 7, - Field = 8, - Constructor = 9, - Enum = 10, - Interface = 11, - Function = 12, - Variable = 13, - Constant = 14, - String = 15, - Number = 16, - Boolean = 17, - Array = 18, - Object = 19, - Key = 20, - Null = 21, - EnumMember = 22, - Struct = 23, - Event = 24, - Operator = 25, - TypeParameter = 26, - } - - const kinds = [ - SymbolKind.Class, - SymbolKind.Function, - SymbolKind.Method, - SymbolKind.Interface, - SymbolKind.Variable, - SymbolKind.Constant, - SymbolKind.Struct, - SymbolKind.Enum, - ] - - const filterExperimentalServers = (servers: Record) => { - if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { - if (servers["pyright"]) { - log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled") - delete servers["pyright"] - } - } else { - if (servers["ty"]) { - delete servers["ty"] - } - } - } - - type LocInput = { file: string; line: number; character: number } - - interface State { - clients: LSPClient.Info[] - servers: Record - broken: Set - spawning: Map> - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly hasClients: (file: string) => Effect.Effect - readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect - readonly diagnostics: () => Effect.Effect> - readonly hover: (input: LocInput) => Effect.Effect - readonly definition: (input: LocInput) => Effect.Effect - readonly references: (input: LocInput) => Effect.Effect - readonly implementation: (input: LocInput) => Effect.Effect - readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]> - readonly workspaceSymbol: (query: string) => Effect.Effect - readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect - readonly incomingCalls: (input: LocInput) => Effect.Effect - readonly outgoingCalls: (input: LocInput) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/LSP") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("LSP.state")(function* () { - const cfg = yield* config.get() - - const servers: Record = {} - - if (cfg.lsp === false) { - log.info("all LSPs are disabled") - } else { - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } - - filterExperimentalServers(servers) - - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => Instance.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { ...process.env, ...item.env }, - }), - initialization: item.initialization, - }), - } - } - - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), - }) - } - - const s: State = { - clients: [], - servers, - broken: new Set(), - spawning: new Map(), - } - - yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await Promise.all(s.clients.map((client) => client.shutdown())) - }), - ) - - return s - }), - ) - - const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) return [] as LSPClient.Info[] - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] - - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { - s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id, root }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch(async (err) => { - s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) - - if (!client) return undefined - - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } - - s.clients.push(client) - return client - } - - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } - - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } - - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) - - void task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) - - const client = await task - if (!client) continue - - result.push(client) - void Bus.publish(Event.Updated, {}) - } - - return result - }) - }) - - const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { - const clients = yield* getClients(file) - return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) - }) - - const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { - const s = yield* InstanceState.get(state) - return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) - }) - - const init = Effect.fn("LSP.init")(function* () { - yield* InstanceState.get(state) - }) - - const status = Effect.fn("LSP.status")(function* () { - const s = yield* InstanceState.get(state) - const result: Status[] = [] - for (const client of s.clients) { - result.push({ - id: client.serverID, - name: s.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), - status: "connected", - }) - } - return result - }) - - const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false - }) - }) - - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { - log.info("touching file", { file: input }) - const clients = yield* getClients(input) - yield* Effect.promise(() => - Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait - }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }), - ) - }) - - const diagnostics = Effect.fn("LSP.diagnostics")(function* () { - const results: Record = {} - const all = yield* runAll(async (client) => client.diagnostics) - for (const result of all) { - for (const [p, diags] of result.entries()) { - const arr = results[p] || [] - arr.push(...diags) - results[p] = arr - } - } - return results - }) - - const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { - return yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/hover", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - }) - - const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const references = Effect.fn("LSP.references")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - context: { includeDeclaration: true }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { - const file = fileURLToPath(uri) - const results = yield* run(file, (client) => - client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), - ) - return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean) - }) - - const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { - const results = yield* runAll((client) => - client.connection - .sendRequest("workspace/symbol", { query }) - .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) - .catch(() => []), - ) - return results.flat() as LSP.Symbol[] - }) - - const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const callHierarchyRequest = Effect.fnUntraced(function* ( - input: LocInput, - direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", - ) { - const results = yield* run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) - }) - return results.flat().filter(Boolean) - }) - - const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") - }) - - const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") - }) - - return Service.of({ - init, - status, - hasClients, - touchFile, - diagnostics, - hover, - definition, - references, - implementation, - documentSymbol, - workspaceSymbol, - prepareCallHierarchy, - incomingCalls, - outgoingCalls, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - - export namespace Diagnostic { - const MAX_PER_FILE = 20 - - export function pretty(diagnostic: LSPClient.Diagnostic) { - const severityMap = { - 1: "ERROR", - 2: "WARN", - 3: "INFO", - 4: "HINT", - } - - const severity = severityMap[diagnostic.severity || 1] - const line = diagnostic.range.start.line + 1 - const col = diagnostic.range.start.character + 1 - - return `${severity} [${line}:${col}] ${diagnostic.message}` - } - - export function report(file: string, issues: LSPClient.Diagnostic[]) { - const errors = issues.filter((item) => item.severity === 1) - if (errors.length === 0) return "" - const limited = errors.slice(0, MAX_PER_FILE) - const more = errors.length - MAX_PER_FILE - const suffix = more > 0 ? `\n... and ${more} more` : "" - return `\n${limited.map(pretty).join("\n")}${suffix}\n` - } - } -} +export * as LSP from "./lsp" +export * as LSPClient from "./client" +export * as LSPServer from "./server" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts new file mode 100644 index 0000000000..7f5b36313d --- /dev/null +++ b/packages/opencode/src/lsp/lsp.ts @@ -0,0 +1,535 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Log } from "../util" +import { LSPClient } from "." +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" +import { LSPServer } from "." +import z from "zod" +import { Config } from "../config" +import { Instance } from "../project/instance" +import { Flag } from "@/flag/flag" +import { Process } from "../util" +import { spawn as lspspawn } from "./launch" +import { Effect, Layer, Context } from "effect" +import { InstanceState } from "@/effect" + +const log = Log.create({ service: "lsp" }) + +export const Event = { + Updated: BusEvent.define("lsp.updated", z.object({})), +} + +export const Range = z + .object({ + start: z.object({ + line: z.number(), + character: z.number(), + }), + end: z.object({ + line: z.number(), + character: z.number(), + }), + }) + .meta({ + ref: "Range", + }) +export type Range = z.infer + +export const Symbol = z + .object({ + name: z.string(), + kind: z.number(), + location: z.object({ + uri: z.string(), + range: Range, + }), + }) + .meta({ + ref: "Symbol", + }) +export type Symbol = z.infer + +export const DocumentSymbol = z + .object({ + name: z.string(), + detail: z.string().optional(), + kind: z.number(), + range: Range, + selectionRange: Range, + }) + .meta({ + ref: "DocumentSymbol", + }) +export type DocumentSymbol = z.infer + +export const Status = z + .object({ + id: z.string(), + name: z.string(), + root: z.string(), + status: z.union([z.literal("connected"), z.literal("error")]), + }) + .meta({ + ref: "LSPStatus", + }) +export type Status = z.infer + +enum SymbolKind { + File = 1, + Module = 2, + Namespace = 3, + Package = 4, + Class = 5, + Method = 6, + Property = 7, + Field = 8, + Constructor = 9, + Enum = 10, + Interface = 11, + Function = 12, + Variable = 13, + Constant = 14, + String = 15, + Number = 16, + Boolean = 17, + Array = 18, + Object = 19, + Key = 20, + Null = 21, + EnumMember = 22, + Struct = 23, + Event = 24, + Operator = 25, + TypeParameter = 26, +} + +const kinds = [ + SymbolKind.Class, + SymbolKind.Function, + SymbolKind.Method, + SymbolKind.Interface, + SymbolKind.Variable, + SymbolKind.Constant, + SymbolKind.Struct, + SymbolKind.Enum, +] + +const filterExperimentalServers = (servers: Record) => { + if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { + if (servers["pyright"]) { + log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled") + delete servers["pyright"] + } + } else { + if (servers["ty"]) { + delete servers["ty"] + } + } +} + +type LocInput = { file: string; line: number; character: number } + +interface State { + clients: LSPClient.Info[] + servers: Record + broken: Set + spawning: Map> +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly hasClients: (file: string) => Effect.Effect + readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly diagnostics: () => Effect.Effect> + readonly hover: (input: LocInput) => Effect.Effect + readonly definition: (input: LocInput) => Effect.Effect + readonly references: (input: LocInput) => Effect.Effect + readonly implementation: (input: LocInput) => Effect.Effect + readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]> + readonly workspaceSymbol: (query: string) => Effect.Effect + readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect + readonly incomingCalls: (input: LocInput) => Effect.Effect + readonly outgoingCalls: (input: LocInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LSP") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("LSP.state")(function* () { + const cfg = yield* config.get() + + const servers: Record = {} + + if (cfg.lsp === false) { + log.info("all LSPs are disabled") + } else { + for (const server of Object.values(LSPServer)) { + servers[server.id] = server + } + + filterExperimentalServers(servers) + + for (const [name, item] of Object.entries(cfg.lsp ?? {})) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async () => Instance.directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, + }), + } + } + + log.info("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), + }) + } + + const s: State = { + clients: [], + servers, + broken: new Set(), + spawning: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all(s.clients.map((client) => client.shutdown())) + }), + ) + + return s + }), + ) + + const getClients = Effect.fnUntraced(function* (file: string) { + if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] + + async function schedule(server: LSPServer.Info, root: string, key: string) { + const handle = await server + .spawn(root) + .then((value) => { + if (!value) s.broken.add(key) + return value + }) + .catch((err) => { + s.broken.add(key) + log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) + return undefined + }) + + if (!handle) return undefined + log.info("spawned lsp server", { serverID: server.id, root }) + + const client = await LSPClient.create({ + serverID: server.id, + server: handle, + root, + }).catch(async (err) => { + s.broken.add(key) + await Process.stop(handle.process) + log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) + return undefined + }) + + if (!client) return undefined + + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + await Process.stop(handle.process) + return existing + } + + s.clients.push(client) + return client + } + + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + + const root = await server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue + } + + const inflight = s.spawning.get(root + server.id) + if (inflight) { + const client = await inflight + if (!client) continue + result.push(client) + continue + } + + const task = schedule(server, root, root + server.id) + s.spawning.set(root + server.id, task) + + task.finally(() => { + if (s.spawning.get(root + server.id) === task) { + s.spawning.delete(root + server.id) + } + }) + + const client = await task + if (!client) continue + + result.push(client) + Bus.publish(Event.Updated, {}) + } + + return result + }) + }) + + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const clients = yield* getClients(file) + return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + }) + + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + }) + + const init = Effect.fn("LSP.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("LSP.status")(function* () { + const s = yield* InstanceState.get(state) + const result: Status[] = [] + for (const client of s.clients) { + result.push({ + id: client.serverID, + name: s.servers[client.serverID].id, + root: path.relative(Instance.directory, client.root), + status: "connected", + }) + } + return result + }) + + const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = await server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false + }) + }) + + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + log.info("touching file", { file: input }) + const clients = yield* getClients(input) + yield* Effect.promise(() => + Promise.all( + clients.map(async (client) => { + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() + await client.notify.open({ path: input }) + return wait + }), + ).catch((err) => { + log.error("failed to touch file", { err, file: input }) + }), + ) + }) + + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + const results: Record = {} + const all = yield* runAll(async (client) => client.diagnostics) + for (const result of all) { + for (const [p, diags] of result.entries()) { + const arr = results[p] || [] + arr.push(...diags) + results[p] = arr + } + } + return results + }) + + const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { + return yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/hover", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + }) + + const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const references = Effect.fn("LSP.references")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: { includeDeclaration: true }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/implementation", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + const file = fileURLToPath(uri) + const results = yield* run(file, (client) => + client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), + ) + return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) + }) + + const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + const results = yield* runAll((client) => + client.connection + .sendRequest("workspace/symbol", { query }) + .then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind))) + .then((result: any) => result.slice(0, 10)) + .catch(() => []), + ) + return results.flat() as Symbol[] + }) + + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const callHierarchyRequest = Effect.fnUntraced(function* ( + input: LocInput, + direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", + ) { + const results = yield* run(input.file, async (client) => { + const items = (await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [])) as any[] + if (!items?.length) return [] + return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) + }) + return results.flat().filter(Boolean) + }) + + const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") + }) + + const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") + }) + + return Service.of({ + init, + status, + hasClients, + touchFile, + diagnostics, + hover, + definition, + references, + implementation, + documentSymbol, + workspaceSymbol, + prepareCallHierarchy, + incomingCalls, + outgoingCalls, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export namespace Diagnostic { + const MAX_PER_FILE = 20 + + export function pretty(diagnostic: LSPClient.Diagnostic) { + const severityMap = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", + } + + const severity = severityMap[diagnostic.severity || 1] + const line = diagnostic.range.start.line + 1 + const col = diagnostic.range.start.character + 1 + + return `${severity} [${line}:${col}] ${diagnostic.message}` + } + + export function report(file: string, issues: LSPClient.Diagnostic[]) { + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) return "" + const limited = errors.slice(0, MAX_PER_FILE) + const more = errors.length - MAX_PER_FILE + const suffix = more > 0 ? `\n... and ${more} more` : "" + return `\n${limited.map(pretty).join("\n")}${suffix}\n` + } +} diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 8110e86082..25aaaa36a4 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -15,972 +15,673 @@ import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" import { Npm } from "../npm" -export namespace LSPServer { - const log = Log.create({ service: "lsp.server" }) - const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .catch(() => false) - const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) - const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) +const log = Log.create({ service: "lsp.server" }) +const pathExists = async (p: string) => + fs + .stat(p) + .then(() => true) + .catch(() => false) +const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) +const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) - export interface Handle { - process: ChildProcessWithoutNullStreams - initialization?: Record - } +export interface Handle { + process: ChildProcessWithoutNullStreams + initialization?: Record +} - type RootFunction = (file: string) => Promise +type RootFunction = (file: string) => Promise - const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { - return async (file) => { - if (excludePatterns) { - const excludedFiles = Filesystem.up({ - targets: excludePatterns, - start: path.dirname(file), - stop: Instance.directory, - }) - const excluded = await excludedFiles.next() - await excludedFiles.return() - if (excluded.value) return undefined - } - const files = Filesystem.up({ - targets: includePatterns, +const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { + return async (file) => { + if (excludePatterns) { + const excludedFiles = Filesystem.up({ + targets: excludePatterns, start: path.dirname(file), stop: Instance.directory, }) - const first = await files.next() - await files.return() - if (!first.value) return Instance.directory - return path.dirname(first.value) + const excluded = await excludedFiles.next() + await excludedFiles.return() + if (excluded.value) return undefined } + const files = Filesystem.up({ + targets: includePatterns, + start: path.dirname(file), + stop: Instance.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return Instance.directory + return path.dirname(first.value) } +} - export interface Info { - id: string - extensions: string[] - global?: boolean - root: RootFunction - spawn(root: string): Promise - } +export interface Info { + id: string + extensions: string[] + global?: boolean + root: RootFunction + spawn(root: string): Promise +} - export const Deno: Info = { - id: "deno", - root: async (file) => { - const files = Filesystem.up({ - targets: ["deno.json", "deno.jsonc"], - start: path.dirname(file), - stop: Instance.directory, - }) - const first = await files.next() - await files.return() - if (!first.value) return undefined - return path.dirname(first.value) - }, - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], - async spawn(root) { - const deno = which("deno") - if (!deno) { - log.info("deno not found, please install deno first") - return - } - return { - process: spawn(deno, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Typescript: Info = { - id: "typescript", - root: NearestRoot( - ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], - ["deno.json", "deno.jsonc"], - ), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) - log.info("typescript server", { tsserver }) - if (!tsserver) return - const bin = await Npm.which("typescript-language-server") - if (!bin) return - const proc = spawn(bin, ["--stdio"], { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: { - tsserver: { - path: tsserver, - }, - }, - } - }, - } - - export const Vue: Info = { - id: "vue", - extensions: [".vue"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("vue-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("@vue/language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: { - // Leave empty; the server will auto-detect workspace TypeScript. - }, - } - }, - } - - export const ESLint: Info = { - id: "eslint", - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root) { - const eslint = Module.resolve("eslint", Instance.directory) - if (!eslint) return - log.info("spawning eslint server") - const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") - if (!(await Filesystem.exists(serverPath))) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading and building VS Code ESLint server") - const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") - if (!response.ok) return - - const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract vscode-eslint archive", { error }) - return false - }) - if (!ok) return - await fs.rm(zipPath, { force: true }) - - const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") - const finalPath = path.join(Global.Path.bin, "vscode-eslint") - - const stats = await fs.stat(finalPath).catch(() => undefined) - if (stats) { - log.info("removing old eslint installation", { path: finalPath }) - await fs.rm(finalPath, { force: true, recursive: true }) - } - await fs.rename(extractedPath, finalPath) - - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" - await Process.run([npmCmd, "install"], { cwd: finalPath }) - await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) - - log.info("installed VS Code ESLint server", { serverPath }) - } - - const proc = spawn("node", [serverPath, "--stdio"], { - cwd: root, - env: { - ...process.env, - }, - }) - - return { - process: proc, - } - }, - } - - export const Oxlint: Info = { - id: "oxlint", - root: NearestRoot([ - ".oxlintrc.json", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - "package.json", - ]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - async spawn(root) { - const ext = process.platform === "win32" ? ".cmd" : "" - - const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) - const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) - - const resolveBin = async (target: string) => { - const localBin = path.join(root, target) - if (await Filesystem.exists(localBin)) return localBin - - const candidates = Filesystem.up({ - targets: [target], - start: root, - stop: Instance.worktree, - }) - const first = await candidates.next() - await candidates.return() - if (first.value) return first.value - - return undefined - } - - let lintBin = await resolveBin(lintTarget) - if (!lintBin) { - const found = which("oxlint") - if (found) lintBin = found - } - - if (lintBin) { - const proc = spawn(lintBin, ["--help"]) - await proc.exited - if (proc.stdout) { - const help = await text(proc.stdout) - if (help.includes("--lsp")) { - return { - process: spawn(lintBin, ["--lsp"], { - cwd: root, - }), - } - } - } - } - - let serverBin = await resolveBin(serverTarget) - if (!serverBin) { - const found = which("oxc_language_server") - if (found) serverBin = found - } - if (serverBin) { - return { - process: spawn(serverBin, [], { - cwd: root, - }), - } - } - - log.info("oxlint not found, please install oxlint") +export const Deno: Info = { + id: "deno", + root: async (file) => { + const files = Filesystem.up({ + targets: ["deno.json", "deno.jsonc"], + start: path.dirname(file), + stop: Instance.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return undefined + return path.dirname(first.value) + }, + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + async spawn(root) { + const deno = which("deno") + if (!deno) { + log.info("deno not found, please install deno first") return - }, - } - - export const Biome: Info = { - id: "biome", - root: NearestRoot([ - "biome.json", - "biome.jsonc", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), - extensions: [ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".mts", - ".cts", - ".json", - ".jsonc", - ".vue", - ".astro", - ".svelte", - ".css", - ".graphql", - ".gql", - ".html", - ], - async spawn(root) { - const localBin = path.join(root, "node_modules", ".bin", "biome") - let bin: string | undefined - if (await Filesystem.exists(localBin)) bin = localBin - if (!bin) { - const found = which("biome") - if (found) bin = found - } - - let args = ["lsp-proxy", "--stdio"] - - if (!bin) { - const resolved = Module.resolve("biome", root) - if (!resolved) return - bin = await Npm.which("biome") - if (!bin) return - args = ["lsp-proxy", "--stdio"] - } - - const proc = spawn(bin, args, { + } + return { + process: spawn(deno, ["lsp"], { cwd: root, - env: { - ...process.env, + }), + } + }, +} + +export const Typescript: Info = { + id: "typescript", + root: NearestRoot( + ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], + ["deno.json", "deno.jsonc"], + ), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + async spawn(root) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + log.info("typescript server", { tsserver }) + if (!tsserver) return + const bin = await Npm.which("typescript-language-server") + if (!bin) return + const proc = spawn(bin, ["--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + tsserver: { + path: tsserver, }, + }, + } + }, +} + +export const Vue: Info = { + id: "vue", + extensions: [".vue"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("vue-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("@vue/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + // Leave empty; the server will auto-detect workspace TypeScript. + }, + } + }, +} + +export const ESLint: Info = { + id: "eslint", + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], + async spawn(root) { + const eslint = Module.resolve("eslint", Instance.directory) + if (!eslint) return + log.info("spawning eslint server") + const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") + if (!(await Filesystem.exists(serverPath))) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading and building VS Code ESLint server") + const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") + if (!response.ok) return + + const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract vscode-eslint archive", { error }) + return false + }) + if (!ok) return + await fs.rm(zipPath, { force: true }) + + const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") + const finalPath = path.join(Global.Path.bin, "vscode-eslint") + + const stats = await fs.stat(finalPath).catch(() => undefined) + if (stats) { + log.info("removing old eslint installation", { path: finalPath }) + await fs.rm(finalPath, { force: true, recursive: true }) + } + await fs.rename(extractedPath, finalPath) + + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" + await Process.run([npmCmd, "install"], { cwd: finalPath }) + await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) + + log.info("installed VS Code ESLint server", { serverPath }) + } + + const proc = spawn("node", [serverPath, "--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Oxlint: Info = { + id: "oxlint", + root: NearestRoot([ + ".oxlintrc.json", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + "package.json", + ]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], + async spawn(root) { + const ext = process.platform === "win32" ? ".cmd" : "" + + const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) + const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) + + const resolveBin = async (target: string) => { + const localBin = path.join(root, target) + if (await Filesystem.exists(localBin)) return localBin + + const candidates = Filesystem.up({ + targets: [target], + start: root, + stop: Instance.worktree, }) - - return { - process: proc, - } - }, - } - - export const Gopls: Info = { - id: "gopls", - root: async (file) => { - const work = await NearestRoot(["go.work"])(file) - if (work) return work - return NearestRoot(["go.mod", "go.sum"])(file) - }, - extensions: [".go"], - async spawn(root) { - let bin = which("gopls") - if (!bin) { - if (!which("go")) return - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - - log.info("installing gopls") - const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { - env: { ...process.env, GOBIN: Global.Path.bin }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install gopls") - return - } - bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed gopls`, { - bin, - }) - } - return { - process: spawn(bin!, { - cwd: root, - }), - } - }, - } - - export const Rubocop: Info = { - id: "ruby-lsp", - root: NearestRoot(["Gemfile"]), - extensions: [".rb", ".rake", ".gemspec", ".ru"], - async spawn(root) { - let bin = which("rubocop") - if (!bin) { - const ruby = which("ruby") - const gem = which("gem") - if (!ruby || !gem) { - log.info("Ruby not found, please install Ruby first") - return - } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing rubocop") - const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install rubocop") - return - } - bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed rubocop`, { - bin, - }) - } - return { - process: spawn(bin!, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ty: Info = { - id: "ty", - extensions: [".py", ".pyi"], - root: NearestRoot([ - "pyproject.toml", - "ty.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "pyrightconfig.json", - ]), - async spawn(root) { - if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { - return undefined - } - - let binary = which("ty") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - if (!binary) { - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialTyPath = isWindows - ? path.join(venvPath, "Scripts", "ty.exe") - : path.join(venvPath, "bin", "ty") - if (await Filesystem.exists(potentialTyPath)) { - binary = potentialTyPath - break - } - } - } - - if (!binary) { - log.error("ty not found, please install ty first") - return - } - - const proc = spawn(binary, ["server"], { - cwd: root, - }) - - return { - process: proc, - initialization, - } - }, - } - - export const Pyright: Info = { - id: "pyright", - extensions: [".py", ".pyi"], - root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), - async spawn(root) { - let binary = which("pyright-langserver") - const args = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("pyright") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization, - } - }, - } - - export const ElixirLS: Info = { - id: "elixir-ls", - extensions: [".ex", ".exs"], - root: NearestRoot(["mix.exs", "mix.lock"]), - async spawn(root) { - let binary = which("elixir-ls") - if (!binary) { - const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") - binary = path.join( - Global.Path.bin, - "elixir-ls-master", - "release", - process.platform === "win32" ? "language_server.bat" : "language_server.sh", - ) - - if (!(await Filesystem.exists(binary))) { - const elixir = which("elixir") - if (!elixir) { - log.error("elixir is required to run elixir-ls") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading elixir-ls from GitHub releases") - - const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") - if (!response.ok) return - const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract elixir-ls archive", { error }) - return false - }) - if (!ok) return - - await fs.rm(zipPath, { - force: true, - recursive: true, - }) - - const cwd = path.join(Global.Path.bin, "elixir-ls-master") - const env = { MIX_ENV: "prod", ...process.env } - await Process.run(["mix", "deps.get"], { cwd, env }) - await Process.run(["mix", "compile"], { cwd, env }) - await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) - - log.info(`installed elixir-ls`, { - path: elixirLsPath, - }) - } - } - - return { - process: spawn(binary, { - cwd: root, - }), - } - }, - } - - export const Zls: Info = { - id: "zls", - extensions: [".zig", ".zon"], - root: NearestRoot(["build.zig"]), - async spawn(root) { - let bin = which("zls") - - if (!bin) { - const zig = which("zig") - if (!zig) { - log.error("Zig is required to use zls. Please install Zig first.") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading zls from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch zls release info") - return - } - - const release = (await releaseResponse.json()) as any - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let zlsArch: string = arch - if (arch === "arm64") zlsArch = "aarch64" - else if (arch === "x64") zlsArch = "x86_64" - else if (arch === "ia32") zlsArch = "x86" - - let zlsPlatform: string = platform - if (platform === "darwin") zlsPlatform = "macos" - else if (platform === "win32") zlsPlatform = "windows" - - const ext = platform === "win32" ? "zip" : "tar.xz" - - assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` - - const supportedCombos = [ - "zls-x86_64-linux.tar.xz", - "zls-x86_64-macos.tar.xz", - "zls-x86_64-windows.zip", - "zls-aarch64-linux.tar.xz", - "zls-aarch64-macos.tar.xz", - "zls-aarch64-windows.zip", - "zls-x86-linux.tar.xz", - "zls-x86-windows.zip", - ] - - if (!supportedCombos.includes(assetName)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest zls release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download zls") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract zls archive", { error }) - return false - }) - if (!ok) return - } else { - await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract zls binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info(`installed zls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const CSharp: Info = { - id: "csharp", - root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), - extensions: [".cs"], - async spawn(root) { - let bin = which("csharp-ls") - if (!bin) { - if (!which("dotnet")) { - log.error(".NET SDK is required to install csharp-ls") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing csharp-ls via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install csharp-ls") - return - } - - bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed csharp-ls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const FSharp: Info = { - id: "fsharp", - root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), - extensions: [".fs", ".fsi", ".fsx", ".fsscript"], - async spawn(root) { - let bin = which("fsautocomplete") - if (!bin) { - if (!which("dotnet")) { - log.error(".NET SDK is required to install fsautocomplete") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing fsautocomplete via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install fsautocomplete") - return - } - - bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed fsautocomplete`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const SourceKit: Info = { - id: "sourcekit-lsp", - extensions: [".swift", ".objc", "objcpp"], - root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), - async spawn(root) { - // Check if sourcekit-lsp is available in the PATH - // This is installed with the Swift toolchain - const sourcekit = which("sourcekit-lsp") - if (sourcekit) { - return { - process: spawn(sourcekit, { - cwd: root, - }), - } - } - - // If sourcekit-lsp not found, check if xcrun is available - // This is specific to macOS where sourcekit-lsp is typically installed with Xcode - if (!which("xcrun")) return - - const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) - - if (lspLoc.code !== 0) return - - const bin = lspLoc.text.trim() - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const RustAnalyzer: Info = { - id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) - if (crateRoot === undefined) { - return undefined - } - let currentDir = crateRoot - - while (currentDir !== path.dirname(currentDir)) { - // Stop at filesystem root - const cargoTomlPath = path.join(currentDir, "Cargo.toml") - try { - const cargoTomlContent = await Filesystem.readText(cargoTomlPath) - if (cargoTomlContent.includes("[workspace]")) { - return currentDir - } - } catch { - // File doesn't exist or can't be read, continue searching up - } - - const parentDir = path.dirname(currentDir) - if (parentDir === currentDir) break // Reached filesystem root - currentDir = parentDir - - // Stop if we've gone above the app root - if (!currentDir.startsWith(Instance.worktree)) break - } - - return crateRoot - }, - extensions: [".rs"], - async spawn(root) { - const bin = which("rust-analyzer") - if (!bin) { - log.info("rust-analyzer not found in path, please install it") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const Clangd: Info = { - id: "clangd", - root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), - extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], - async spawn(root) { - const args = ["--background-index", "--clang-tidy"] - const fromPath = which("clangd") - if (fromPath) { - return { - process: spawn(fromPath, args, { - cwd: root, - }), - } - } - - const ext = process.platform === "win32" ? ".exe" : "" - const direct = path.join(Global.Path.bin, "clangd" + ext) - if (await Filesystem.exists(direct)) { - return { - process: spawn(direct, args, { - cwd: root, - }), - } - } - - const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (!entry.name.startsWith("clangd_")) continue - const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) - if (await Filesystem.exists(candidate)) { + const first = await candidates.next() + await candidates.return() + if (first.value) return first.value + + return undefined + } + + let lintBin = await resolveBin(lintTarget) + if (!lintBin) { + const found = which("oxlint") + if (found) lintBin = found + } + + if (lintBin) { + const proc = spawn(lintBin, ["--help"]) + await proc.exited + if (proc.stdout) { + const help = await text(proc.stdout) + if (help.includes("--lsp")) { return { - process: spawn(candidate, args, { + process: spawn(lintBin, ["--lsp"], { cwd: root, }), } } } + } + let serverBin = await resolveBin(serverTarget) + if (!serverBin) { + const found = which("oxc_language_server") + if (found) serverBin = found + } + if (serverBin) { + return { + process: spawn(serverBin, [], { + cwd: root, + }), + } + } + + log.info("oxlint not found, please install oxlint") + return + }, +} + +export const Biome: Info = { + id: "biome", + root: NearestRoot([ + "biome.json", + "biome.jsonc", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), + extensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ".json", + ".jsonc", + ".vue", + ".astro", + ".svelte", + ".css", + ".graphql", + ".gql", + ".html", + ], + async spawn(root) { + const localBin = path.join(root, "node_modules", ".bin", "biome") + let bin: string | undefined + if (await Filesystem.exists(localBin)) bin = localBin + if (!bin) { + const found = which("biome") + if (found) bin = found + } + + let args = ["lsp-proxy", "--stdio"] + + if (!bin) { + const resolved = Module.resolve("biome", root) + if (!resolved) return + bin = await Npm.which("biome") + if (!bin) return + args = ["lsp-proxy", "--stdio"] + } + + const proc = spawn(bin, args, { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Gopls: Info = { + id: "gopls", + root: async (file) => { + const work = await NearestRoot(["go.work"])(file) + if (work) return work + return NearestRoot(["go.mod", "go.sum"])(file) + }, + extensions: [".go"], + async spawn(root) { + let bin = which("gopls") + if (!bin) { + if (!which("go")) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch clangd release info") + log.info("installing gopls") + const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { + env: { ...process.env, GOBIN: Global.Path.bin }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install gopls") return } + bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed gopls`, { + bin, + }) + } + return { + process: spawn(bin!, { + cwd: root, + }), + } + }, +} - const release: { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } = await releaseResponse.json() - - const tag = release.tag_name - if (!tag) { - log.error("clangd release did not include a tag name") +export const Rubocop: Info = { + id: "ruby-lsp", + root: NearestRoot(["Gemfile"]), + extensions: [".rb", ".rake", ".gemspec", ".ru"], + async spawn(root) { + let bin = which("rubocop") + if (!bin) { + const ruby = which("ruby") + const gem = which("gem") + if (!ruby || !gem) { + log.info("Ruby not found, please install Ruby first") return } - const platform = process.platform - const tokens: Record = { - darwin: "mac", - linux: "linux", - win32: "windows", - } - const token = tokens[platform] - if (!token) { - log.error(`Platform ${platform} is not supported by clangd auto-download`) + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing rubocop") + const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install rubocop") return } + bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed rubocop`, { + bin, + }) + } + return { + process: spawn(bin!, ["--lsp"], { + cwd: root, + }), + } + }, +} - const assets = release.assets ?? [] - const valid = (item: { name?: string; browser_download_url?: string }) => { - if (!item.name) return false - if (!item.browser_download_url) return false - if (!item.name.includes(token)) return false - return item.name.includes(tag) +export const Ty: Info = { + id: "ty", + extensions: [".py", ".pyi"], + root: NearestRoot([ + "pyproject.toml", + "ty.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "pyrightconfig.json", + ]), + async spawn(root) { + if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { + return undefined + } + + let binary = which("ty") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break } + } - const asset = - assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? - assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? - assets.find((item) => valid(item)) - if (!asset?.name || !asset.browser_download_url) { - log.error("clangd could not match release asset", { tag, platform }) - return + if (!binary) { + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialTyPath = isWindows + ? path.join(venvPath, "Scripts", "ty.exe") + : path.join(venvPath, "bin", "ty") + if (await Filesystem.exists(potentialTyPath)) { + binary = potentialTyPath + break + } } + } - const name = asset.name - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download clangd") - return + if (!binary) { + log.error("ty not found, please install ty first") + return + } + + const proc = spawn(binary, ["server"], { + cwd: root, + }) + + return { + process: proc, + initialization, + } + }, +} + +export const Pyright: Info = { + id: "pyright", + extensions: [".py", ".pyi"], + root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), + async spawn(root) { + let binary = which("pyright-langserver") + const args = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("pyright") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break } + } - const archive = path.join(Global.Path.bin, name) - const buf = await downloadResponse.arrayBuffer() - if (buf.byteLength === 0) { - log.error("Failed to write clangd archive") - return - } - await Filesystem.write(archive, Buffer.from(buf)) + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization, + } + }, +} - const zip = name.endsWith(".zip") - const tar = name.endsWith(".tar.xz") - if (!zip && !tar) { - log.error("clangd encountered unsupported asset", { asset: name }) - return - } +export const ElixirLS: Info = { + id: "elixir-ls", + extensions: [".ex", ".exs"], + root: NearestRoot(["mix.exs", "mix.lock"]), + async spawn(root) { + let binary = which("elixir-ls") + if (!binary) { + const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") + binary = path.join( + Global.Path.bin, + "elixir-ls-master", + "release", + process.platform === "win32" ? "language_server.bat" : "language_server.sh", + ) - if (zip) { - const ok = await Archive.extractZip(archive, Global.Path.bin) + if (!(await Filesystem.exists(binary))) { + const elixir = which("elixir") + if (!elixir) { + log.error("elixir is required to run elixir-ls") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading elixir-ls from GitHub releases") + + const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") + if (!response.ok) return + const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) .then(() => true) .catch((error) => { - log.error("Failed to extract clangd archive", { error }) + log.error("Failed to extract elixir-ls archive", { error }) return false }) if (!ok) return - } - if (tar) { - await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) - } - await fs.rm(archive, { force: true }) - const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + await fs.rm(zipPath, { + force: true, + recursive: true, + }) + + const cwd = path.join(Global.Path.bin, "elixir-ls-master") + const env = { MIX_ENV: "prod", ...process.env } + await Process.run(["mix", "deps.get"], { cwd, env }) + await Process.run(["mix", "compile"], { cwd, env }) + await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) + + log.info(`installed elixir-ls`, { + path: elixirLsPath, + }) + } + } + + return { + process: spawn(binary, { + cwd: root, + }), + } + }, +} + +export const Zls: Info = { + id: "zls", + extensions: [".zig", ".zon"], + root: NearestRoot(["build.zig"]), + async spawn(root) { + let bin = which("zls") + + if (!bin) { + const zig = which("zig") + if (!zig) { + log.error("Zig is required to use zls. Please install Zig first.") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading zls from GitHub releases") + + const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch zls release info") + return + } + + const release = (await releaseResponse.json()) as any + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let zlsArch: string = arch + if (arch === "arm64") zlsArch = "aarch64" + else if (arch === "x64") zlsArch = "x86_64" + else if (arch === "ia32") zlsArch = "x86" + + let zlsPlatform: string = platform + if (platform === "darwin") zlsPlatform = "macos" + else if (platform === "win32") zlsPlatform = "windows" + + const ext = platform === "win32" ? "zip" : "tar.xz" + + assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` + + const supportedCombos = [ + "zls-x86_64-linux.tar.xz", + "zls-x86_64-macos.tar.xz", + "zls-x86_64-windows.zip", + "zls-aarch64-linux.tar.xz", + "zls-aarch64-macos.tar.xz", + "zls-aarch64-windows.zip", + "zls-x86-linux.tar.xz", + "zls-x86-windows.zip", + ] + + if (!supportedCombos.includes(assetName)) { + log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`) + return + } + + const asset = release.assets.find((a: any) => a.name === assetName) + if (!asset) { + log.error(`Could not find asset ${assetName} in latest zls release`) + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + log.error("Failed to download zls") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract zls archive", { error }) + return false + }) + if (!ok) return + } else { + await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) + if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract clangd binary") + log.error("Failed to extract zls binary") return } @@ -988,971 +689,1268 @@ export namespace LSPServer { await fs.chmod(bin, 0o755).catch(() => {}) } - await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) - await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + log.info(`installed zls`, { bin }) + } - log.info(`installed clangd`, { bin }) + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} +export const CSharp: Info = { + id: "csharp", + root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), + extensions: [".cs"], + async spawn(root) { + let bin = which("csharp-ls") + if (!bin) { + if (!which("dotnet")) { + log.error(".NET SDK is required to install csharp-ls") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing csharp-ls via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install csharp-ls") + return + } + + bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed csharp-ls`, { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const FSharp: Info = { + id: "fsharp", + root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), + extensions: [".fs", ".fsi", ".fsx", ".fsscript"], + async spawn(root) { + let bin = which("fsautocomplete") + if (!bin) { + if (!which("dotnet")) { + log.error(".NET SDK is required to install fsautocomplete") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing fsautocomplete via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install fsautocomplete") + return + } + + bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed fsautocomplete`, { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const SourceKit: Info = { + id: "sourcekit-lsp", + extensions: [".swift", ".objc", "objcpp"], + root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), + async spawn(root) { + // Check if sourcekit-lsp is available in the PATH + // This is installed with the Swift toolchain + const sourcekit = which("sourcekit-lsp") + if (sourcekit) { return { - process: spawn(bin, args, { + process: spawn(sourcekit, { cwd: root, }), } - }, - } + } - export const Svelte: Info = { - id: "svelte", - extensions: [".svelte"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("svelteserver") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("svelte-language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + // If sourcekit-lsp not found, check if xcrun is available + // This is specific to macOS where sourcekit-lsp is typically installed with Xcode + if (!which("xcrun")) return + + const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) + + if (lspLoc.code !== 0) return + + const bin = lspLoc.text.trim() + + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: {}, - } - }, - } + }), + } + }, +} - export const Astro: Info = { - id: "astro", - extensions: [".astro"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) - if (!tsserver) { - log.info("typescript not found, required for Astro language server") - return - } - const tsdk = path.dirname(tsserver) +export const RustAnalyzer: Info = { + id: "rust", + root: async (root) => { + const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) + if (crateRoot === undefined) { + return undefined + } + let currentDir = crateRoot - let binary = which("astro-ls") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("@astrojs/language-server") - if (!resolved) return - binary = resolved + while (currentDir !== path.dirname(currentDir)) { + // Stop at filesystem root + const cargoTomlPath = path.join(currentDir, "Cargo.toml") + try { + const cargoTomlContent = await Filesystem.readText(cargoTomlPath) + if (cargoTomlContent.includes("[workspace]")) { + return currentDir + } + } catch { + // File doesn't exist or can't be read, continue searching up } - args.push("--stdio") - const proc = spawn(binary, args, { + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) break // Reached filesystem root + currentDir = parentDir + + // Stop if we've gone above the app root + if (!currentDir.startsWith(Instance.worktree)) break + } + + return crateRoot + }, + extensions: [".rs"], + async spawn(root) { + const bin = which("rust-analyzer") + if (!bin) { + log.info("rust-analyzer not found in path, please install it") + return + } + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, - }, - }) + }), + } + }, +} + +export const Clangd: Info = { + id: "clangd", + root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), + extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], + async spawn(root) { + const args = ["--background-index", "--clang-tidy"] + const fromPath = which("clangd") + if (fromPath) { return { - process: proc, - initialization: { - typescript: { - tsdk, - }, - }, + process: spawn(fromPath, args, { + cwd: root, + }), } - }, - } + } - export const JDTLS: Info = { - id: "jdtls", - root: async (file) => { - // Without exclusions, NearestRoot defaults to instance directory so we can't - // distinguish between a) no project found and b) project found at instance dir. - // So we can't choose the root from (potential) monorepo markers first. - // Look for potential subproject markers first while excluding potential monorepo markers. - const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] - const gradleMarkers = ["gradlew", "gradlew.bat"] - const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) - - const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ - NearestRoot( - ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], - exclusionsForMonorepos, - )(file), - NearestRoot(gradleMarkers, settingsMarkers)(file), - NearestRoot(settingsMarkers)(file), - ]) - - // If projectRoot is undefined we know we are in a monorepo or no project at all. - // So can safely fall through to the other roots - if (projectRoot) return projectRoot - if (wrapperRoot) return wrapperRoot - if (settingsRoot) return settingsRoot - }, - extensions: [".java"], - async spawn(root) { - const java = which("java") - if (!java) { - log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") - return - } - const javaMajorVersion = await run(["java", "-version"]).then((result) => { - const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) - return !m ? undefined : parseInt(m[1]) - }) - if (javaMajorVersion == null || javaMajorVersion < 21) { - log.error("JDTLS requires at least Java 21.") - return - } - const distPath = path.join(Global.Path.bin, "jdtls") - const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) - if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading JDTLS LSP server.") - await fs.mkdir(distPath, { recursive: true }) - const releaseURL = - "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archiveName = "release.tar.gz" - - log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) - const download = await fetch(releaseURL) - if (!download.ok || !download.body) { - log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText }) - return - } - await Filesystem.writeStream(path.join(distPath, archiveName), download.body) - - log.info("Extracting JDTLS archive") - const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) - if (tarResult.code !== 0) { - log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() }) - return - } - - await fs.rm(path.join(distPath, archiveName), { force: true }) - log.info("JDTLS download and extraction completed") - } - const jarFileName = - (await fs.readdir(launcherDir).catch(() => [])) - .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) - ?.trim() ?? "" - const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { - log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) - return - } - const configFile = path.join( - distPath, - (() => { - switch (process.platform) { - case "darwin": - return "config_mac" - case "linux": - return "config_linux" - case "win32": - return "config_win" - default: - return "config_linux" - } - })(), - ) - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) + const ext = process.platform === "win32" ? ".exe" : "" + const direct = path.join(Global.Path.bin, "clangd" + ext) + if (await Filesystem.exists(direct)) { return { - process: spawn( - java, - [ - "-jar", - launcherJar, - "-configuration", - configFile, - "-data", - dataDir, - "-Declipse.application=org.eclipse.jdt.ls.core.id1", - "-Dosgi.bundles.defaultStartLevel=4", - "-Declipse.product=org.eclipse.jdt.ls.core.product", - "-Dlog.level=ALL", - "--add-modules=ALL-SYSTEM", - "--add-opens java.base/java.util=ALL-UNNAMED", - "--add-opens java.base/java.lang=ALL-UNNAMED", - ], - { + process: spawn(direct, args, { + cwd: root, + }), + } + } + + const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (!entry.name.startsWith("clangd_")) continue + const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) + if (await Filesystem.exists(candidate)) { + return { + process: spawn(candidate, args, { cwd: root, - }, - ), + }), + } } - }, - } + } - export const KotlinLS: Info = { - id: "kotlin-ls", - extensions: [".kt", ".kts"], - root: async (file) => { - // 1) Nearest Gradle root (multi-project or included build) - const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) - if (settingsRoot) return settingsRoot - // 2) Gradle wrapper (strong root signal) - const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) - if (wrapperRoot) return wrapperRoot - // 3) Single-project or module-level build - const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) - if (buildRoot) return buildRoot - // 4) Maven fallback - return NearestRoot(["pom.xml"])(file) - }, - async spawn(root) { - const distPath = path.join(Global.Path.bin, "kotlin-ls") - const launcherScript = - process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") - const installed = await Filesystem.exists(launcherScript) - if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading Kotlin Language Server from GitHub.") + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch kotlin-lsp release info") - return + const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch clangd release info") + return + } + + const release: { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } = await releaseResponse.json() + + const tag = release.tag_name + if (!tag) { + log.error("clangd release did not include a tag name") + return + } + const platform = process.platform + const tokens: Record = { + darwin: "mac", + linux: "linux", + win32: "windows", + } + const token = tokens[platform] + if (!token) { + log.error(`Platform ${platform} is not supported by clangd auto-download`) + return + } + + const assets = release.assets ?? [] + const valid = (item: { name?: string; browser_download_url?: string }) => { + if (!item.name) return false + if (!item.browser_download_url) return false + if (!item.name.includes(token)) return false + return item.name.includes(tag) + } + + const asset = + assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? + assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? + assets.find((item) => valid(item)) + if (!asset?.name || !asset.browser_download_url) { + log.error("clangd could not match release asset", { tag, platform }) + return + } + + const name = asset.name + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download clangd") + return + } + + const archive = path.join(Global.Path.bin, name) + const buf = await downloadResponse.arrayBuffer() + if (buf.byteLength === 0) { + log.error("Failed to write clangd archive") + return + } + await Filesystem.write(archive, Buffer.from(buf)) + + const zip = name.endsWith(".zip") + const tar = name.endsWith(".tar.xz") + if (!zip && !tar) { + log.error("clangd encountered unsupported asset", { asset: name }) + return + } + + if (zip) { + const ok = await Archive.extractZip(archive, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract clangd archive", { error }) + return false + }) + if (!ok) return + } + if (tar) { + await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) + } + await fs.rm(archive, { force: true }) + + const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract clangd binary") + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) + await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + + log.info(`installed clangd`, { bin }) + + return { + process: spawn(bin, args, { + cwd: root, + }), + } + }, +} + +export const Svelte: Info = { + id: "svelte", + extensions: [".svelte"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("svelteserver") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("svelte-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: {}, + } + }, +} + +export const Astro: Info = { + id: "astro", + extensions: [".astro"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + if (!tsserver) { + log.info("typescript not found, required for Astro language server") + return + } + const tsdk = path.dirname(tsserver) + + let binary = which("astro-ls") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("@astrojs/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + typescript: { + tsdk, + }, + }, + } + }, +} + +export const JDTLS: Info = { + id: "jdtls", + root: async (file) => { + // Without exclusions, NearestRoot defaults to instance directory so we can't + // distinguish between a) no project found and b) project found at instance dir. + // So we can't choose the root from (potential) monorepo markers first. + // Look for potential subproject markers first while excluding potential monorepo markers. + const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] + const gradleMarkers = ["gradlew", "gradlew.bat"] + const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) + + const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ + NearestRoot( + ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], + exclusionsForMonorepos, + )(file), + NearestRoot(gradleMarkers, settingsMarkers)(file), + NearestRoot(settingsMarkers)(file), + ]) + + // If projectRoot is undefined we know we are in a monorepo or no project at all. + // So can safely fall through to the other roots + if (projectRoot) return projectRoot + if (wrapperRoot) return wrapperRoot + if (settingsRoot) return settingsRoot + }, + extensions: [".java"], + async spawn(root) { + const java = which("java") + if (!java) { + log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") + return + } + const javaMajorVersion = await run(["java", "-version"]).then((result) => { + const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) + return !m ? undefined : parseInt(m[1]) + }) + if (javaMajorVersion == null || javaMajorVersion < 21) { + log.error("JDTLS requires at least Java 21.") + return + } + const distPath = path.join(Global.Path.bin, "jdtls") + const launcherDir = path.join(distPath, "plugins") + const installed = await pathExists(launcherDir) + if (!installed) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("Downloading JDTLS LSP server.") + await fs.mkdir(distPath, { recursive: true }) + const releaseURL = + "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" + const archiveName = "release.tar.gz" + + log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText }) + return + } + await Filesystem.writeStream(path.join(distPath, archiveName), download.body) + + log.info("Extracting JDTLS archive") + const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) + if (tarResult.code !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() }) + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + log.info("JDTLS download and extraction completed") + } + const jarFileName = + (await fs.readdir(launcherDir).catch(() => [])) + .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) + ?.trim() ?? "" + const launcherJar = path.join(launcherDir, jarFileName) + if (!(await pathExists(launcherJar))) { + log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) + return + } + const configFile = path.join( + distPath, + (() => { + switch (process.platform) { + case "darwin": + return "config_mac" + case "linux": + return "config_linux" + case "win32": + return "config_win" + default: + return "config_linux" } + })(), + ) + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) + return { + process: spawn( + java, + [ + "-jar", + launcherJar, + "-configuration", + configFile, + "-data", + dataDir, + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dlog.level=ALL", + "--add-modules=ALL-SYSTEM", + "--add-opens java.base/java.util=ALL-UNNAMED", + "--add-opens java.base/java.lang=ALL-UNNAMED", + ], + { + cwd: root, + }, + ), + } + }, +} - const release = await releaseResponse.json() - const version = release.name?.replace(/^v/, "") +export const KotlinLS: Info = { + id: "kotlin-ls", + extensions: [".kt", ".kts"], + root: async (file) => { + // 1) Nearest Gradle root (multi-project or included build) + const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) + if (settingsRoot) return settingsRoot + // 2) Gradle wrapper (strong root signal) + const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) + if (wrapperRoot) return wrapperRoot + // 3) Single-project or module-level build + const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) + if (buildRoot) return buildRoot + // 4) Maven fallback + return NearestRoot(["pom.xml"])(file) + }, + async spawn(root) { + const distPath = path.join(Global.Path.bin, "kotlin-ls") + const launcherScript = + process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") + const installed = await Filesystem.exists(launcherScript) + if (!installed) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("Downloading Kotlin Language Server from GitHub.") - if (!version) { - log.error("Could not determine Kotlin LSP version from release") - return - } + const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch kotlin-lsp release info") + return + } - const platform = process.platform - const arch = process.arch + const release = await releaseResponse.json() + const version = release.name?.replace(/^v/, "") - let kotlinArch: string = arch - if (arch === "arm64") kotlinArch = "aarch64" - else if (arch === "x64") kotlinArch = "x64" + if (!version) { + log.error("Could not determine Kotlin LSP version from release") + return + } - let kotlinPlatform: string = platform - if (platform === "darwin") kotlinPlatform = "mac" - else if (platform === "linux") kotlinPlatform = "linux" - else if (platform === "win32") kotlinPlatform = "win" + const platform = process.platform + const arch = process.arch - const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] + let kotlinArch: string = arch + if (arch === "arm64") kotlinArch = "aarch64" + else if (arch === "x64") kotlinArch = "x64" - const combo = `${kotlinPlatform}-${kotlinArch}` + let kotlinPlatform: string = platform + if (platform === "darwin") kotlinPlatform = "mac" + else if (platform === "linux") kotlinPlatform = "linux" + else if (platform === "win32") kotlinPlatform = "win" - if (!supportedCombos.includes(combo)) { - log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`) - return - } + const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] - const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` - const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` + const combo = `${kotlinPlatform}-${kotlinArch}` - await fs.mkdir(distPath, { recursive: true }) - const archivePath = path.join(distPath, "kotlin-ls.zip") - const download = await fetch(releaseURL) - if (!download.ok || !download.body) { - log.error("Failed to download Kotlin Language Server", { - status: download.status, - statusText: download.statusText, - }) - return - } - await Filesystem.writeStream(archivePath, download.body) - const ok = await Archive.extractZip(archivePath, distPath) + if (!supportedCombos.includes(combo)) { + log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`) + return + } + + const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` + const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` + + await fs.mkdir(distPath, { recursive: true }) + const archivePath = path.join(distPath, "kotlin-ls.zip") + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download Kotlin Language Server", { + status: download.status, + statusText: download.statusText, + }) + return + } + await Filesystem.writeStream(archivePath, download.body) + const ok = await Archive.extractZip(archivePath, distPath) + .then(() => true) + .catch((error) => { + log.error("Failed to extract Kotlin LS archive", { error }) + return false + }) + if (!ok) return + await fs.rm(archivePath, { force: true }) + if (process.platform !== "win32") { + await fs.chmod(launcherScript, 0o755).catch(() => {}) + } + log.info("Installed Kotlin Language Server", { path: launcherScript }) + } + if (!(await Filesystem.exists(launcherScript))) { + log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) + return + } + return { + process: spawn(launcherScript, ["--stdio"], { + cwd: root, + }), + } + }, +} + +export const YamlLS: Info = { + id: "yaml-ls", + extensions: [".yaml", ".yml"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("yaml-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("yaml-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const LuaLS: Info = { + id: "lua-ls", + root: NearestRoot([ + ".luarc.json", + ".luarc.jsonc", + ".luacheckrc", + ".stylua.toml", + "stylua.toml", + "selene.toml", + "selene.yml", + ]), + extensions: [".lua"], + async spawn(root) { + let bin = which("lua-language-server") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading lua-language-server from GitHub releases") + + const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch lua-language-server release info") + return + } + + const release = await releaseResponse.json() + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let lualsArch: string = arch + if (arch === "arm64") lualsArch = "arm64" + else if (arch === "x64") lualsArch = "x64" + else if (arch === "ia32") lualsArch = "ia32" + + let lualsPlatform: string = platform + if (platform === "darwin") lualsPlatform = "darwin" + else if (platform === "linux") lualsPlatform = "linux" + else if (platform === "win32") lualsPlatform = "win32" + + const ext = platform === "win32" ? "zip" : "tar.gz" + + assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` + + const supportedCombos = [ + "darwin-arm64.tar.gz", + "darwin-x64.tar.gz", + "linux-x64.tar.gz", + "linux-arm64.tar.gz", + "win32-x64.zip", + "win32-ia32.zip", + ] + + const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` + if (!supportedCombos.includes(assetSuffix)) { + log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) + return + } + + const asset = release.assets.find((a: any) => a.name === assetName) + if (!asset) { + log.error(`Could not find asset ${assetName} in latest lua-language-server release`) + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + log.error("Failed to download lua-language-server") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + // Unlike zls which is a single self-contained binary, + // lua-language-server needs supporting files (meta/, locale/, etc.) + // Extract entire archive to dedicated directory to preserve all files + const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) + + // Remove old installation if exists + const stats = await fs.stat(installDir).catch(() => undefined) + if (stats) { + await fs.rm(installDir, { force: true, recursive: true }) + } + + await fs.mkdir(installDir, { recursive: true }) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, installDir) .then(() => true) .catch((error) => { - log.error("Failed to extract Kotlin LS archive", { error }) + log.error("Failed to extract lua-language-server archive", { error }) + return false + }) + if (!ok) return + } else { + const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) + .then((result) => result.code === 0) + .catch((error: unknown) => { + log.error("Failed to extract lua-language-server archive", { error }) return false }) if (!ok) return - await fs.rm(archivePath, { force: true }) - if (process.platform !== "win32") { - await fs.chmod(launcherScript, 0o755).catch(() => {}) - } - log.info("Installed Kotlin Language Server", { path: launcherScript }) } - if (!(await Filesystem.exists(launcherScript))) { - log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) + + await fs.rm(tempPath, { force: true }) + + // Binary is located in bin/ subdirectory within the extracted archive + bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract lua-language-server binary") return } - return { - process: spawn(launcherScript, ["--stdio"], { - cwd: root, - }), - } - }, - } - export const YamlLS: Info = { - id: "yaml-ls", - extensions: [".yaml", ".yml"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("yaml-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("yaml-language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - } - }, - } - - export const LuaLS: Info = { - id: "lua-ls", - root: NearestRoot([ - ".luarc.json", - ".luarc.jsonc", - ".luacheckrc", - ".stylua.toml", - "stylua.toml", - "selene.toml", - "selene.yml", - ]), - extensions: [".lua"], - async spawn(root) { - let bin = which("lua-language-server") - - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading lua-language-server from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch lua-language-server release info") - return - } - - const release = await releaseResponse.json() - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let lualsArch: string = arch - if (arch === "arm64") lualsArch = "arm64" - else if (arch === "x64") lualsArch = "x64" - else if (arch === "ia32") lualsArch = "ia32" - - let lualsPlatform: string = platform - if (platform === "darwin") lualsPlatform = "darwin" - else if (platform === "linux") lualsPlatform = "linux" - else if (platform === "win32") lualsPlatform = "win32" - - const ext = platform === "win32" ? "zip" : "tar.gz" - - assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` - - const supportedCombos = [ - "darwin-arm64.tar.gz", - "darwin-x64.tar.gz", - "linux-x64.tar.gz", - "linux-arm64.tar.gz", - "win32-x64.zip", - "win32-ia32.zip", - ] - - const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` - if (!supportedCombos.includes(assetSuffix)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest lua-language-server release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download lua-language-server") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - // Unlike zls which is a single self-contained binary, - // lua-language-server needs supporting files (meta/, locale/, etc.) - // Extract entire archive to dedicated directory to preserve all files - const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) - - // Remove old installation if exists - const stats = await fs.stat(installDir).catch(() => undefined) - if (stats) { - await fs.rm(installDir, { force: true, recursive: true }) - } - - await fs.mkdir(installDir, { recursive: true }) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, installDir) - .then(() => true) - .catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false + if (platform !== "win32") { + const ok = await fs + .chmod(bin, 0o755) + .then(() => true) + .catch((error: unknown) => { + log.error("Failed to set executable permission for lua-language-server binary", { + error, }) - if (!ok) return - } else { - const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) - .then((result) => result.code === 0) - .catch((error: unknown) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false - }) - if (!ok) return - } - - await fs.rm(tempPath, { force: true }) - - // Binary is located in bin/ subdirectory within the extracted archive - bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract lua-language-server binary") - return - } - - if (platform !== "win32") { - const ok = await fs - .chmod(bin, 0o755) - .then(() => true) - .catch((error: unknown) => { - log.error("Failed to set executable permission for lua-language-server binary", { - error, - }) - return false - }) - if (!ok) return - } - - log.info(`installed lua-language-server`, { bin }) + return false + }) + if (!ok) return } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } + log.info(`installed lua-language-server`, { bin }) + } - export const PHPIntelephense: Info = { - id: "php intelephense", - extensions: [".php"], - root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), - async spawn(root) { - let binary = which("intelephense") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("intelephense") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, + }), + } + }, +} + +export const PHPIntelephense: Info = { + id: "php intelephense", + extensions: [".php"], + root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), + async spawn(root) { + let binary = which("intelephense") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("intelephense") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + telemetry: { + enabled: false, }, - }) - return { - process: proc, - initialization: { - telemetry: { - enabled: false, - }, - }, - } - }, - } + }, + } + }, +} - export const Prisma: Info = { - id: "prisma", - extensions: [".prisma"], - root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), - async spawn(root) { - const prisma = which("prisma") - if (!prisma) { - log.info("prisma not found, please install prisma") - return - } - return { - process: spawn(prisma, ["language-server"], { - cwd: root, - }), - } - }, - } - - export const Dart: Info = { - id: "dart", - extensions: [".dart"], - root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), - async spawn(root) { - const dart = which("dart") - if (!dart) { - log.info("dart not found, please install dart first") - return - } - return { - process: spawn(dart, ["language-server", "--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ocaml: Info = { - id: "ocaml-lsp", - extensions: [".ml", ".mli"], - root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), - async spawn(root) { - const bin = which("ocamllsp") - if (!bin) { - log.info("ocamllsp not found, please install ocaml-lsp-server") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - export const BashLS: Info = { - id: "bash", - extensions: [".sh", ".bash", ".zsh", ".ksh"], - root: async () => Instance.directory, - async spawn(root) { - let binary = which("bash-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("bash-language-server") - if (!resolved) return - binary = resolved - } - args.push("start") - const proc = spawn(binary, args, { +export const Prisma: Info = { + id: "prisma", + extensions: [".prisma"], + root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), + async spawn(root) { + const prisma = which("prisma") + if (!prisma) { + log.info("prisma not found, please install prisma") + return + } + return { + process: spawn(prisma, ["language-server"], { cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, + }), + } + }, +} + +export const Dart: Info = { + id: "dart", + extensions: [".dart"], + root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), + async spawn(root) { + const dart = which("dart") + if (!dart) { + log.info("dart not found, please install dart first") + return + } + return { + process: spawn(dart, ["language-server", "--lsp"], { + cwd: root, + }), + } + }, +} + +export const Ocaml: Info = { + id: "ocaml-lsp", + extensions: [".ml", ".mli"], + root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), + async spawn(root) { + const bin = which("ocamllsp") + if (!bin) { + log.info("ocamllsp not found, please install ocaml-lsp-server") + return + } + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} +export const BashLS: Info = { + id: "bash", + extensions: [".sh", ".bash", ".zsh", ".ksh"], + root: async () => Instance.directory, + async spawn(root) { + let binary = which("bash-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("bash-language-server") + if (!resolved) return + binary = resolved + } + args.push("start") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const TerraformLS: Info = { + id: "terraform", + extensions: [".tf", ".tfvars"], + root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), + async spawn(root) { + let bin = which("terraform-ls") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading terraform-ls from HashiCorp releases") + + const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch terraform-ls release info") + return } - }, - } - export const TerraformLS: Info = { - id: "terraform", - extensions: [".tf", ".tfvars"], - root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), - async spawn(root) { - let bin = which("terraform-ls") + const release = (await releaseResponse.json()) as { + version?: string + builds?: { arch?: string; os?: string; url?: string }[] + } - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading terraform-ls from HashiCorp releases") + const platform = process.platform + const arch = process.arch - const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch terraform-ls release info") - return - } + const tfArch = arch === "arm64" ? "arm64" : "amd64" + const tfPlatform = platform === "win32" ? "windows" : platform - const release = (await releaseResponse.json()) as { - version?: string - builds?: { arch?: string; os?: string; url?: string }[] - } + const builds = release.builds ?? [] + const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) + if (!build?.url) { + log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) + return + } - const platform = process.platform - const arch = process.arch + const downloadResponse = await fetch(build.url) + if (!downloadResponse.ok) { + log.error("Failed to download terraform-ls") + return + } - const tfArch = arch === "arm64" ? "arm64" : "amd64" - const tfPlatform = platform === "win32" ? "windows" : platform + const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - const builds = release.builds ?? [] - const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) - if (!build?.url) { - log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) - return - } + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract terraform-ls archive", { error }) + return false + }) + if (!ok) return + await fs.rm(tempPath, { force: true }) - const downloadResponse = await fetch(build.url) - if (!downloadResponse.ok) { - log.error("Failed to download terraform-ls") - return - } + bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract terraform-ls binary") + return + } + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + log.info(`installed terraform-ls`, { bin }) + } + + return { + process: spawn(bin, ["serve"], { + cwd: root, + }), + initialization: { + experimentalFeatures: { + prefillRequiredFields: true, + validateOnSave: true, + }, + }, + } + }, +} + +export const TexLab: Info = { + id: "texlab", + extensions: [".tex", ".bib"], + root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), + async spawn(root) { + let bin = which("texlab") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading texlab from GitHub releases") + + const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") + if (!response.ok) { + log.error("Failed to fetch texlab release info") + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + const version = release.tag_name?.replace("v", "") + if (!version) { + log.error("texlab release did not include a version tag") + return + } + + const platform = process.platform + const arch = process.arch + + const texArch = arch === "arm64" ? "aarch64" : "x86_64" + const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" + const ext = platform === "win32" ? "zip" : "tar.gz" + const assetName = `texlab-${texArch}-${texPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + log.error(`Could not find asset ${assetName} in texlab release`) + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download texlab") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { const ok = await Archive.extractZip(tempPath, Global.Path.bin) .then(() => true) .catch((error) => { - log.error("Failed to extract terraform-ls archive", { error }) + log.error("Failed to extract texlab archive", { error }) return false }) if (!ok) return - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract terraform-ls binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info(`installed terraform-ls`, { bin }) + } + if (ext === "tar.gz") { + await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) } - return { - process: spawn(bin, ["serve"], { - cwd: root, - }), - initialization: { - experimentalFeatures: { - prefillRequiredFields: true, - validateOnSave: true, - }, - }, - } - }, - } + await fs.rm(tempPath, { force: true }) - export const TexLab: Info = { - id: "texlab", - extensions: [".tex", ".bib"], - root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), - async spawn(root) { - let bin = which("texlab") + bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading texlab from GitHub releases") - - const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") - if (!response.ok) { - log.error("Failed to fetch texlab release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - const version = release.tag_name?.replace("v", "") - if (!version) { - log.error("texlab release did not include a version tag") - return - } - - const platform = process.platform - const arch = process.arch - - const texArch = arch === "arm64" ? "aarch64" : "x86_64" - const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" - const ext = platform === "win32" ? "zip" : "tar.gz" - const assetName = `texlab-${texArch}-${texPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in texlab release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download texlab") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract texlab archive", { error }) - return false - }) - if (!ok) return - } - if (ext === "tar.gz") { - await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract texlab binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info("installed texlab", { bin }) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract texlab binary") + return } - return { - process: spawn(bin, { - cwd: root, - }), + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) } - }, - } - export const DockerfileLS: Info = { - id: "dockerfile", - extensions: [".dockerfile", "Dockerfile"], - root: async () => Instance.directory, - async spawn(root) { - let binary = which("docker-langserver") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("dockerfile-language-server-nodejs") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + log.info("installed texlab", { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async () => Instance.directory, + async spawn(root) { + let binary = which("docker-langserver") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("dockerfile-language-server-nodejs") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const Gleam: Info = { + id: "gleam", + extensions: [".gleam"], + root: NearestRoot(["gleam.toml"]), + async spawn(root) { + const gleam = which("gleam") + if (!gleam) { + log.info("gleam not found, please install gleam first") + return + } + return { + process: spawn(gleam, ["lsp"], { + cwd: root, + }), + } + }, +} + +export const Clojure: Info = { + id: "clojure-lsp", + extensions: [".clj", ".cljs", ".cljc", ".edn"], + root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), + async spawn(root) { + let bin = which("clojure-lsp") + if (!bin && process.platform === "win32") { + bin = which("clojure-lsp.exe") + } + if (!bin) { + log.info("clojure-lsp not found, please install clojure-lsp first") + return + } + return { + process: spawn(bin, ["listen"], { + cwd: root, + }), + } + }, +} + +export const Nixd: Info = { + id: "nixd", + extensions: [".nix"], + root: async (file) => { + // First, look for flake.nix - the most reliable Nix project root indicator + const flakeRoot = await NearestRoot(["flake.nix"])(file) + if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot + + // If no flake.nix, fall back to git repository root + if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree + + // Finally, use the instance directory as fallback + return Instance.directory + }, + async spawn(root) { + const nixd = which("nixd") + if (!nixd) { + log.info("nixd not found, please install nixd first") + return + } + return { + process: spawn(nixd, [], { cwd: root, env: { ...process.env, }, - }) - return { - process: proc, - } - }, - } - - export const Gleam: Info = { - id: "gleam", - extensions: [".gleam"], - root: NearestRoot(["gleam.toml"]), - async spawn(root) { - const gleam = which("gleam") - if (!gleam) { - log.info("gleam not found, please install gleam first") - return - } - return { - process: spawn(gleam, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Clojure: Info = { - id: "clojure-lsp", - extensions: [".clj", ".cljs", ".cljc", ".edn"], - root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), - async spawn(root) { - let bin = which("clojure-lsp") - if (!bin && process.platform === "win32") { - bin = which("clojure-lsp.exe") - } - if (!bin) { - log.info("clojure-lsp not found, please install clojure-lsp first") - return - } - return { - process: spawn(bin, ["listen"], { - cwd: root, - }), - } - }, - } - - export const Nixd: Info = { - id: "nixd", - extensions: [".nix"], - root: async (file) => { - // First, look for flake.nix - the most reliable Nix project root indicator - const flakeRoot = await NearestRoot(["flake.nix"])(file) - if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot - - // If no flake.nix, fall back to git repository root - if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree - - // Finally, use the instance directory as fallback - return Instance.directory - }, - async spawn(root) { - const nixd = which("nixd") - if (!nixd) { - log.info("nixd not found, please install nixd first") - return - } - return { - process: spawn(nixd, [], { - cwd: root, - env: { - ...process.env, - }, - }), - } - }, - } - - export const Tinymist: Info = { - id: "tinymist", - extensions: [".typ", ".typc"], - root: NearestRoot(["typst.toml"]), - async spawn(root) { - let bin = which("tinymist") - - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading tinymist from GitHub releases") - - const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") - if (!response.ok) { - log.error("Failed to fetch tinymist release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - - const platform = process.platform - const arch = process.arch - - const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" - let tinymistPlatform: string - let ext: string - - if (platform === "darwin") { - tinymistPlatform = "apple-darwin" - ext = "tar.gz" - } else if (platform === "win32") { - tinymistPlatform = "pc-windows-msvc" - ext = "zip" - } else { - tinymistPlatform = "unknown-linux-gnu" - ext = "tar.gz" - } - - const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in tinymist release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download tinymist") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract tinymist archive", { error }) - return false - }) - if (!ok) return - } else { - await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract tinymist binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info("installed tinymist", { bin }) - } - - return { - process: spawn(bin, { cwd: root }), - } - }, - } - - export const HLS: Info = { - id: "haskell-language-server", - extensions: [".hs", ".lhs"], - root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), - async spawn(root) { - const bin = which("haskell-language-server-wrapper") - if (!bin) { - log.info("haskell-language-server-wrapper not found, please install haskell-language-server") - return - } - return { - process: spawn(bin, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const JuliaLS: Info = { - id: "julials", - extensions: [".jl"], - root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), - async spawn(root) { - const julia = which("julia") - if (!julia) { - log.info("julia not found, please install julia first (https://julialang.org/downloads/)") - return - } - return { - process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { - cwd: root, - }), - } - }, - } + }), + } + }, +} + +export const Tinymist: Info = { + id: "tinymist", + extensions: [".typ", ".typc"], + root: NearestRoot(["typst.toml"]), + async spawn(root) { + let bin = which("tinymist") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading tinymist from GitHub releases") + + const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") + if (!response.ok) { + log.error("Failed to fetch tinymist release info") + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + + const platform = process.platform + const arch = process.arch + + const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" + let tinymistPlatform: string + let ext: string + + if (platform === "darwin") { + tinymistPlatform = "apple-darwin" + ext = "tar.gz" + } else if (platform === "win32") { + tinymistPlatform = "pc-windows-msvc" + ext = "zip" + } else { + tinymistPlatform = "unknown-linux-gnu" + ext = "tar.gz" + } + + const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + log.error(`Could not find asset ${assetName} in tinymist release`) + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download tinymist") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract tinymist archive", { error }) + return false + }) + if (!ok) return + } else { + await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract tinymist binary") + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + log.info("installed tinymist", { bin }) + } + + return { + process: spawn(bin, { cwd: root }), + } + }, +} + +export const HLS: Info = { + id: "haskell-language-server", + extensions: [".hs", ".lhs"], + root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), + async spawn(root) { + const bin = which("haskell-language-server-wrapper") + if (!bin) { + log.info("haskell-language-server-wrapper not found, please install haskell-language-server") + return + } + return { + process: spawn(bin, ["--lsp"], { + cwd: root, + }), + } + }, +} + +export const JuliaLS: Info = { + id: "julials", + extensions: [".jl"], + root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), + async spawn(root) { + const julia = which("julia") + if (!julia) { + log.info("julia not found, please install julia first (https://julialang.org/downloads/)") + return + } + return { + process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { + cwd: root, + }), + } + }, } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 414d11f8e7..f124fddf95 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeEach } from "bun:test" import path from "path" -import { LSPClient } from "../../src/lsp/client" -import { LSPServer } from "../../src/lsp/server" +import { LSPClient } from "../../src/lsp" +import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index b12a61ae3c..7419f3bf5c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" -import { LSPServer } from "../../src/lsp/server" +import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index a6de869fcb..fe14729736 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" -import { LSPServer } from "../../src/lsp/server" +import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" From 0e2038239699b430595980be2939c08c5e4cde93 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:41:34 -0400 Subject: [PATCH 56/75] fix: resolve circular sibling imports causing runtime ReferenceError (#22752) --- packages/opencode/src/config/config.ts | 6 +++--- packages/opencode/src/config/tui-migrate.ts | 2 +- packages/opencode/src/config/tui-schema.ts | 2 +- packages/opencode/src/config/tui.ts | 4 ++-- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/effect/instance-state.ts | 2 +- packages/opencode/src/effect/observability.ts | 2 +- packages/opencode/src/effect/run-service.ts | 2 +- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/lsp/lsp.ts | 4 ++-- packages/opencode/src/project/bootstrap.ts | 4 ++-- packages/opencode/src/project/instance.ts | 2 +- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/projectors.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/revert.ts | 2 +- packages/opencode/src/session/run-state.ts | 2 +- packages/opencode/src/session/summary.ts | 2 +- packages/opencode/src/share/session.ts | 2 +- packages/opencode/src/util/archive.ts | 2 +- 23 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d8cfd5e48f..8690dbda2d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,9 +19,9 @@ import { printParseErrorCode, } from "jsonc-parser" import { Instance, type InstanceContext } from "../project/instance" -import { LSPServer } from "../lsp" +import * as LSPServer from "../lsp/server" import { Installation } from "@/installation" -import { ConfigMarkdown } from "." +import * as ConfigMarkdown from "./markdown" import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" @@ -29,7 +29,7 @@ import { Event } from "../server/event" import { Glob } from "@opencode-ai/shared/util/glob" import { Account } from "@/account" import { isRecord } from "@/util/record" -import { ConfigPaths } from "." +import * as ConfigPaths from "./paths" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index 18cee554d5..ed19474be2 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -2,7 +2,7 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" import z from "zod" -import { ConfigPaths } from "." +import * as ConfigPaths from "./paths" import { TuiInfo, TuiOptions } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index fd5cd8c88d..3be988370d 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -1,5 +1,5 @@ import z from "zod" -import { Config } from "." +import * as Config from "./config" const KeybindOverride = z .object( diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 43f1bce460..3cde908b03 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,8 +2,8 @@ import { existsSync } from "fs" import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" -import { Config } from "." -import { ConfigPaths } from "." +import * as Config from "./config" +import * as ConfigPaths from "./paths" import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3e28183448..0b76e96a84 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,6 +1,6 @@ import { Layer, ManagedRuntime } from "effect" import { attach, memoMap } from "./run-service" -import { Observability } from "." +import * as Observability from "./observability" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 208a83bf85..89cc071561 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -10,7 +10,7 @@ import { File } from "@/file" import { Vcs } from "@/project" import { Snapshot } from "@/snapshot" import { Bus } from "@/bus" -import { Observability } from "." +import * as Observability from "./observability" export const BootstrapLayer = Layer.mergeAll( Plugin.defaultLayer, diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index d71f82df97..7095657f5d 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,5 +1,5 @@ import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" -import { EffectLogger } from "@/effect" +import * as EffectLogger from "./logger" import { Instance, type InstanceContext } from "@/project/instance" import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index 4e8ae22217..2f4040113d 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -1,7 +1,7 @@ import { Effect, Layer, Logger } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" -import { EffectLogger } from "@/effect" +import * as EffectLogger from "./logger" import { Flag } from "@/flag/flag" import { CHANNEL, VERSION } from "@/installation/meta" diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index a9d653b108..28265f9b27 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util" import { InstanceRef, WorkspaceRef } from "./instance-ref" -import { Observability } from "." +import * as Observability from "./observability" import { WorkspaceContext } from "@/control-plane/workspace-context" import type { InstanceContext } from "@/project/instance" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index fed2bf5c99..59a64ca1ed 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -8,7 +8,7 @@ import { Log } from "../util" import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" -import type { LSPServer } from "." +import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 7f5b36313d..2c0982eca5 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,10 +1,10 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Log } from "../util" -import { LSPClient } from "." +import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" -import { LSPServer } from "." +import * as LSPServer from "./server" import z from "zod" import { Config } from "../config" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 27ed35b7f0..a405607bea 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -3,8 +3,8 @@ import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" import { Snapshot } from "../snapshot" -import { Project } from "." -import { Vcs } from "." +import * as Project from "./project" +import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index b95962ae08..056eede01b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util" import { LocalContext } from "../util" -import { Project } from "." +import * as Project from "./project" import { WorkspaceContext } from "@/control-plane/workspace-context" export interface InstanceContext { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7b83c245f4..a0d3e2258a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -2,7 +2,7 @@ import type { ModelMessage } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" -import type { Provider } from "." +import type * as Provider from "./provider" import type { ModelsDev } from "./models" import { iife } from "@/util/iife" import { Flag } from "@/flag/flag" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 5ad80b6b02..3ef6977547 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { Session } from "." +import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "../provider" import { MessageV2 } from "./message-v2" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 72b27403bd..415639fbe5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -6,7 +6,7 @@ import { Config } from "@/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { Snapshot } from "@/snapshot" -import { Session } from "." +import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" import { isOverflow } from "./overflow" diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 9a36ef5b3b..fb8354dda1 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -1,6 +1,6 @@ import { NotFoundError, eq, and } from "../storage" import { SyncEvent } from "@/sync" -import { Session } from "." +import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { Log } from "../util" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1d4bb66bc5..65fc7c8c70 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,7 +5,7 @@ import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util" import { SessionRevert } from "./revert" -import { Session } from "." +import * as Session from "./session" import { Agent } from "../agent/agent" import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 93d0e6219c..f09ccf24ad 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -5,7 +5,7 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage" import { SyncEvent } from "../sync" import { Log } from "../util" -import { Session } from "." +import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" import { SessionRunState } from "./run-state" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 179f287fa8..a18e0b5732 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,7 +1,7 @@ import { InstanceState } from "@/effect" import { Runner } from "@/effect" import { Effect, Layer, Scope, Context } from "effect" -import { Session } from "." +import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID } from "./schema" import { SessionStatus } from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 21203c326b..9f8e70f162 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Context } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage" -import { Session } from "." +import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 71fa17c889..63b7670785 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" import { Config } from "../config" import { Flag } from "../flag/flag" -import { ShareNext } from "." +import * as ShareNext from "./share-next" export interface Interface { readonly create: (input?: Session.CreateInput) => Effect.Effect diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts index 21d014c6a8..97fe6aefb2 100644 --- a/packages/opencode/src/util/archive.ts +++ b/packages/opencode/src/util/archive.ts @@ -1,5 +1,5 @@ import path from "path" -import { Process } from "." +import * as Process from "./process" export async function extractZip(zipPath: string, destDir: string) { if (process.platform === "win32") { From 225a769411f35a0e2dd357589374766dae77ae6a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 03:42:25 +0000 Subject: [PATCH 57/75] chore: generate --- packages/opencode/src/lsp/server.ts | 4 +--- packages/opencode/src/project/project.ts | 7 ++----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 25aaaa36a4..390c5f2428 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -457,9 +457,7 @@ export const Ty: Info = { if (!binary) { for (const venvPath of potentialVenvPaths) { const isWindows = process.platform === "win32" - const potentialTyPath = isWindows - ? path.join(venvPath, "Scripts", "ty.exe") - : path.join(venvPath, "bin", "ty") + const potentialTyPath = isWindows ? path.join(venvPath, "Scripts", "ty.exe") : path.join(venvPath, "bin", "ty") if (await Filesystem.exists(potentialTyPath)) { binary = potentialTyPath break diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 050951a606..f838d9ab43 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -54,9 +54,7 @@ type Row = typeof ProjectTable.$inferSelect export function fromRow(row: Row): Info { const icon = - row.icon_url || row.icon_color - ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } - : undefined + row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { id: row.id, worktree: row.worktree, @@ -256,8 +254,7 @@ export const layer: Layer.Layer< time: { created: Date.now(), updated: Date.now() }, } - if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) - yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) const result: Info = { ...existing, From c802695ee9555ccfd8b0a6ae2215f750bccda712 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:44:08 -0400 Subject: [PATCH 58/75] docs: add circular import rules to namespace treeshake spec (#22754) --- .../specs/effect/namespace-treeshake.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/opencode/specs/effect/namespace-treeshake.md b/packages/opencode/specs/effect/namespace-treeshake.md index 8a9cf94fd4..5d1fbd07e5 100644 --- a/packages/opencode/specs/effect/namespace-treeshake.md +++ b/packages/opencode/specs/effect/namespace-treeshake.md @@ -442,3 +442,58 @@ Going forward: - If a file grows large enough that it's hard to navigate, split by concern (errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the bundler handles that. + +## Circular import rules + +Barrel files (`index.ts` with `export * as`) introduce circular import risks. +These cause `ReferenceError: Cannot access 'X' before initialization` at +runtime — not caught by the type checker. + +### Rule 1: Sibling files never import through their own barrel + +Files in the same directory must import directly from the source file, never +through `"."` or `"@/"`: + +```ts +// BAD — circular: index.ts re-exports both files, so A → index → B → index → A +import { Sibling } from "." + +// GOOD — direct, no cycle +import * as Sibling from "./sibling" +``` + +### Rule 2: Cross-directory imports must not form cycles through barrels + +If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and +`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle: + +``` +lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥 +``` + +Fix by importing the specific file, breaking the cycle: + +```ts +// In config/config.ts — import directly, not through the lsp barrel +import * as LSPServer from "../lsp/server" +``` + +### Why the type checker doesn't catch this + +TypeScript resolves types lazily — it doesn't evaluate module-scope +expressions. The `ReferenceError` only happens at runtime when a module-scope +`const` or function call accesses a value from a circular dependency that +hasn't finished initializing. The SDK build step (`bun run --conditions=browser +./src/index.ts generate`) is the reliable way to catch these because it +evaluates all modules eagerly. + +### How to verify + +After any namespace conversion, run: + +```bash +cd packages/opencode +bun run --conditions=browser ./src/index.ts generate +``` + +If this completes without `ReferenceError`, the module graph is safe. From 8aa0f9fe9515ba0234ab6a0a58c868068913bb05 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:50:47 -0400 Subject: [PATCH 59/75] feat: enable type-aware no-base-to-string rule, fix 56 violations (#22750) --- .oxlintrc.json | 4 ++++ .../workspace/[id]/billing/black-section.tsx | 4 ++-- .../[id]/billing/monthly-limit-section.tsx | 4 ++-- .../workspace/[id]/billing/reload-section.tsx | 22 +++++++++---------- .../routes/workspace/[id]/go/lite-section.tsx | 4 ++-- .../workspace/[id]/keys/key-section.tsx | 8 +++---- .../workspace/[id]/members/member-section.tsx | 22 +++++++++---------- .../routes/workspace/[id]/model-section.tsx | 8 +++---- .../workspace/[id]/provider-section.tsx | 17 ++++++++------ .../[id]/settings/settings-section.tsx | 4 ++-- packages/opencode/script/schema.ts | 2 +- packages/opencode/src/effect/logger.ts | 2 ++ .../src/plugin/github-copilot/copilot.ts | 2 +- packages/opencode/src/provider/transform.ts | 4 ++-- packages/opencode/src/pty/service.ts | 2 +- packages/opencode/src/snapshot/snapshot.ts | 2 +- packages/opencode/src/tool/apply_patch.ts | 10 ++++++++- packages/opencode/src/util/error.ts | 1 + packages/opencode/src/v2/session-entry.ts | 1 + packages/opencode/test/config/config.test.ts | 4 ++-- packages/shared/src/util/retry.ts | 1 + .../shared/test/util/effect-flock.test.ts | 1 + .../.storybook/mocks/app/context/language.ts | 1 + packages/ui/src/components/file.tsx | 3 +++ packages/ui/src/components/session-turn.tsx | 1 + packages/web/src/components/share/part.tsx | 8 ++++++- 26 files changed, 87 insertions(+), 55 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index e16c8408d6..a0b620649f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,9 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", + "options": { + "typeAware": true + }, "categories": { "suspicious": "warn" }, "rules": { + "typescript/no-base-to-string": "warn", // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield "require-yield": "off", // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx index b8f089864d..5b4389466e 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx @@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index ef54b84099..7da1de2389 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx @@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error" const setMonthlyLimit = action(async (form: FormData) => { "use server" - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null if (!limit) return { error: formError.limitRequired } const numericLimit = parseInt(limit) if (numericLimit < 0) return { error: formError.monthlyLimitInvalid } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index a25963ab07..c9a72c0879 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz const reload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: queryBillingInfo.key, @@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => { const setReload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const reloadValue = form.get("reload")?.toString() === "true" - const amountStr = form.get("reloadAmount")?.toString() - const triggerStr = form.get("reloadTrigger")?.toString() + const reloadValue = (form.get("reload") as string | null) === "true" + const amountStr = form.get("reloadAmount") as string | null + const triggerStr = form.get("reloadTrigger") as string | null const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null @@ -91,8 +91,8 @@ export function ReloadSection() { const info = billingInfo()! setStore("show", true) setStore("reload", true) - setStore("reloadAmount", info.reloadAmount.toString()) - setStore("reloadTrigger", info.reloadTrigger.toString()) + setStore("reloadAmount", String(info.reloadAmount)) + setStore("reloadTrigger", String(info.reloadTrigger)) } function hide() { @@ -152,11 +152,11 @@ export function ReloadSection() { data-component="input" name="reloadAmount" type="number" - min={billingInfo()?.reloadAmountMin.toString()} + min={String(billingInfo()?.reloadAmountMin ?? "")} step="1" value={store.reloadAmount} onInput={(e) => setStore("reloadAmount", e.currentTarget.value)} - placeholder={billingInfo()?.reloadAmount.toString()} + placeholder={String(billingInfo()?.reloadAmount ?? "")} disabled={!store.reload} /> @@ -166,11 +166,11 @@ export function ReloadSection() { data-component="input" name="reloadTrigger" type="number" - min={billingInfo()?.reloadTriggerMin.toString()} + min={String(billingInfo()?.reloadTriggerMin ?? "")} step="1" value={store.reloadTrigger} onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)} - placeholder={billingInfo()?.reloadTrigger.toString()} + placeholder={String(billingInfo()?.reloadTrigger ?? "")} disabled={!store.reload} /> diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 95ff7af2b9..d0f8121828 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setLiteUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index 837ab743a5..cb273a422e 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error" const removeKey = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key }) }, "key.remove") const createKey = action(async (form: FormData) => { "use server" - const name = form.get("name")?.toString().trim() + const name = (form.get("name") as string | null)?.trim() if (!name) return { error: formError.nameRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx index 5a440632f8..00edb400c9 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => { const inviteMember = action(async (form: FormData) => { "use server" - const email = form.get("email")?.toString().trim() + const email = (form.get("email") as string | null)?.trim() if (!email) return { error: formError.emailRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } return json( @@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => { const removeMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( @@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => { const updateMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } @@ -118,7 +118,7 @@ function MemberRow(props: { } setStore("editing", true) setStore("selectedRole", props.member.role) - setStore("limit", props.member.monthlyLimit?.toString() ?? "") + setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "") } function hide() { diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index bf19f81cd2..b9cdf3bc3a 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => { const updateModel = action(async (form: FormData) => { "use server" - const model = form.get("model")?.toString() + const model = form.get("model") as string | null if (!model) return { error: formError.modelRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const enabled = form.get("enabled")?.toString() === "true" + const enabled = (form.get("enabled") as string | null) === "true" return json( withActor(async () => { if (enabled) { @@ -163,7 +163,7 @@ export function ModelSection() {
- +
{arg[0]}
-
{String(arg[1] ?? "")}
+
+ {typeof arg[1] === "string" || typeof arg[1] === "number" || typeof arg[1] === "boolean" + ? String(arg[1]) + : arg[1] == null + ? "" + : JSON.stringify(arg[1])} +
)} From bd2900483150d690acdc53acb37e98eda7bb7fe5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:50:50 -0400 Subject: [PATCH 60/75] feat: enable type-aware no-misused-spread rule, fix 8 violations (#22749) --- .opencode/tool/github-pr-search.ts | 2 +- .opencode/tool/github-triage.ts | 2 +- .oxlintrc.json | 9 +++++++-- packages/app/src/utils/server.ts | 5 ++++- .../app/src/routes/download/[channel]/[platform].ts | 2 +- packages/opencode/src/cli/cmd/tui/component/logo.tsx | 2 +- packages/opencode/src/server/ui/index.ts | 4 ++-- packages/opencode/src/v2/session-event.ts | 6 +++++- 8 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.opencode/tool/github-pr-search.ts b/.opencode/tool/github-pr-search.ts index 927e68fd73..8bc8c554aa 100644 --- a/.opencode/tool/github-pr-search.ts +++ b/.opencode/tool/github-pr-search.ts @@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", - ...options.headers, + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), }, }) if (!response.ok) { diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index c06d2407fe..dcbfc8d054 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", - ...options.headers, + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), }, }) if (!response.ok) { diff --git a/.oxlintrc.json b/.oxlintrc.json index a0b620649f..f1ca1ff46f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -37,10 +37,15 @@ "no-new": "off", // Type-aware: catch unhandled promises - "typescript/no-floating-promises": "warn" + "typescript/no-floating-promises": "warn", + // Warn when spreading non-plain objects (Headers, class instances, etc.) + "typescript/no-misused-spread": "warn" }, "options": { "typeAware": true }, - "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] + "options": { + "typeAware": true + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"] } diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 17f4a3adce..ae849b71ee 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -16,7 +16,10 @@ export function createSdkForServer({ return createOpencodeClient({ ...config, - headers: { ...config.headers, ...auth }, + headers: { + ...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers), + ...auth, + }, baseUrl: server.url, }) } diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index e9b3f23e79..82d2f1d01c 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -37,5 +37,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const headers = new Headers(resp.headers) if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`) - return new Response(resp.body, { ...resp, headers }) + return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers }) } diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 51cf69dc1f..d41d36a6e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -520,7 +520,7 @@ export function Logo() { const shadow = tint(theme.background, ink, 0.25) const attrs = bold ? TextAttributes.BOLD : undefined - return [...line].map((char, i) => { + return Array.from(line).map((char, i) => { const h = field(off + i, y, frame) const n = wave(off + i, y, frame, lit(char)) + h const s = wave(off + i, y, dusk, false) + h diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/ui/index.ts index afe6e510f1..d449cd1c42 100644 --- a/packages/opencode/src/server/ui/index.ts +++ b/packages/opencode/src/server/ui/index.ts @@ -37,9 +37,9 @@ export const UIRoutes = (): Hono => } } else { const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, + raw: c.req.raw, headers: { - ...c.req.raw.headers, + ...Object.fromEntries(c.req.raw.headers.entries()), host: "app.opencode.ai", }, }) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f662f05e71..8ea239033f 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -39,7 +39,11 @@ export namespace SessionEvent { }) { static create(input: FileAttachment) { return new FileAttachment({ - ...input, + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, }) } } From 9f4b73b6a330dc606faa22e44454638fa45e49ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:54:21 -0400 Subject: [PATCH 61/75] fix: clean up final 16 no-unused-vars warnings (#22751) --- packages/app/src/components/terminal.tsx | 4 ++-- packages/app/src/i18n/ko.ts | 2 -- packages/app/src/pages/session.tsx | 1 - packages/console/app/src/routes/index.tsx | 3 +-- packages/opencode/script/postinstall.mjs | 17 ----------------- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/session/llm.ts | 3 +-- .../test/acp/event-subscription.test.ts | 4 ++-- packages/opencode/test/config/config.test.ts | 2 -- packages/opencode/test/effect/runner.test.ts | 1 - packages/opencode/test/mcp/lifecycle.test.ts | 3 +-- packages/ui/src/components/session-review.tsx | 2 -- .../components/timeline-playground.stories.tsx | 1 - 13 files changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index db7d53f2b6..57e91d6d33 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => { const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined let ws: WebSocket | undefined let term: Term | undefined - let ghostty: Ghostty + let _ghostty: Ghostty let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void @@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => { cleanup() return } - ghostty = g + _ghostty = g term = t output = terminalWriter((data, done) => t.write(data, () => { diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1c15720091..2b5ccd43d9 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -1,5 +1,3 @@ -import { dict as en } from "./en" - export const dict = { "command.category.suggested": "추천", "command.category.view": "보기", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c63bbc4f93..1aba1bb08f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -432,7 +432,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) - const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index ee40ded87b..c046a56a29 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -12,7 +12,6 @@ import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { github } from "~/lib/github" -import { createMemo } from "solid-js" import { config } from "~/config" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" @@ -30,7 +29,7 @@ function CopyStatus() { export default function Home() { const i18n = useI18n() const language = useLanguage() - const githubData = createAsync(() => github()) + const _githubData = createAsync(() => github()) const handleCopyClick = (event: Event) => { const button = event.currentTarget as HTMLButtonElement const text = button.textContent diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 99f8bf4321..7c6f85d2b1 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -68,23 +68,6 @@ function findBinary() { } } -function prepareBinDirectory(binaryName) { - const binDir = path.join(__dirname, "bin") - const targetPath = path.join(binDir, binaryName) - - // Ensure bin directory exists - if (!fs.existsSync(binDir)) { - fs.mkdirSync(binDir, { recursive: true }) - } - - // Remove existing binary/symlink if it exists - if (fs.existsSync(targetPath)) { - fs.unlinkSync(targetPath) - } - - return { binDir, targetPath } -} - async function main() { try { if (os.platform() === "win32") { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8690dbda2d..66471e908a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1297,7 +1297,7 @@ export const layer: Layer.Layer< yield* Effect.promise(() => Npm.install(dir)) }) - const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) { + const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, _input?: InstallInput) { if ( !(yield* fs.access(dir, { writable: true }).pipe( Effect.as(true), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 8f93bd5e15..0652a599a2 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -277,11 +277,10 @@ export namespace LLM { } const id = PermissionID.ascending() - let reply: Permission.Reply | undefined let unsub: (() => void) | undefined try { unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { - if (evt.properties.requestID === id) reply = evt.properties.reply + if (evt.properties.requestID === id) void evt.properties.reply }) const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { try { diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index a0944e33b7..bce5e94598 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -463,9 +463,9 @@ describe("acp.agent event subscription", () => { // Make permission request for session A block until we release it const originalRequestPermission = connection.requestPermission.bind(connection) - let permissionCalls = 0 + let _permissionCalls = 0 connection.requestPermission = async (params: RequestPermissionParams) => { - permissionCalls++ + _permissionCalls++ if (params.sessionId.endsWith("1")) { await permissionABlocking } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6a79cf6449..e309416f1d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -858,7 +858,6 @@ it.live("dedupes concurrent config dependency installs for the same dir", () => let calls = 0 const online = spyOn(Network, "online").mockReturnValue(false) const ready = Deferred.makeUnsafe() - const blocked = Deferred.makeUnsafe() const hold = Deferred.makeUnsafe() const target = path.normalize(dir) const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { @@ -921,7 +920,6 @@ it.live("serializes config dependency installs across dirs", () => let open = 0 let peak = 0 const ready = Deferred.makeUnsafe() - const blocked = Deferred.makeUnsafe() const hold = Deferred.makeUnsafe() const online = spyOn(Network, "online").mockReturnValue(false) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 241e7c2a88..deebdcaa33 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -395,7 +395,6 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const gate = yield* Deferred.make() const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 31712f1561..1b459481f3 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -652,9 +652,8 @@ test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses o // The callback should still be pending because cancelPending looked up // "my-mcp-server" in a map keyed by "abc123hexstate" - let resolved = false let rejected = false - callbackPromise.then(() => (resolved = true)).catch(() => (rejected = true)) + callbackPromise.then(() => {}).catch(() => (rejected = true)) // Give it a tick await new Promise((r) => setTimeout(r, 50)) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index bb19d099e0..6e2b0853ac 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -385,7 +385,6 @@ export const SessionReview = (props: SessionReviewProps) => { {(diff) => { - let wrapper: HTMLDivElement | undefined const file = diff.file // binary files have empty diffs that we can't render @@ -569,7 +568,6 @@ export const SessionReview = (props: SessionReviewProps) => {
{ - wrapper = el anchors.set(file, el) nodes.set(file, el) queue() diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index fa3e7ff798..c071db303b 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -9,7 +9,6 @@ import type { TextPart, ReasoningPart, ToolPart, - CompactionPart, FilePart, AgentPart, } from "@opencode-ai/sdk/v2" From f6cc228684ef9022c93a158b3fd1cd69c677ec1a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:56:51 -0400 Subject: [PATCH 62/75] feat: unwrap cli-tui namespaces to flat exports + barrel (#22759) --- packages/opencode/src/cli/cmd/tui/app.tsx | 6 +- .../cli/cmd/tui/component/dialog-provider.tsx | 2 +- .../cli/cmd/tui/component/error-component.tsx | 2 +- .../src/cli/cmd/tui/component/logo.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 4 +- .../cmd/tui/routes/session/dialog-message.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 2 +- .../src/cli/cmd/tui/util/clipboard.ts | 294 +++++++++--------- .../opencode/src/cli/cmd/tui/util/editor.ts | 46 ++- .../opencode/src/cli/cmd/tui/util/index.ts | 5 + .../src/cli/cmd/tui/util/selection.ts | 20 +- .../opencode/src/cli/cmd/tui/util/sound.ts | 214 +++++++------ .../opencode/src/cli/cmd/tui/util/terminal.ts | 230 +++++++------- 14 files changed, 414 insertions(+), 419 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/index.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 9e96d5dcbc..5102169b5c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,7 +1,7 @@ import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" -import { Selection } from "@tui/util/selection" -import { Terminal } from "@tui/util/terminal" +import * as Clipboard from "@tui/util/clipboard" +import * as Selection from "@tui/util/selection" +import * as Terminal from "@tui/util/terminal" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index c0e39e0e21..8e24ffb1b1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" import { useKeyboard } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index e8758b3d7f..38df35a04a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import { createSignal } from "solid-js" import { Installation } from "@/installation" import { win32FlushInputBuffer } from "../win32" diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index d41d36a6e1..e53974871a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,7 +1,7 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import { Sound } from "@tui/util/sound" +import * as Sound from "@tui/util/sound" import { logo } from "@/cli/logo" // Shadow markers (rendered chars in parens): diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b80c32243f..20003d8467 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" import { useRenderer, type JSX } from "@opentui/solid" -import { Editor } from "@tui/util/editor" +import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" -import { Clipboard } from "../../util/clipboard" +import * as Clipboard from "../../util/clipboard" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 835ac8f5d5..412b4d87eb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync" import { DialogSelect } from "@tui/ui/dialog-select" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import type { PromptInfo } from "@tui/component/prompt/history" import { strip } from "@tui/component/prompt/part" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2ea936c898..75098b6083 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -66,10 +66,10 @@ import { SubagentFooter } from "./subagent-footer.tsx" import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" -import { Clipboard } from "../../util/clipboard" +import * as Clipboard from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" -import { Editor } from "../../util/editor" +import * as Editor from "../../util/editor" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 11c43fe24c..29eb6fd4cb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@/flag/flag" -import { Selection } from "@tui/util/selection" +import * as Selection from "@tui/util/selection" export function Dialog( props: ParentProps<{ diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index a67eb04f69..6968b07eb4 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -22,171 +22,169 @@ function writeOsc52(text: string): void { process.stdout.write(sequence) } -export namespace Clipboard { - export interface Content { - data: string - mime: string - } +export interface Content { + data: string + mime: string +} - // Checks clipboard for images first, then falls back to text. - // - // On Windows prompt/ can call this from multiple paste signals because - // terminals surface image paste differently: - // 1. A forwarded Ctrl+V keypress - // 2. An empty bracketed-paste hint for image-only clipboard in Windows - // Terminal <1.25 - // 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ - export async function read(): Promise { - const os = platform() +// Checks clipboard for images first, then falls back to text. +// +// On Windows prompt/ can call this from multiple paste signals because +// terminals surface image paste differently: +// 1. A forwarded Ctrl+V keypress +// 2. An empty bracketed-paste hint for image-only clipboard in Windows +// Terminal <1.25 +// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ +export async function read(): Promise { + const os = platform() - if (os === "darwin") { - const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") - try { - await Process.run( - [ - "osascript", - "-e", - 'set imageData to the clipboard as "PNGf"', - "-e", - `set fileRef to open for access POSIX file "${tmpfile}" with write permission`, - "-e", - "set eof fileRef to 0", - "-e", - "write imageData to fileRef", - "-e", - "close access fileRef", - ], - { nothrow: true }, - ) - const buffer = await Filesystem.readBytes(tmpfile) - return { data: buffer.toString("base64"), mime: "image/png" } - } catch { - } finally { - await fs.rm(tmpfile, { force: true }).catch(() => {}) - } - } - - // Windows/WSL: probe clipboard for images via PowerShell. - // Bracketed paste can't carry image data so we read it directly. - if (os === "win32" || release().includes("WSL")) { - const script = - "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" - const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { - nothrow: true, - }) - if (base64.text) { - const imageBuffer = Buffer.from(base64.text.trim(), "base64") - if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } - } - } - } - - if (os === "linux") { - const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) - if (wayland.stdout.byteLength > 0) { - return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } - } - const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { - nothrow: true, - }) - if (x11.stdout.byteLength > 0) { - return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } - } - } - - const text = await clipboardy.read().catch(() => {}) - if (text) { - return { data: text, mime: "text/plain" } + if (os === "darwin") { + const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") + try { + await Process.run( + [ + "osascript", + "-e", + 'set imageData to the clipboard as "PNGf"', + "-e", + `set fileRef to open for access POSIX file "${tmpfile}" with write permission`, + "-e", + "set eof fileRef to 0", + "-e", + "write imageData to fileRef", + "-e", + "close access fileRef", + ], + { nothrow: true }, + ) + const buffer = await Filesystem.readBytes(tmpfile) + return { data: buffer.toString("base64"), mime: "image/png" } + } catch { + } finally { + await fs.rm(tmpfile, { force: true }).catch(() => {}) } } - const getCopyMethod = lazy(() => { - const os = platform() - - if (os === "darwin" && which("osascript")) { - console.log("clipboard: using osascript") - return async (text: string) => { - const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) + // Windows/WSL: probe clipboard for images via PowerShell. + // Bracketed paste can't carry image data so we read it directly. + if (os === "win32" || release().includes("WSL")) { + const script = + "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" + const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { + nothrow: true, + }) + if (base64.text) { + const imageBuffer = Buffer.from(base64.text.trim(), "base64") + if (imageBuffer.length > 0) { + return { data: imageBuffer.toString("base64"), mime: "image/png" } } } + } - if (os === "linux") { - if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { - console.log("clipboard: using wl-copy") - return async (text: string) => { - const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } - if (which("xclip")) { - console.log("clipboard: using xclip") - return async (text: string) => { - const proc = Process.spawn(["xclip", "-selection", "clipboard"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } - if (which("xsel")) { - console.log("clipboard: using xsel") - return async (text: string) => { - const proc = Process.spawn(["xsel", "--clipboard", "--input"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } + if (os === "linux") { + const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) + if (wayland.stdout.byteLength > 0) { + return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } } + const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { + nothrow: true, + }) + if (x11.stdout.byteLength > 0) { + return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } + } + } - if (os === "win32") { - console.log("clipboard: using powershell") + const text = await clipboardy.read().catch(() => {}) + if (text) { + return { data: text, mime: "text/plain" } + } +} + +const getCopyMethod = lazy(() => { + const os = platform() + + if (os === "darwin" && which("osascript")) { + console.log("clipboard: using osascript") + return async (text: string) => { + const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) + } + } + + if (os === "linux") { + if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { + console.log("clipboard: using wl-copy") return async (text: string) => { - // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) - const proc = Process.spawn( - [ - "powershell.exe", - "-NonInteractive", - "-NoProfile", - "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", - ], - { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }, - ) - + const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) } } - - console.log("clipboard: no native support") - return async (text: string) => { - await clipboardy.write(text).catch(() => {}) + if (which("xclip")) { + console.log("clipboard: using xclip") + return async (text: string) => { + const proc = Process.spawn(["xclip", "-selection", "clipboard"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } + } + if (which("xsel")) { + console.log("clipboard: using xsel") + return async (text: string) => { + const proc = Process.spawn(["xsel", "--clipboard", "--input"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } } - }) - - export async function copy(text: string): Promise { - writeOsc52(text) - await getCopyMethod()(text) } + + if (os === "win32") { + console.log("clipboard: using powershell") + return async (text: string) => { + // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) + const proc = Process.spawn( + [ + "powershell.exe", + "-NonInteractive", + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ], + { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }, + ) + + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } + } + + console.log("clipboard: no native support") + return async (text: string) => { + await clipboardy.write(text).catch(() => {}) + } +}) + +export async function copy(text: string): Promise { + writeOsc52(text) + await getCopyMethod()(text) } diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index 540cf6f497..26e595dfbc 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core" import { Filesystem } from "@/util" import { Process } from "@/util" -export namespace Editor { - export async function open(opts: { value: string; renderer: CliRenderer }): Promise { - const editor = process.env["VISUAL"] || process.env["EDITOR"] - if (!editor) return +export async function open(opts: { value: string; renderer: CliRenderer }): Promise { + const editor = process.env["VISUAL"] || process.env["EDITOR"] + if (!editor) return - const filepath = join(tmpdir(), `${Date.now()}.md`) - await using _ = defer(async () => rm(filepath, { force: true })) + const filepath = join(tmpdir(), `${Date.now()}.md`) + await using _ = defer(async () => rm(filepath, { force: true })) - await Filesystem.write(filepath, opts.value) - opts.renderer.suspend() + await Filesystem.write(filepath, opts.value) + opts.renderer.suspend() + opts.renderer.currentRenderBuffer.clear() + try { + const parts = editor.split(" ") + const proc = Process.spawn([...parts, filepath], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + shell: process.platform === "win32", + }) + await proc.exited + const content = await Filesystem.readText(filepath) + return content || undefined + } finally { opts.renderer.currentRenderBuffer.clear() - try { - const parts = editor.split(" ") - const proc = Process.spawn([...parts, filepath], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - shell: process.platform === "win32", - }) - await proc.exited - const content = await Filesystem.readText(filepath) - return content || undefined - } finally { - opts.renderer.currentRenderBuffer.clear() - opts.renderer.resume() - opts.renderer.requestRender() - } + opts.renderer.resume() + opts.renderer.requestRender() } } diff --git a/packages/opencode/src/cli/cmd/tui/util/index.ts b/packages/opencode/src/cli/cmd/tui/util/index.ts new file mode 100644 index 0000000000..a0bdbc3c28 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/index.ts @@ -0,0 +1,5 @@ +export * as Editor from "./editor" +export * as Selection from "./selection" +export * as Sound from "./sound" +export * as Terminal from "./terminal" +export * as Clipboard from "./clipboard" diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 1230852dcc..d677972ee8 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -1,4 +1,4 @@ -import { Clipboard } from "./clipboard" +import * as Clipboard from "./clipboard" type Toast = { show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void @@ -10,16 +10,14 @@ type Renderer = { clearSelection: () => void } -export namespace Selection { - export function copy(renderer: Renderer, toast: Toast): boolean { - const text = renderer.getSelection()?.getSelectedText() - if (!text) return false +export function copy(renderer: Renderer, toast: Toast): boolean { + const text = renderer.getSelection()?.getSelectedText() + if (!text) return false - Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) + Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) - renderer.clearSelection() - return true - } + renderer.clearSelection() + return true } diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts index 1be35eecbf..e0a15c1a70 100644 --- a/packages/opencode/src/cli/cmd/tui/util/sound.ts +++ b/packages/opencode/src/cli/cmd/tui/util/sound.ts @@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) { return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] } -export namespace Sound { - let item: Player | null | undefined - let kind: Kind | null | undefined - let proc: Process.Child | undefined - let tail: ReturnType | undefined - let cache: Promise<{ hum: string; pulse: string[] }> | undefined - let seq = 0 - let shot = 0 +let item: Player | null | undefined +let kind: Kind | null | undefined +let proc: Process.Child | undefined +let tail: ReturnType | undefined +let cache: Promise<{ hum: string; pulse: string[] }> | undefined +let seq = 0 +let shot = 0 - function load() { - if (item !== undefined) return item - try { - item = new Player({ volume: 0.35 }) - } catch { - item = null - } - return item - } - - async function file(path: string) { - mkdirSync(DIR, { recursive: true }) - const next = join(DIR, basename(path)) - const out = Bun.file(next) - if (await out.exists()) return next - await Bun.write(out, Bun.file(path)) - return next - } - - function asset() { - cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) - return cache - } - - function pick() { - if (kind !== undefined) return kind - kind = LIST.find((item) => which(item)) ?? null - return kind - } - - function run(file: string, volume: number) { - const kind = pick() - if (!kind) return - return Process.spawn(args(kind, file, volume), { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }) - } - - function clear() { - if (!tail) return - clearTimeout(tail) - tail = undefined - } - - function play(file: string, volume: number) { - const item = load() - if (!item) return run(file, volume)?.exited - return item.play(file, { volume }).catch(() => run(file, volume)?.exited) - } - - export function start() { - stop() - const id = ++seq - void asset().then(({ hum }) => { - if (id !== seq) return - const next = run(hum, 0.24) - if (!next) return - proc = next - void next.exited.then( - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - ) - }) - } - - export function stop(delay = 0) { - seq++ - clear() - if (!proc) return - const next = proc - if (delay <= 0) { - proc = undefined - void Process.stop(next).catch(() => undefined) - return - } - tail = setTimeout(() => { - tail = undefined - if (proc === next) proc = undefined - void Process.stop(next).catch(() => undefined) - }, delay) - } - - export function pulse(scale = 1) { - stop(140) - const index = shot++ % FILE.length - void asset() - .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) - .catch(() => undefined) - } - - export function dispose() { - stop() +function load() { + if (item !== undefined) return item + try { + item = new Player({ volume: 0.35 }) + } catch { + item = null } + return item +} + +async function file(path: string) { + mkdirSync(DIR, { recursive: true }) + const next = join(DIR, basename(path)) + const out = Bun.file(next) + if (await out.exists()) return next + await Bun.write(out, Bun.file(path)) + return next +} + +function asset() { + cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) + return cache +} + +function pick() { + if (kind !== undefined) return kind + kind = LIST.find((item) => which(item)) ?? null + return kind +} + +function run(file: string, volume: number) { + const kind = pick() + if (!kind) return + return Process.spawn(args(kind, file, volume), { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }) +} + +function clear() { + if (!tail) return + clearTimeout(tail) + tail = undefined +} + +function play(file: string, volume: number) { + const item = load() + if (!item) return run(file, volume)?.exited + return item.play(file, { volume }).catch(() => run(file, volume)?.exited) +} + +export function start() { + stop() + const id = ++seq + void asset().then(({ hum }) => { + if (id !== seq) return + const next = run(hum, 0.24) + if (!next) return + proc = next + void next.exited.then( + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + ) + }) +} + +export function stop(delay = 0) { + seq++ + clear() + if (!proc) return + const next = proc + if (delay <= 0) { + proc = undefined + void Process.stop(next).catch(() => undefined) + return + } + tail = setTimeout(() => { + tail = undefined + if (proc === next) proc = undefined + void Process.stop(next).catch(() => undefined) + }, delay) +} + +export function pulse(scale = 1) { + stop(140) + const index = shot++ % FILE.length + void asset() + .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) + .catch(() => undefined) +} + +export function dispose() { + stop() } diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index 97b51fb4c5..46cf4635a7 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -1,137 +1,135 @@ import { RGBA } from "@opentui/core" -export namespace Terminal { - export type Colors = Awaited> +export type Colors = Awaited> - function parse(color: string): RGBA | null { - if (color.startsWith("rgb:")) { - const parts = color.substring(4).split("/") - return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) - } - if (color.startsWith("#")) { - return RGBA.fromHex(color) - } - if (color.startsWith("rgb(")) { - const parts = color.substring(4, color.length - 1).split(",") - return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) - } - return null +function parse(color: string): RGBA | null { + if (color.startsWith("rgb:")) { + const parts = color.substring(4).split("/") + return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) } - - function mode(bg: RGBA | null): "dark" | "light" { - if (!bg) return "dark" - const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 - return luminance > 0.5 ? "light" : "dark" + if (color.startsWith("#")) { + return RGBA.fromHex(color) } + if (color.startsWith("rgb(")) { + const parts = color.substring(4, color.length - 1).split(",") + return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) + } + return null +} - /** - * Query terminal colors including background, foreground, and palette (0-15). - * Uses OSC escape sequences to retrieve actual terminal color values. - * - * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. - * OSC 10/11 (foreground/background) typically work in most environments. - * - * Returns an object with background, foreground, and colors array. - * Any query that fails will be null/empty. - */ - export async function colors(): Promise<{ - background: RGBA | null - foreground: RGBA | null - colors: RGBA[] - }> { - if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } +function mode(bg: RGBA | null): "dark" | "light" { + if (!bg) return "dark" + const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 + return luminance > 0.5 ? "light" : "dark" +} - return new Promise((resolve) => { - let background: RGBA | null = null - let foreground: RGBA | null = null - const paletteColors: RGBA[] = [] - let timeout: NodeJS.Timeout +/** + * Query terminal colors including background, foreground, and palette (0-15). + * Uses OSC escape sequences to retrieve actual terminal color values. + * + * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. + * OSC 10/11 (foreground/background) typically work in most environments. + * + * Returns an object with background, foreground, and colors array. + * Any query that fails will be null/empty. + */ +export async function colors(): Promise<{ + background: RGBA | null + foreground: RGBA | null + colors: RGBA[] +}> { + if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) + return new Promise((resolve) => { + let background: RGBA | null = null + let foreground: RGBA | null = null + const paletteColors: RGBA[] = [] + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const handler = (data: Buffer) => { + const str = data.toString() + + // Match OSC 11 (background color) + const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) + if (bgMatch) { + background = parse(bgMatch[1]) } - const handler = (data: Buffer) => { - const str = data.toString() - - // Match OSC 11 (background color) - const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) - if (bgMatch) { - background = parse(bgMatch[1]) - } - - // Match OSC 10 (foreground color) - const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) - if (fgMatch) { - foreground = parse(fgMatch[1]) - } - - // Match OSC 4 (palette colors) - const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) - for (const match of paletteMatches) { - const index = parseInt(match[1]) - const color = parse(match[2]) - if (color) paletteColors[index] = color - } - - // Return immediately if we have all 16 palette colors - if (paletteColors.filter((c) => c !== undefined).length === 16) { - cleanup() - resolve({ background, foreground, colors: paletteColors }) - } + // Match OSC 10 (foreground color) + const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) + if (fgMatch) { + foreground = parse(fgMatch[1]) } - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - - // Query background (OSC 11) - process.stdout.write("\x1b]11;?\x07") - // Query foreground (OSC 10) - process.stdout.write("\x1b]10;?\x07") - // Query palette colors 0-15 (OSC 4) - for (let i = 0; i < 16; i++) { - process.stdout.write(`\x1b]4;${i};?\x07`) + // Match OSC 4 (palette colors) + const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) + for (const match of paletteMatches) { + const index = parseInt(match[1]) + const color = parse(match[2]) + if (color) paletteColors[index] = color } - timeout = setTimeout(() => { + // Return immediately if we have all 16 palette colors + if (paletteColors.filter((c) => c !== undefined).length === 16) { cleanup() resolve({ background, foreground, colors: paletteColors }) - }, 1000) - }) - } - - // Keep startup mode detection separate from `colors()`: the TUI boot path only - // needs OSC 11 and should resolve on the first background response instead of - // waiting on the full palette query used by system theme generation. - export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { - if (!process.stdin.isTTY) return "dark" - - return new Promise((resolve) => { - let timeout: NodeJS.Timeout - - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) } + } - const handler = (data: Buffer) => { - const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) - if (!match) return - cleanup() - resolve(mode(parse(match[1]))) - } + process.stdin.setRawMode(true) + process.stdin.on("data", handler) - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - process.stdout.write("\x1b]11;?\x07") + // Query background (OSC 11) + process.stdout.write("\x1b]11;?\x07") + // Query foreground (OSC 10) + process.stdout.write("\x1b]10;?\x07") + // Query palette colors 0-15 (OSC 4) + for (let i = 0; i < 16; i++) { + process.stdout.write(`\x1b]4;${i};?\x07`) + } - timeout = setTimeout(() => { - cleanup() - resolve("dark") - }, 1000) - }) - } + timeout = setTimeout(() => { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + }, 1000) + }) +} + +// Keep startup mode detection separate from `colors()`: the TUI boot path only +// needs OSC 11 and should resolve on the first background response instead of +// waiting on the full palette query used by system theme generation. +export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { + if (!process.stdin.isTTY) return "dark" + + return new Promise((resolve) => { + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const handler = (data: Buffer) => { + const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) + if (!match) return + cleanup() + resolve(mode(parse(match[1]))) + } + + process.stdin.setRawMode(true) + process.stdin.on("data", handler) + process.stdout.write("\x1b]11;?\x07") + + timeout = setTimeout(() => { + cleanup() + resolve("dark") + }, 1000) + }) } From 5011465c8118491fb839e020c78ecc721a377846 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:56:54 -0400 Subject: [PATCH 63/75] feat: unwrap tool namespaces to flat exports + barrel (#22762) --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/effect/app-runtime.ts | 4 +- .../src/server/instance/experimental.ts | 2 +- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 4 +- packages/opencode/src/tool/codesearch.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/index.ts | 3 + packages/opencode/src/tool/invalid.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/multiedit.ts | 2 +- packages/opencode/src/tool/plan.ts | 2 +- packages/opencode/src/tool/question.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 548 +++++++++--------- packages/opencode/src/tool/skill.ts | 2 +- packages/opencode/src/tool/task.ts | 2 +- packages/opencode/src/tool/todo.ts | 2 +- packages/opencode/src/tool/tool.ts | 278 +++++---- packages/opencode/src/tool/truncate.ts | 256 ++++---- packages/opencode/src/tool/webfetch.ts | 2 +- packages/opencode/src/tool/websearch.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/test/agent/agent.test.ts | 8 +- .../test/session/prompt-effect.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 4 +- .../opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/question.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 4 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 4 +- packages/opencode/test/tool/task.test.ts | 4 +- .../opencode/test/tool/tool-define.test.ts | 4 +- .../opencode/test/tool/truncation.test.ts | 2 +- packages/opencode/test/tool/webfetch.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 4 +- 48 files changed, 599 insertions(+), 602 deletions(-) create mode 100644 packages/opencode/src/tool/index.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b027c8c945..f7e3a35154 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,7 +4,7 @@ import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" import { Instance } from "../project/instance" -import { Truncate } from "../tool/truncate" +import { Truncate } from "../tool" import { Auth } from "../auth" import { ProviderTransform } from "../provider/transform" diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 29d6ace598..10b6d5c9e2 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -6,7 +6,7 @@ import { Provider } from "../../../provider" import { Session } from "../../../session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" -import { ToolRegistry } from "../../../tool/registry" +import { ToolRegistry } from "../../../tool" import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index da72372370..0874beee16 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -12,7 +12,7 @@ import { Server } from "../../server/server" import { Provider } from "../../provider" import { Agent } from "../../agent/agent" import { Permission } from "../../permission" -import { Tool } from "../../tool/tool" +import { Tool } from "../../tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" import { ReadTool } from "../../tool/read" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 75098b6083..1a64c21d00 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -34,7 +34,7 @@ import type { } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util" -import type { Tool } from "@/tool/tool" +import type { Tool } from "@/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/bash" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 0b76e96a84..60bbfe0ef5 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -37,8 +37,8 @@ import { LSP } from "@/lsp" import { MCP } from "@/mcp" import { McpAuth } from "@/mcp/auth" import { Command } from "@/command" -import { Truncate } from "@/tool/truncate" -import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool" +import { ToolRegistry } from "@/tool" import { Format } from "@/format" import { Project } from "@/project" import { Vcs } from "@/project" diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 610d67df08..fe80173a8b 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { ProviderID, ModelID } from "../../provider/schema" -import { ToolRegistry } from "../../tool/registry" +import { ToolRegistry } from "../../tool" import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 65fc7c8c70..44073c8501 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -19,7 +19,7 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" -import { ToolRegistry } from "../tool/registry" +import { ToolRegistry } from "../tool" import { MCP } from "../mcp" import { LSP } from "../lsp" import { FileTime } from "../file/time" @@ -34,13 +34,13 @@ import { ConfigMarkdown } from "../config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" -import { Tool } from "@/tool/tool" +import { Tool } from "@/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Truncate } from "@/tool/truncate" +import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 2d8a50101b..7da7dd255c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,7 +1,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 1edd754143..6260b22216 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,7 +1,7 @@ import z from "zod" import os from "os" import { createWriteStream } from "node:fs" -import { Tool } from "./tool" +import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import { Log } from "../util" @@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index d4d5779bf3..ac9961e250 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2303618a0b..62b96cba82 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -6,7 +6,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 810206f817..88b73da509 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" -import type { Tool } from "./tool" +import type * as Tool from "./tool" import { Instance } from "../project/instance" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0a0a8f1e25..673bb9cc8f 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,7 +7,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" -import { Tool } from "./tool" +import * as Tool from "./tool" export const GlobTool = Tool.define( "glob", diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index b6b4a063f0..caa75edad5 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" -import { Tool } from "./tool" +import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 diff --git a/packages/opencode/src/tool/index.ts b/packages/opencode/src/tool/index.ts new file mode 100644 index 0000000000..5b2463b507 --- /dev/null +++ b/packages/opencode/src/tool/index.ts @@ -0,0 +1,3 @@ +export * as Truncate from "./truncate" +export * as ToolRegistry from "./registry" +export * as Tool from "./tool" diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index b9794ed5fd..aca3618b6d 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" export const InvalidTool = Tool.define( "invalid", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 36cab3c1c3..263bfe81d2 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 449df33430..004d3c870d 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index cc52c2abde..fd7276e09c 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,7 +1,7 @@ import z from "zod" import path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 50e4b1c511..e5bb33aa69 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 4dc984d0ee..c6d1461cdf 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -4,7 +4,7 @@ import { createReadStream } from "fs" import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" -import { Tool } from "./tool" +import * as Tool from "./tool" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { LSP } from "../lsp" import { FileTime } from "../file/time" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 80115884d9..fa442fd3a4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,7 +12,7 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -24,7 +24,7 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util" import { LspTool } from "./lsp" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/shared/util/glob" import path from "path" @@ -47,299 +47,297 @@ import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" -export namespace ToolRegistry { - const log = Log.create({ service: "tool.registry" }) +const log = Log.create({ service: "tool.registry" }) - type TaskDef = Tool.InferDef - type ReadDef = Tool.InferDef +type TaskDef = Tool.InferDef +type ReadDef = Tool.InferDef - type State = { - custom: Tool.Def[] - builtin: Tool.Def[] - task: TaskDef - read: ReadDef - } +type State = { + custom: Tool.Def[] + builtin: Tool.Def[] + task: TaskDef + read: ReadDef +} - export interface Interface { - readonly ids: () => Effect.Effect - readonly all: () => Effect.Effect - readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> - readonly tools: (model: { - providerID: ProviderID - modelID: ModelID - agent: Agent.Info - }) => Effect.Effect - } +export interface Interface { + readonly ids: () => Effect.Effect + readonly all: () => Effect.Effect + readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> + readonly tools: (model: { + providerID: ProviderID + modelID: ModelID + agent: Agent.Info + }) => Effect.Effect +} - export class Service extends Context.Service()("@opencode/ToolRegistry") {} +export class Service extends Context.Service()("@opencode/ToolRegistry") {} - export const layer: Layer.Layer< - Service, - never, - | Config.Service - | Plugin.Service - | Question.Service - | Todo.Service - | Agent.Service - | Skill.Service - | Session.Service - | Provider.Service - | LSP.Service - | FileTime.Service - | Instruction.Service - | AppFileSystem.Service - | Bus.Service - | HttpClient.HttpClient - | ChildProcessSpawner - | Ripgrep.Service - | Format.Service - | Truncate.Service - > = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const plugin = yield* Plugin.Service - const agents = yield* Agent.Service - const skill = yield* Skill.Service - const truncate = yield* Truncate.Service +export const layer: Layer.Layer< + Service, + never, + | Config.Service + | Plugin.Service + | Question.Service + | Todo.Service + | Agent.Service + | Skill.Service + | Session.Service + | Provider.Service + | LSP.Service + | FileTime.Service + | Instruction.Service + | AppFileSystem.Service + | Bus.Service + | HttpClient.HttpClient + | ChildProcessSpawner + | Ripgrep.Service + | Format.Service + | Truncate.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const skill = yield* Skill.Service + const truncate = yield* Truncate.Service - const invalid = yield* InvalidTool - const task = yield* TaskTool - const read = yield* ReadTool - const question = yield* QuestionTool - const todo = yield* TodoWriteTool - const lsptool = yield* LspTool - const plan = yield* PlanExitTool - const webfetch = yield* WebFetchTool - const websearch = yield* WebSearchTool - const bash = yield* BashTool - const codesearch = yield* CodeSearchTool - const globtool = yield* GlobTool - const writetool = yield* WriteTool - const edit = yield* EditTool - const greptool = yield* GrepTool - const patchtool = yield* ApplyPatchTool - const skilltool = yield* SkillTool - const agent = yield* Agent.Service + const invalid = yield* InvalidTool + const task = yield* TaskTool + const read = yield* ReadTool + const question = yield* QuestionTool + const todo = yield* TodoWriteTool + const lsptool = yield* LspTool + const plan = yield* PlanExitTool + const webfetch = yield* WebFetchTool + const websearch = yield* WebSearchTool + const bash = yield* BashTool + const codesearch = yield* CodeSearchTool + const globtool = yield* GlobTool + const writetool = yield* WriteTool + const edit = yield* EditTool + const greptool = yield* GrepTool + const patchtool = yield* ApplyPatchTool + const skilltool = yield* SkillTool + const agent = yield* Agent.Service - const state = yield* InstanceState.make( - Effect.fn("ToolRegistry.state")(function* (ctx) { - const custom: Tool.Def[] = [] - - function fromPlugin(id: string, def: ToolDefinition): Tool.Def { - return { - id, - parameters: z.object(def.args), - description: def.description, - execute: (args, toolCtx) => - Effect.gen(function* () { - const pluginCtx: PluginToolContext = { - ...toolCtx, - ask: (req) => toolCtx.ask(req), - directory: ctx.directory, - worktree: ctx.worktree, - } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) - const info = yield* agent.get(toolCtx.agent) - const out = yield* truncate.output(result, {}, info) - return { - title: "", - output: out.truncated ? out.content : result, - metadata: { - truncated: out.truncated, - outputPath: out.truncated ? out.outputPath : undefined, - }, - } - }), - } - } - - const dirs = yield* config.directories() - const matches = dirs.flatMap((dir) => - Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), - ) - if (matches.length) yield* config.waitForDependencies() - for (const match of matches) { - const namespace = path.basename(match, path.extname(match)) - const mod = yield* Effect.promise( - () => import(process.platform === "win32" ? match : pathToFileURL(match).href), - ) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) - } - } - - const plugins = yield* plugin.list() - for (const p of plugins) { - for (const [id, def] of Object.entries(p.tool ?? {})) { - custom.push(fromPlugin(id, def)) - } - } - - yield* config.get() - const questionEnabled = - ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - - const tool = yield* Effect.all({ - invalid: Tool.init(invalid), - bash: Tool.init(bash), - read: Tool.init(read), - glob: Tool.init(globtool), - grep: Tool.init(greptool), - edit: Tool.init(edit), - write: Tool.init(writetool), - task: Tool.init(task), - fetch: Tool.init(webfetch), - todo: Tool.init(todo), - search: Tool.init(websearch), - code: Tool.init(codesearch), - skill: Tool.init(skilltool), - patch: Tool.init(patchtool), - question: Tool.init(question), - lsp: Tool.init(lsptool), - plan: Tool.init(plan), - }) + const state = yield* InstanceState.make( + Effect.fn("ToolRegistry.state")(function* (ctx) { + const custom: Tool.Def[] = [] + function fromPlugin(id: string, def: ToolDefinition): Tool.Def { return { - custom, - builtin: [ - tool.invalid, - ...(questionEnabled ? [tool.question] : []), - tool.bash, - tool.read, - tool.glob, - tool.grep, - tool.edit, - tool.write, - tool.task, - tool.fetch, - tool.todo, - tool.search, - tool.code, - tool.skill, - tool.patch, - ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), - ], - task: tool.task, - read: tool.read, + id, + parameters: z.object(def.args), + description: def.description, + execute: (args, toolCtx) => + Effect.gen(function* () { + const pluginCtx: PluginToolContext = { + ...toolCtx, + ask: (req) => toolCtx.ask(req), + directory: ctx.directory, + worktree: ctx.worktree, + } + const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const info = yield* agent.get(toolCtx.agent) + const out = yield* truncate.output(result, {}, info) + return { + title: "", + output: out.truncated ? out.content : result, + metadata: { + truncated: out.truncated, + outputPath: out.truncated ? out.outputPath : undefined, + }, + } + }), } - }), - ) + } - const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { - const s = yield* InstanceState.get(state) - return [...s.builtin, ...s.custom] as Tool.Def[] - }) - - const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { - return (yield* all()).map((tool) => tool.id) - }) - - const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { - const list = yield* skill.available(agent) - if (list.length === 0) return "No skills are currently available." - return [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") - }) - - const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { - const items = (yield* agents.list()).filter((item) => item.mode !== "primary") - const filtered = items.filter( - (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + const dirs = yield* config.directories() + const matches = dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), ) - const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) - const description = list - .map( - (item) => - `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, + if (matches.length) yield* config.waitForDependencies() + for (const match of matches) { + const namespace = path.basename(match, path.extname(match)) + const mod = yield* Effect.promise( + () => import(process.platform === "win32" ? match : pathToFileURL(match).href), ) - .join("\n") - return ["Available agent types and the tools they have access to:", description].join("\n") - }) - - const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { - const filtered = (yield* all()).filter((tool) => { - if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } + } - const usePatch = - input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") - if (tool.id === ApplyPatchTool.id) return usePatch - if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch + const plugins = yield* plugin.list() + for (const p of plugins) { + for (const [id, def] of Object.entries(p.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } + } - return true + yield* config.get() + const questionEnabled = + ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + + const tool = yield* Effect.all({ + invalid: Tool.init(invalid), + bash: Tool.init(bash), + read: Tool.init(read), + glob: Tool.init(globtool), + grep: Tool.init(greptool), + edit: Tool.init(edit), + write: Tool.init(writetool), + task: Tool.init(task), + fetch: Tool.init(webfetch), + todo: Tool.init(todo), + search: Tool.init(websearch), + code: Tool.init(codesearch), + skill: Tool.init(skilltool), + patch: Tool.init(patchtool), + question: Tool.init(question), + lsp: Tool.init(lsptool), + plan: Tool.init(plan), }) - return yield* Effect.forEach( - filtered, - Effect.fnUntraced(function* (tool: Tool.Def) { - using _ = log.time(tool.id) - const output = { - description: tool.description, - parameters: tool.parameters, - } - yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) - return { - id: tool.id, - description: [ - output.description, - tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, - tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, - ] - .filter(Boolean) - .join("\n"), - parameters: output.parameters, - execute: tool.execute, - formatValidationError: tool.formatValidationError, - } - }), - { concurrency: "unbounded" }, + return { + custom, + builtin: [ + tool.invalid, + ...(questionEnabled ? [tool.question] : []), + tool.bash, + tool.read, + tool.glob, + tool.grep, + tool.edit, + tool.write, + tool.task, + tool.fetch, + tool.todo, + tool.search, + tool.code, + tool.skill, + tool.patch, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), + ], + task: tool.task, + read: tool.read, + } + }), + ) + + const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { + const s = yield* InstanceState.get(state) + return [...s.builtin, ...s.custom] as Tool.Def[] + }) + + const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { + return (yield* all()).map((tool) => tool.id) + }) + + const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { + const list = yield* skill.available(agent) + if (list.length === 0) return "No skills are currently available." + return [ + "Load a specialized skill that provides domain-specific instructions and workflows.", + "", + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", + "", + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", + "", + 'Tool output includes a `` block with the loaded content.', + "", + "The following skills provide specialized sets of instructions for particular tasks", + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", + "", + Skill.fmt(list, { verbose: false }), + ].join("\n") + }) + + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, ) + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) + + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + const filtered = (yield* all()).filter((tool) => { + if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { + return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + } + + const usePatch = + input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") + if (tool.id === ApplyPatchTool.id) return usePatch + if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch + + return true }) - const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { - const s = yield* InstanceState.get(state) - return { task: s.task, read: s.read } - }) + return yield* Effect.forEach( + filtered, + Effect.fnUntraced(function* (tool: Tool.Def) { + using _ = log.time(tool.id) + const output = { + description: tool.description, + parameters: tool.parameters, + } + yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) + return { + id: tool.id, + description: [ + output.description, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, + ] + .filter(Boolean) + .join("\n"), + parameters: output.parameters, + execute: tool.execute, + formatValidationError: tool.formatValidationError, + } + }), + { concurrency: "unbounded" }, + ) + }) - return Service.of({ ids, all, named, tools }) - }), - ) + const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { + const s = yield* InstanceState.get(state) + return { task: s.task, read: s.read } + }) - export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ), - ) -} + return Service.of({ ids, all, named, tools }) + }), +) + +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), + ), +) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index eaec667e58..58a66ee744 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -6,7 +6,7 @@ import * as Stream from "effect/Stream" import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" -import { Tool } from "./tool" +import * as Tool from "./tool" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8f7104e80d..3da0664f3d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,4 +1,4 @@ -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import z from "zod" import { Session } from "../session" diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 253bcfa32a..5090f17a7c 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index ca25862349..db39073484 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -3,146 +3,144 @@ import { Effect } from "effect" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { Agent } from "@/agent/agent" -export namespace Tool { - interface Metadata { - [key: string]: any - } - - // TODO: remove this hack - export type DynamicDescription = (agent: Agent.Info) => Effect.Effect - - export type Context = { - sessionID: SessionID - messageID: MessageID - agent: string - abort: AbortSignal - callID?: string - extra?: { [key: string]: any } - messages: MessageV2.WithParts[] - metadata(input: { title?: string; metadata?: M }): Effect.Effect - ask(input: Omit): Effect.Effect - } - - export interface ExecuteResult { - title: string - metadata: M - output: string - attachments?: Omit[] - } - - export interface Def { - id: string - description: string - parameters: Parameters - execute(args: z.infer, ctx: Context): Effect.Effect> - formatValidationError?(error: z.ZodError): string - } - export type DefWithoutID = Omit< - Def, - "id" - > - - export interface Info { - id: string - init: () => Effect.Effect> - } - - type Init = - | DefWithoutID - | (() => Effect.Effect>) - - export type InferParameters = - T extends Info - ? z.infer

- : T extends Effect.Effect, any, any> - ? z.infer

- : never - export type InferMetadata = - T extends Info ? M : T extends Effect.Effect, any, any> ? M : never - - export type InferDef = - T extends Info - ? Def - : T extends Effect.Effect, any, any> - ? Def - : never - - function wrap( - id: string, - init: Init, - truncate: Truncate.Interface, - agents: Agent.Interface, - ) { - return () => - Effect.gen(function* () { - const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } - const execute = toolInfo.execute - toolInfo.execute = (args, ctx) => { - const attrs = { - "tool.name": id, - "session.id": ctx.sessionID, - "message.id": ctx.messageID, - ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), - } - return Effect.gen(function* () { - yield* Effect.try({ - try: () => toolInfo.parameters.parse(args), - catch: (error) => { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - return new Error(toolInfo.formatValidationError(error), { cause: error }) - } - return new Error( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) - }, - }) - const result = yield* execute(args, ctx) - if (result.metadata.truncated !== undefined) { - return result - } - const agent = yield* agents.get(ctx.agent) - const truncated = yield* truncate.output(result.output, {}, agent) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, - } - }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) - } - return toolInfo - }) - } - - export function define( - id: ID, - init: Effect.Effect, never, R>, - ): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { - return Object.assign( - Effect.gen(function* () { - const resolved = yield* init - const truncate = yield* Truncate.Service - const agents = yield* Agent.Service - return { id, init: wrap(id, resolved, truncate, agents) } - }), - { id }, - ) - } - - export function init

(info: Info): Effect.Effect> { - return Effect.gen(function* () { - const init = yield* info.init() - return { - ...init, - id: info.id, - } - }) - } +interface Metadata { + [key: string]: any +} + +// TODO: remove this hack +export type DynamicDescription = (agent: Agent.Info) => Effect.Effect + +export type Context = { + sessionID: SessionID + messageID: MessageID + agent: string + abort: AbortSignal + callID?: string + extra?: { [key: string]: any } + messages: MessageV2.WithParts[] + metadata(input: { title?: string; metadata?: M }): Effect.Effect + ask(input: Omit): Effect.Effect +} + +export interface ExecuteResult { + title: string + metadata: M + output: string + attachments?: Omit[] +} + +export interface Def { + id: string + description: string + parameters: Parameters + execute(args: z.infer, ctx: Context): Effect.Effect> + formatValidationError?(error: z.ZodError): string +} +export type DefWithoutID = Omit< + Def, + "id" +> + +export interface Info { + id: string + init: () => Effect.Effect> +} + +type Init = + | DefWithoutID + | (() => Effect.Effect>) + +export type InferParameters = + T extends Info + ? z.infer

+ : T extends Effect.Effect, any, any> + ? z.infer

+ : never +export type InferMetadata = + T extends Info ? M : T extends Effect.Effect, any, any> ? M : never + +export type InferDef = + T extends Info + ? Def + : T extends Effect.Effect, any, any> + ? Def + : never + +function wrap( + id: string, + init: Init, + truncate: Truncate.Interface, + agents: Agent.Interface, +) { + return () => + Effect.gen(function* () { + const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + const execute = toolInfo.execute + toolInfo.execute = (args, ctx) => { + const attrs = { + "tool.name": id, + "session.id": ctx.sessionID, + "message.id": ctx.messageID, + ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), + } + return Effect.gen(function* () { + yield* Effect.try({ + try: () => toolInfo.parameters.parse(args), + catch: (error) => { + if (error instanceof z.ZodError && toolInfo.formatValidationError) { + return new Error(toolInfo.formatValidationError(error), { cause: error }) + } + return new Error( + `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ) + }, + }) + const result = yield* execute(args, ctx) + if (result.metadata.truncated !== undefined) { + return result + } + const agent = yield* agents.get(ctx.agent) + const truncated = yield* truncate.output(result.output, {}, agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) + } + return toolInfo + }) +} + +export function define( + id: ID, + init: Effect.Effect, never, R>, +): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { + return Object.assign( + Effect.gen(function* () { + const resolved = yield* init + const truncate = yield* Truncate.Service + const agents = yield* Agent.Service + return { id, init: wrap(id, resolved, truncate, agents) } + }), + { id }, + ) +} + +export function init

(info: Info): Effect.Effect> { + return Effect.gen(function* () { + const init = yield* info.init() + return { + ...init, + id: info.id, + } + }) } diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index d2aa944a85..d990e7adf7 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -9,136 +9,134 @@ import { Log } from "../util" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" -export namespace Truncate { - const log = Log.create({ service: "truncation" }) - const RETENTION = Duration.days(7) +const log = Log.create({ service: "truncation" }) +const RETENTION = Duration.days(7) - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 - export const DIR = TRUNCATION_DIR - export const GLOB = path.join(TRUNCATION_DIR, "*") +export const MAX_LINES = 2000 +export const MAX_BYTES = 50 * 1024 +export const DIR = TRUNCATION_DIR +export const GLOB = path.join(TRUNCATION_DIR, "*") - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } +export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - function hasTaskTool(agent?: Agent.Info) { - if (!agent?.permission) return false - return evaluate("task", "*", agent.permission).action !== "deny" - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - readonly write: (text: string) => Effect.Effect - /** - * Returns output unchanged when it fits within the limits, otherwise writes the full text - * to the truncation directory and returns a preview plus a hint to inspect the saved file. - */ - readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Truncate") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) - const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( - Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), - Effect.catch(() => Effect.succeed([])), - ) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) - } - }) - - const write = Effect.fn("Truncate.write")(function* (text: string) { - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) - return file - }) - - const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } as const - } - - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - const file = yield* write(text) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - - return { - content: - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, - truncated: true, - outputPath: file, - } as const - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, write, output }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) +export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" } + +function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return evaluate("task", "*", agent.permission).action !== "deny" +} + +export interface Interface { + readonly cleanup: () => Effect.Effect + readonly write: (text: string) => Effect.Effect + /** + * Returns output unchanged when it fits within the limits, otherwise writes the full text + * to the truncation directory and returns a preview plus a hint to inspect the saved file. + */ + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Truncate") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const cleanup = Effect.fn("Truncate.cleanup")(function* () { + const cutoff = Identifier.timestamp( + Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), + ) + const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + const write = Effect.fn("Truncate.write")(function* (text: string) { + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + return file + }) + + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const direction = options.direction ?? "head" + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } as const + } + + const out: string[] = [] + let i = 0 + let bytes = 0 + let hitBytes = false + + if (direction === "head") { + for (i = 0; i < lines.length && i < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = yield* write(text) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, write, output }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 14d5465846..6498b871f8 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 968e1e34b6..34cefd031f 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 337c2708c9..c5871eb0ef 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,7 +1,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 409a0ed606..7e9a6fe90b 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -84,7 +84,7 @@ test("explore agent denies edit and write", async () => { }) test("explore agent asks for external directories and allows Truncate.GLOB", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -496,7 +496,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a }) test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { permission: { @@ -516,7 +516,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { agent: { @@ -540,7 +540,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }) test("explicit Truncate.GLOB deny is respected", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { permission: { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 6819da4817..121d662e5f 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -34,8 +34,8 @@ import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool/registry" -import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { Log } from "../../src/util" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 38aed43765..1f66ccb995 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -50,8 +50,8 @@ import { SessionProcessor } from "../../src/session/processor" import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool/registry" -import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c0448c78cb..ebfa9a531e 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 6a3eac15e0..d66cfc3e37 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -9,7 +9,7 @@ import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 37a19a5fda..2e3dfa8a69 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -12,7 +12,7 @@ import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" const ctx = { diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ee8cb53963..8cbfe78270 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Effect } from "effect" -import type { Tool } from "../../src/tool/tool" +import type { Tool } from "../../src/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "../../src/util" diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 20e761fc10..87d35715dd 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -6,7 +6,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 35467aeab4..388828f6eb 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -5,7 +5,7 @@ import { GrepTool } from "../../src/tool/grep" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 629e5d2d28..17718b2b3a 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -5,7 +5,7 @@ import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fa65068f86..8e1724b474 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -11,8 +11,8 @@ import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" -import { Truncate } from "../../src/tool/truncate" -import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool" +import { Tool } from "../../src/tool" import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dea84bdcd4..dbb89e09a9 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { ToolRegistry } from "../../src/tool/registry" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 9b92a8cd30..55e126ab47 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -4,10 +4,10 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" -import type { Tool } from "../../src/tool/tool" +import type { Tool } from "../../src/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" -import { ToolRegistry } from "../../src/tool/registry" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index bc90dc0f22..b94dd52086 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -10,8 +10,8 @@ import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" -import { Truncate } from "../../src/tool/truncate" -import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index b8003e475d..00d1e039a7 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -2,8 +2,8 @@ import { describe, test, expect } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import z from "zod" import { Agent } from "../../src/agent/agent" -import { Tool } from "../../src/tool/tool" -import { Truncate } from "../../src/tool/truncate" +import { Tool } from "../../src/tool" +import { Truncate } from "../../src/tool" const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d0873046d6..d3cec4cd9e 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util" import { Filesystem } from "../../src/util" diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 7d2ff1dcab..699e388fb9 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index e83ec2efdb..46bbe2e401 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -9,8 +9,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileTime } from "../../src/file/time" import { Bus } from "../../src/bus" import { Format } from "../../src/format" -import { Truncate } from "../../src/tool/truncate" -import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool" +import { Tool } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" From c8af8f96ce2059ebf114a25ec958ab88dc15ff76 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 03:57:53 +0000 Subject: [PATCH 64/75] chore: generate --- packages/opencode/src/tool/registry.ts | 6 +----- packages/opencode/src/tool/tool.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index fa442fd3a4..a8ab4c27ea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -63,11 +63,7 @@ export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> - readonly tools: (model: { - providerID: ProviderID - modelID: ModelID - agent: Agent.Info - }) => Effect.Effect + readonly tools: (model: { providerID: ProviderID; modelID: ModelID; agent: Agent.Info }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ToolRegistry") {} diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index db39073484..0ea0435fb1 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -54,11 +54,7 @@ type Init = | (() => Effect.Effect>) export type InferParameters = - T extends Info - ? z.infer

- : T extends Effect.Effect, any, any> - ? z.infer

- : never + T extends Info ? z.infer

: T extends Effect.Effect, any, any> ? z.infer

: never export type InferMetadata = T extends Info ? M : T extends Effect.Effect, any, any> ? M : never From 6b2083898120c02413dae806e749872ae407d9d1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 01:02:50 -0400 Subject: [PATCH 65/75] feat: unwrap provider namespaces to flat exports + barrel (#22760) --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/models.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/provider/auth.ts | 382 ++-- packages/opencode/src/provider/error.ts | 340 ++- packages/opencode/src/provider/index.ts | 4 + packages/opencode/src/provider/models.ts | 294 ++- packages/opencode/src/provider/provider.ts | 4 +- packages/opencode/src/provider/transform.ts | 1842 ++++++++--------- .../src/server/instance/httpapi/provider.ts | 2 +- .../opencode/src/server/instance/provider.ts | 4 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/overflow.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- .../test/plugin/auth-override.test.ts | 2 +- .../opencode/test/provider/provider.test.ts | 2 +- .../opencode/test/provider/transform.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 4 +- 21 files changed, 1448 insertions(+), 1452 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index f7e3a35154..54ca484555 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -6,7 +6,7 @@ import { generateObject, streamObject, type ModelMessage } from "ai" import { Instance } from "../project/instance" import { Truncate } from "../tool" import { Auth } from "../auth" -import { ProviderTransform } from "../provider/transform" +import { ProviderTransform } from "../provider" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 822d78770e..ed1ca2124d 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,7 +18,7 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" -import { ModelsDev } from "../../provider/models" +import { ModelsDev } from "../../provider" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" import { SessionShare } from "@/share" diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index af5ca2f957..446d21f5df 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -2,7 +2,7 @@ import type { Argv } from "yargs" import { Instance } from "../../project/instance" import { Provider } from "../../provider" import { ProviderID } from "../../provider/schema" -import { ModelsDev } from "../../provider/models" +import { ModelsDev } from "../../provider" import { cmd } from "./cmd" import { UI } from "../ui" import { EOL } from "os" diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 47a5c37e85..4bc3f0ea6c 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { ModelsDev } from "../../provider/models" +import { ModelsDev } from "../../provider" import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 60bbfe0ef5..aabafc5b4d 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -16,7 +16,7 @@ import { Storage } from "@/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" import { Provider } from "@/provider" -import { ProviderAuth } from "@/provider/auth" +import { ProviderAuth } from "@/provider" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" import { Discovery } from "@/skill/discovery" diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index fd71f2f7a3..c0c73b2cc1 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -9,219 +9,217 @@ import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" import z from "zod" -export namespace ProviderAuth { - const When = Schema.Struct({ - key: Schema.String, - op: Schema.Literals(["eq", "neq"]), - value: Schema.String, - }) +const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}) - const TextPrompt = Schema.Struct({ - type: Schema.Literal("text"), - key: Schema.String, - message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), - }) +const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: Schema.optional(Schema.String), + when: Schema.optional(When), +}) - const SelectOption = Schema.Struct({ - label: Schema.String, - value: Schema.String, - hint: Schema.optional(Schema.String), - }) +const SelectOption = Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), +}) - const SelectPrompt = Schema.Struct({ - type: Schema.Literal("select"), - key: Schema.String, - message: Schema.String, - options: Schema.Array(SelectOption), - when: Schema.optional(When), - }) +const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array(SelectOption), + when: Schema.optional(When), +}) - const Prompt = Schema.Union([TextPrompt, SelectPrompt]) +const Prompt = Schema.Union([TextPrompt, SelectPrompt]) - export class Method extends Schema.Class("ProviderAuthMethod")({ - type: Schema.Literals(["oauth", "api"]), - label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), - }) { - static readonly zod = zod(this) - } +export class Method extends Schema.Class("ProviderAuthMethod")({ + type: Schema.Literals(["oauth", "api"]), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), +}) { + static readonly zod = zod(this) +} - export const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) }))) - export type Methods = typeof Methods.Type +export const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Methods = typeof Methods.Type - export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ - url: Schema.String, - method: Schema.Literals(["auto", "code"]), - instructions: Schema.String, - }) { - static readonly zod = zod(this) - } +export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ + url: Schema.String, + method: Schema.Literals(["auto", "code"]), + instructions: Schema.String, +}) { + static readonly zod = zod(this) +} - export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) +export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) - export const OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ providerID: ProviderID.zod }), - ) +export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ providerID: ProviderID.zod }), +) - export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) - export const ValidationFailed = NamedError.create( - "ProviderAuthValidationFailed", - z.object({ - field: z.string(), - message: z.string(), - }), - ) +export const ValidationFailed = NamedError.create( + "ProviderAuthValidationFailed", + z.object({ + field: z.string(), + message: z.string(), + }), +) - export type Error = - | Auth.AuthError - | InstanceType - | InstanceType - | InstanceType - | InstanceType +export type Error = + | Auth.AuthError + | InstanceType + | InstanceType + | InstanceType + | InstanceType - type Hook = NonNullable +type Hook = NonNullable - export interface Interface { - readonly methods: () => Effect.Effect - readonly authorize: (input: { +export interface Interface { + readonly methods: () => Effect.Effect + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect +} + +interface State { + hooks: Record + pending: Map +} + +export class Service extends Context.Service()("@opencode/ProviderAuth") {} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const plugin = yield* Plugin.Service + const state = yield* InstanceState.make( + Effect.fn("ProviderAuth.state")(function* () { + const plugins = yield* plugin.list() + return { + hooks: Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), + ), + pending: new Map(), + } + }), + ) + + const decode = Schema.decodeUnknownSync(Methods) + const methods = Effect.fn("ProviderAuth.methods")(function* () { + const hooks = (yield* InstanceState.get(state)).hooks + return decode( + Record.map(hooks, (item) => + item.methods.map((method) => ({ + type: method.type, + label: method.label, + prompts: method.prompts?.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + when: prompt.when, + } + } + return { + type: "text" as const, + key: prompt.key, + message: prompt.message, + placeholder: prompt.placeholder, + when: prompt.when, + } + }), + })), + ), + ) + }) + + const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { providerID: ProviderID method: number inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } + }) { + const { hooks, pending } = yield* InstanceState.get(state) + const method = hooks[input.providerID].methods[input.method] + if (method.type !== "oauth") return - interface State { - hooks: Record - pending: Map - } - - export class Service extends Context.Service()("@opencode/ProviderAuth") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Service - const plugin = yield* Plugin.Service - const state = yield* InstanceState.make( - Effect.fn("ProviderAuth.state")(function* () { - const plugins = yield* plugin.list() - return { - hooks: Record.fromEntries( - Arr.filterMap(plugins, (x) => - x.auth?.provider !== undefined - ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) - : Result.failVoid, - ), - ), - pending: new Map(), + if (method.prompts && input.inputs) { + for (const prompt of method.prompts) { + if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { + const error = prompt.validate(input.inputs[prompt.key]) + if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) } - }), + } + } + + const result = yield* Effect.promise(() => method.authorize(input.inputs)) + pending.set(input.providerID, result) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuth.callback")(function* (input: { + providerID: ProviderID + method: number + code?: string + }) { + const pending = (yield* InstanceState.get(state)).pending + const match = pending.get(input.providerID) + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + if (match.method === "code" && !input.code) { + return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) + } + + const result = yield* Effect.promise(() => + match.method === "code" ? match.callback(input.code!) : match.callback(), ) + if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) - const decode = Schema.decodeUnknownSync(Methods) - const methods = Effect.fn("ProviderAuth.methods")(function* () { - const hooks = (yield* InstanceState.get(state)).hooks - return decode( - Record.map(hooks, (item) => - item.methods.map((method) => ({ - type: method.type, - label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { - return { - type: "select" as const, - key: prompt.key, - message: prompt.message, - options: prompt.options, - when: prompt.when, - } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } - }), - })), - ), - ) - }) + if ("key" in result) { + yield* auth.set(input.providerID, { + type: "api", + key: result.key, + }) + } - const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { - providerID: ProviderID - method: number - inputs?: Record - }) { - const { hooks, pending } = yield* InstanceState.get(state) - const method = hooks[input.providerID].methods[input.method] - if (method.type !== "oauth") return + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extra } = result + yield* auth.set(input.providerID, { + type: "oauth", + access, + refresh, + expires, + ...extra, + }) + } + }) - if (method.prompts && input.inputs) { - for (const prompt of method.prompts) { - if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { - const error = prompt.validate(input.inputs[prompt.key]) - if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) - } - } - } + return Service.of({ methods, authorize, callback }) + }), +) - const result = yield* Effect.promise(() => method.authorize(input.inputs)) - pending.set(input.providerID, result) - return { - url: result.url, - method: result.method, - instructions: result.instructions, - } - }) - - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { - providerID: ProviderID - method: number - code?: string - }) { - const pending = (yield* InstanceState.get(state)).pending - const match = pending.get(input.providerID) - if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) - if (match.method === "code" && !input.code) { - return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) - } - - const result = yield* Effect.promise(() => - match.method === "code" ? match.callback(input.code!) : match.callback(), - ) - if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) - - if ("key" in result) { - yield* auth.set(input.providerID, { - type: "api", - key: result.key, - }) - } - - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extra } = result - yield* auth.set(input.providerID, { - type: "oauth", - access, - refresh, - expires, - ...extra, - }) - } - }) - - return Service.of({ methods, authorize, callback }) - }), - ) - - export const defaultLayer = Layer.suspend(() => - layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), - ) -} +export const defaultLayer = Layer.suspend(() => + layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), +) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 52e525177a..42378b6866 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -3,195 +3,193 @@ import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderID } from "./schema" -export namespace ProviderError { - // Adapted from overflow detection patterns in: - // https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts - const OVERFLOW_PATTERNS = [ - /prompt is too long/i, // Anthropic - /input is too long for requested model/i, // Amazon Bedrock - /exceeds the context window/i, // OpenAI (Completions + Responses API message text) - /input token count.*exceeds the maximum/i, // Google (Gemini) - /maximum prompt length is \d+/i, // xAI (Grok) - /reduce the length of the messages/i, // Groq - /maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek, vLLM - /exceeds the limit of \d+/i, // GitHub Copilot - /exceeds the available context size/i, // llama.cpp server - /greater than the context length/i, // LM Studio - /context window exceeds limit/i, // MiniMax - /exceeded model token limit/i, // Kimi For Coding, Moonshot - /context[_ ]length[_ ]exceeded/i, // Generic fallback - /request entity too large/i, // HTTP 413 - /context length is only \d+ tokens/i, // vLLM - /input length.*exceeds.*context length/i, // vLLM - /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error - /too large for model with \d+ maximum context length/i, // Mistral - /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text - ] +// Adapted from overflow detection patterns in: +// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions + Responses API message text) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek, vLLM + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding, Moonshot + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /request entity too large/i, // HTTP 413 + /context length is only \d+ tokens/i, // vLLM + /input length.*exceeds.*context length/i, // vLLM + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /too large for model with \d+ maximum context length/i, // Mistral + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text +] - function isOpenAiErrorRetryable(e: APICallError) { - const status = e.statusCode - if (!status) return e.isRetryable - // openai sometimes returns 404 for models that are actually available - return status === 404 || e.isRetryable - } +function isOpenAiErrorRetryable(e: APICallError) { + const status = e.statusCode + if (!status) return e.isRetryable + // openai sometimes returns 404 for models that are actually available + return status === 404 || e.isRetryable +} - // Providers not reliably handled in this function: - // - z.ai: can accept overflow silently (needs token-count/context-window checks) - function isOverflow(message: string) { - if (OVERFLOW_PATTERNS.some((p) => p.test(message))) return true +// Providers not reliably handled in this function: +// - z.ai: can accept overflow silently (needs token-count/context-window checks) +function isOverflow(message: string) { + if (OVERFLOW_PATTERNS.some((p) => p.test(message))) return true - // Providers/status patterns handled outside of regex list: - // - Cerebras: often returns "400 (no body)" / "413 (no body)" - // - Mistral: often returns "400 (no body)" / "413 (no body)" - return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) - } + // Providers/status patterns handled outside of regex list: + // - Cerebras: often returns "400 (no body)" / "413 (no body)" + // - Mistral: often returns "400 (no body)" / "413 (no body)" + return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) +} - function message(providerID: ProviderID, e: APICallError) { - return iife(() => { - const msg = e.message - if (msg === "") { - if (e.responseBody) return e.responseBody - if (e.statusCode) { - const err = STATUS_CODES[e.statusCode] - if (err) return err - } - return "Unknown error" - } - - if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) { - return msg - } - - try { - const body = JSON.parse(e.responseBody) - // try to extract common error message fields - const errMsg = body.message || body.error || body.error?.message - if (errMsg && typeof errMsg === "string") { - return `${msg}: ${errMsg}` - } - } catch {} - - // If responseBody is HTML (e.g. from a gateway or proxy error page), - // provide a human-readable message instead of dumping raw markup - if (/^\s*` to re-authenticate." - } - if (e.statusCode === 403) { - return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings." - } - return msg - } - - return `${msg}: ${e.responseBody}` - }).trim() - } - - function json(input: unknown) { - if (typeof input === "string") { - try { - const result = JSON.parse(input) - if (result && typeof result === "object") return result - return undefined - } catch { - return undefined +function message(providerID: ProviderID, e: APICallError) { + return iife(() => { + const msg = e.message + if (msg === "") { + if (e.responseBody) return e.responseBody + if (e.statusCode) { + const err = STATUS_CODES[e.statusCode] + if (err) return err } + return "Unknown error" } - if (typeof input === "object" && input !== null) { - return input + + if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) { + return msg } - return undefined - } - export type ParsedStreamError = - | { - type: "context_overflow" - message: string - responseBody: string + try { + const body = JSON.parse(e.responseBody) + // try to extract common error message fields + const errMsg = body.message || body.error || body.error?.message + if (errMsg && typeof errMsg === "string") { + return `${msg}: ${errMsg}` } - | { - type: "api_error" - message: string - isRetryable: false - responseBody: string + } catch {} + + // If responseBody is HTML (e.g. from a gateway or proxy error page), + // provide a human-readable message instead of dumping raw markup + if (/^\s*` to re-authenticate." } + if (e.statusCode === 403) { + return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings." + } + return msg + } - export function parseStreamError(input: unknown): ParsedStreamError | undefined { - const body = json(input) - if (!body) return + return `${msg}: ${e.responseBody}` + }).trim() +} - const responseBody = JSON.stringify(body) - if (body.type !== "error") return - - switch (body?.error?.code) { - case "context_length_exceeded": - return { - type: "context_overflow", - message: "Input exceeds context window of this model", - responseBody, - } - case "insufficient_quota": - return { - type: "api_error", - message: "Quota exceeded. Check your plan and billing details.", - isRetryable: false, - responseBody, - } - case "usage_not_included": - return { - type: "api_error", - message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", - isRetryable: false, - responseBody, - } - case "invalid_prompt": - return { - type: "api_error", - message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", - isRetryable: false, - responseBody, - } +function json(input: unknown) { + if (typeof input === "string") { + try { + const result = JSON.parse(input) + if (result && typeof result === "object") return result + return undefined + } catch { + return undefined } } + if (typeof input === "object" && input !== null) { + return input + } + return undefined +} - export type ParsedAPICallError = - | { - type: "context_overflow" - message: string - responseBody?: string - } - | { - type: "api_error" - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: Record - responseBody?: string - metadata?: Record - } +export type ParsedStreamError = + | { + type: "context_overflow" + message: string + responseBody: string + } + | { + type: "api_error" + message: string + isRetryable: false + responseBody: string + } - export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { - const m = message(input.providerID, input.error) - const body = json(input.error.responseBody) - if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { +export function parseStreamError(input: unknown): ParsedStreamError | undefined { + const body = json(input) + if (!body) return + + const responseBody = JSON.stringify(body) + if (body.type !== "error") return + + switch (body?.error?.code) { + case "context_length_exceeded": return { type: "context_overflow", - message: m, - responseBody: input.error.responseBody, + message: "Input exceeds context window of this model", + responseBody, + } + case "insufficient_quota": + return { + type: "api_error", + message: "Quota exceeded. Check your plan and billing details.", + isRetryable: false, + responseBody, + } + case "usage_not_included": + return { + type: "api_error", + message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", + isRetryable: false, + responseBody, + } + case "invalid_prompt": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", + isRetryable: false, + responseBody, } - } - - const metadata = input.error.url ? { url: input.error.url } : undefined - return { - type: "api_error", - message: m, - statusCode: input.error.statusCode, - isRetryable: input.providerID.startsWith("openai") - ? isOpenAiErrorRetryable(input.error) - : input.error.isRetryable, - responseHeaders: input.error.responseHeaders, - responseBody: input.error.responseBody, - metadata, - } + } +} + +export type ParsedAPICallError = + | { + type: "context_overflow" + message: string + responseBody?: string + } + | { + type: "api_error" + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + metadata?: Record + } + +export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { + const m = message(input.providerID, input.error) + const body = json(input.error.responseBody) + if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { + return { + type: "context_overflow", + message: m, + responseBody: input.error.responseBody, + } + } + + const metadata = input.error.url ? { url: input.error.url } : undefined + return { + type: "api_error", + message: m, + statusCode: input.error.statusCode, + isRetryable: input.providerID.startsWith("openai") + ? isOpenAiErrorRetryable(input.error) + : input.error.isRetryable, + responseHeaders: input.error.responseHeaders, + responseBody: input.error.responseBody, + metadata, } } diff --git a/packages/opencode/src/provider/index.ts b/packages/opencode/src/provider/index.ts index 3c0174548d..9e8891144a 100644 --- a/packages/opencode/src/provider/index.ts +++ b/packages/opencode/src/provider/index.ts @@ -1 +1,5 @@ export * as Provider from "./provider" +export * as ProviderAuth from "./auth" +export * as ProviderError from "./error" +export * as ModelsDev from "./models" +export * as ProviderTransform from "./transform" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 245730e00f..2924666c0e 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -13,169 +13,167 @@ import { Hash } from "@opencode-ai/shared/util/hash" // Falls back to undefined in dev mode when snapshot doesn't exist /* @ts-ignore */ -export namespace ModelsDev { - const log = Log.create({ service: "models.dev" }) - const source = url() - const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, - ) - const ttl = 5 * 60 * 1000 +const log = Log.create({ service: "models.dev" }) +const source = url() +const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, +) +const ttl = 5 * 60 * 1000 - type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] +type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] - const JsonValue: z.ZodType = z.lazy(() => - z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), - ) +const JsonValue: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), +) - const Cost = z.object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - - export const Model = z.object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), - z - .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }) - .strict(), - ]) - .optional(), - cost: Cost.optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), +const Cost = z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), output: z.number(), - }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - experimental: z - .object({ - modes: z - .record( - z.string(), - z.object({ - cost: Cost.optional(), - provider: z - .object({ - body: z.record(z.string(), JsonValue).optional(), - headers: z.record(z.string(), z.string()).optional(), - }) - .optional(), - }), - ) - .optional(), - }) - .optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - }) - export type Model = z.infer - - export const Provider = z.object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - models: z.record(z.string(), Model), - }) - - export type Provider = z.infer - - function url() { - return Flag.OPENCODE_MODELS_URL || "https://models.dev" - } - - function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl - } - - function skip(force: boolean) { - return !force && fresh() - } - - const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), + cache_read: z.number().optional(), + cache_write: z.number().optional(), }) - return { ok: result.ok, text: await result.text() } - } + .optional(), +}) - export const Data = lazy(async () => { +export const Model = z.object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + release_date: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + tool_call: z.boolean(), + interleaved: z + .union([ + z.literal(true), + z + .object({ + field: z.enum(["reasoning_content", "reasoning_details"]), + }) + .strict(), + ]) + .optional(), + cost: Cost.optional(), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), +}) +export type Model = z.infer + +export const Provider = z.object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()), + id: z.string(), + npm: z.string().optional(), + models: z.record(z.string(), Model), +}) + +export type Provider = z.infer + +function url() { + return Flag.OPENCODE_MODELS_URL || "https://models.dev" +} + +function fresh() { + return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl +} + +function skip(force: boolean) { + return !force && fresh() +} + +const fetchApi = async () => { + const result = await fetch(`${url()}/api.json`, { + headers: { "User-Agent": Installation.USER_AGENT }, + signal: AbortSignal.timeout(10000), + }) + return { ok: result.ok, text: await result.text() } +} + +export const Data = lazy(async () => { + const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) + if (result) return result + // @ts-ignore + const snapshot = await import("./models-snapshot.js") + .then((m) => m.snapshot as Record) + .catch(() => undefined) + if (snapshot) return snapshot + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + return Flock.withLock(`models-dev:${filepath}`, async () => { const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) + const result2 = await fetchApi() + if (result2.ok) { + await Filesystem.write(filepath, result2.text).catch((e) => { + log.error("Failed to write models cache", { error: e }) + }) + } + return JSON.parse(result2.text) + }) +}) + +export async function get() { + const result = await Data() + return result as Record +} + +export async function refresh(force = false) { + if (skip(force)) return Data.reset() + await Flock.withLock(`models-dev:${filepath}`, async () => { + if (skip(force)) return Data.reset() + const result = await fetchApi() + if (!result.ok) return + await Filesystem.write(filepath, result.text) + Data.reset() + }).catch((e) => { + log.error("Failed to fetch models.dev", { + error: e, }) }) - - export async function get() { - const result = await Data() - return result as Record - } - - export async function refresh(force = false) { - if (skip(force)) return ModelsDev.Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return ModelsDev.Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - ModelsDev.Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, - }) - }) - } } if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void ModelsDev.refresh() + void refresh() setInterval( async () => { - await ModelsDev.refresh() + await refresh() }, 60 * 1000 * 60, ).unref() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 432dbab34a..77a45cb1be 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/shared/util/hash" import { Plugin } from "../plugin" import { NamedError } from "@opencode-ai/shared/util/error" import { type LanguageModelV3 } from "@ai-sdk/provider" -import { ModelsDev } from "./models" +import * as ModelsDev from "./models" import { Auth } from "../auth" import { Env } from "../env" import { Instance } from "../project/instance" @@ -55,7 +55,7 @@ import { } from "gitlab-ai-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { GoogleAuth } from "google-auth-library" -import { ProviderTransform } from "./transform" +import * as ProviderTransform from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 5fa39441ce..52632f075e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -3,7 +3,7 @@ import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" import type * as Provider from "./provider" -import type { ModelsDev } from "./models" +import type * as ModelsDev from "./models" import { iife } from "@/util/iife" import { Flag } from "@/flag/flag" @@ -17,570 +17,420 @@ function mimeToModality(mime: string): Modality | undefined { return undefined } -export namespace ProviderTransform { - export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 +export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 - // Maps npm package to the key the AI SDK expects for providerOptions - function sdkKey(npm: string): string | undefined { - switch (npm) { - case "@ai-sdk/github-copilot": - return "copilot" - case "@ai-sdk/azure": - return "azure" - case "@ai-sdk/openai": - return "openai" - case "@ai-sdk/amazon-bedrock": - return "bedrock" - case "@ai-sdk/anthropic": - case "@ai-sdk/google-vertex/anthropic": - return "anthropic" - case "@ai-sdk/google-vertex": - return "vertex" - case "@ai-sdk/google": - return "google" - case "@ai-sdk/gateway": - return "gateway" - case "@openrouter/ai-sdk-provider": - return "openrouter" - } - return undefined +// Maps npm package to the key the AI SDK expects for providerOptions +function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + return "copilot" + case "@ai-sdk/azure": + return "azure" + case "@ai-sdk/openai": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + return "vertex" + case "@ai-sdk/google": + return "google" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + return undefined +} + +function normalizeMessages( + msgs: ModelMessage[], + model: Provider.Model, + _options: Record, +): ModelMessage[] { + // Anthropic rejects messages with empty content - filter out empty string messages + // and remove empty text/reasoning parts from array content + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + msgs = msgs + .map((msg) => { + if (typeof msg.content === "string") { + if (msg.content === "") return undefined + return msg + } + if (!Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if (part.type === "text" || part.type === "reasoning") { + return part.text !== "" + } + return true + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } - function normalizeMessages( - msgs: ModelMessage[], - model: Provider.Model, - _options: Record, - ): ModelMessage[] { - // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { - msgs = msgs - .map((msg) => { - if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg - } - if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" - } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } - }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") - } - - if (model.api.id.includes("claude")) { - const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") - msgs = msgs.map((msg) => { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - return { - ...msg, - content: msg.content.map((part) => { - if (part.type === "tool-call" || part.type === "tool-result") { - return { ...part, toolCallId: scrub(part.toolCallId) } - } - return part - }), - } - } - if (msg.role === "tool" && Array.isArray(msg.content)) { - return { - ...msg, - content: msg.content.map((part) => { - if (part.type === "tool-result") { - return { ...part, toolCallId: scrub(part.toolCallId) } - } - return part - }), - } - } - return msg - }) - } - if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { - // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool - // content, e.g. [tool_use, tool_use, text], with: - // `tool_use` ids were found without `tool_result` blocks immediately after... - // - // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive - // assistant messages are later merged by the provider/SDK, so preserving the - // original [tool_use...] then [text] order still produces the invalid payload. - // - // The root cause appears to be somewhere upstream where the stream is originally - // processed. We were unable to locate an exact narrower reproduction elsewhere, - // so we keep this transform in place for the time being. - msgs = msgs.flatMap((msg) => { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] - - const parts = msg.content - const first = parts.findIndex((part) => part.type === "tool-call") - if (first === -1) return [msg] - if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] - return [ - { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, - { ...msg, content: parts.filter((part) => part.type === "tool-call") }, - ] - }) - } - if ( - model.providerID === "mistral" || - model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLocaleLowerCase().includes("devstral") - ) { - const scrub = (id: string) => { - return id - .replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters - .substring(0, 9) // Take first 9 characters - .padEnd(9, "0") // Pad with zeros if less than 9 characters - } - const result: ModelMessage[] = [] - for (let i = 0; i < msgs.length; i++) { - const msg = msgs[i] - const nextMsg = msgs[i + 1] - - if (msg.role === "assistant" && Array.isArray(msg.content)) { - msg.content = msg.content.map((part) => { + if (model.api.id.includes("claude")) { + const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") + msgs = msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.map((part) => { if (part.type === "tool-call" || part.type === "tool-result") { return { ...part, toolCallId: scrub(part.toolCallId) } } return part - }) + }), } - if (msg.role === "tool" && Array.isArray(msg.content)) { - msg.content = msg.content.map((part) => { + } + if (msg.role === "tool" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.map((part) => { if (part.type === "tool-result") { return { ...part, toolCallId: scrub(part.toolCallId) } } return part - }) - } - result.push(msg) - - // Fix message sequence: tool messages cannot be followed by user messages - if (msg.role === "tool" && nextMsg?.role === "user") { - result.push({ - role: "assistant", - content: [ - { - type: "text", - text: "Done.", - }, - ], - }) + }), } } - return result + return msg + }) + } + if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { + // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool + // content, e.g. [tool_use, tool_use, text], with: + // `tool_use` ids were found without `tool_result` blocks immediately after... + // + // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive + // assistant messages are later merged by the provider/SDK, so preserving the + // original [tool_use...] then [text] order still produces the invalid payload. + // + // The root cause appears to be somewhere upstream where the stream is originally + // processed. We were unable to locate an exact narrower reproduction elsewhere, + // so we keep this transform in place for the time being. + msgs = msgs.flatMap((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] + + const parts = msg.content + const first = parts.findIndex((part) => part.type === "tool-call") + if (first === -1) return [msg] + if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] + return [ + { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, + { ...msg, content: parts.filter((part) => part.type === "tool-call") }, + ] + }) + } + if ( + model.providerID === "mistral" || + model.api.id.toLowerCase().includes("mistral") || + model.api.id.toLocaleLowerCase().includes("devstral") + ) { + const scrub = (id: string) => { + return id + .replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters + .substring(0, 9) // Take first 9 characters + .padEnd(9, "0") // Pad with zeros if less than 9 characters } + const result: ModelMessage[] = [] + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + const nextMsg = msgs[i + 1] - if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { - const field = model.capabilities.interleaved.field - return msgs.map((msg) => { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") - - // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - - // Include reasoning_content | reasoning_details directly on the message for all assistant messages - if (reasoningText) { - return { - ...msg, - content: filteredContent, - providerOptions: { - ...msg.providerOptions, - openaiCompatible: { - ...(msg.providerOptions as any)?.openaiCompatible, - [field]: reasoningText, - }, - }, - } + if (msg.role === "assistant" && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if (part.type === "tool-call" || part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } } + return part + }) + } + if (msg.role === "tool" && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if (part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } + } + return part + }) + } + result.push(msg) + // Fix message sequence: tool messages cannot be followed by user messages + if (msg.role === "tool" && nextMsg?.role === "user") { + result.push({ + role: "assistant", + content: [ + { + type: "text", + text: "Done.", + }, + ], + }) + } + } + return result + } + + if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { + const field = model.capabilities.interleaved.field + return msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + + // Filter out reasoning parts from content + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + + // Include reasoning_content | reasoning_details directly on the message for all assistant messages + if (reasoningText) { return { ...msg, content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...(msg.providerOptions as any)?.openaiCompatible, + [field]: reasoningText, + }, + }, } } - return msg - }) - } - - return msgs - } - - function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { - const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) - const final = msgs.filter((msg) => msg.role !== "system").slice(-2) - - const providerOptions = { - anthropic: { - cacheControl: { type: "ephemeral" }, - }, - openrouter: { - cacheControl: { type: "ephemeral" }, - }, - bedrock: { - cachePoint: { type: "default" }, - }, - openaiCompatible: { - cache_control: { type: "ephemeral" }, - }, - copilot: { - copilot_cache_control: { type: "ephemeral" }, - }, - alibaba: { - cacheControl: { type: "ephemeral" }, - }, - } - - for (const msg of unique([...system, ...final])) { - const useMessageLevelOptions = - model.providerID === "anthropic" || - model.providerID.includes("bedrock") || - model.api.npm === "@ai-sdk/amazon-bedrock" - const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 - - if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] - if ( - lastContent && - typeof lastContent === "object" && - lastContent.type !== "tool-approval-request" && - lastContent.type !== "tool-approval-response" - ) { - lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) - continue + return { + ...msg, + content: filteredContent, } } - msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) - } - - return msgs - } - - function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { - return msgs.map((msg) => { - if (msg.role !== "user" || !Array.isArray(msg.content)) return msg - - const filtered = msg.content.map((part) => { - if (part.type !== "file" && part.type !== "image") return part - - // Check for empty base64 image data - if (part.type === "image") { - const imageStr = String(part.image) - if (imageStr.startsWith("data:")) { - const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) - if (match && (!match[2] || match[2].length === 0)) { - return { - type: "text" as const, - text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", - } - } - } - } - - const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType - const filename = part.type === "file" ? part.filename : undefined - const modality = mimeToModality(mime) - if (!modality) return part - if (model.capabilities.input[modality]) return part - - const name = filename ? `"${filename}"` : modality - return { - type: "text" as const, - text: `ERROR: Cannot read ${name} (this model does not support ${modality} input). Inform the user.`, - } - }) - - return { ...msg, content: filtered } + return msg }) } - export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { - msgs = unsupportedParts(msgs, model) - msgs = normalizeMessages(msgs, model, options) - if ( - (model.providerID === "anthropic" || - model.providerID === "google-vertex-anthropic" || - model.api.id.includes("anthropic") || - model.api.id.includes("claude") || - model.id.includes("anthropic") || - model.id.includes("claude") || - model.api.npm === "@ai-sdk/anthropic" || - model.api.npm === "@ai-sdk/alibaba") && - model.api.npm !== "@ai-sdk/gateway" - ) { - msgs = applyCaching(msgs, model) - } + return msgs +} - // Remap providerOptions keys from stored providerID to expected SDK key - const key = sdkKey(model.api.npm) - if (key && key !== model.providerID) { - const remap = (opts: Record | undefined) => { - if (!opts) return opts - if (!(model.providerID in opts)) return opts - const result = { ...opts } - result[key] = result[model.providerID] - delete result[model.providerID] - return result - } +function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) + const final = msgs.filter((msg) => msg.role !== "system").slice(-2) - msgs = msgs.map((msg) => { - if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } - return { - ...msg, - providerOptions: remap(msg.providerOptions), - content: msg.content.map((part) => { - if (part.type === "tool-approval-request" || part.type === "tool-approval-response") { - return { ...part } - } - return { ...part, providerOptions: remap(part.providerOptions) } - }), - } as typeof msg - }) - } - - return msgs + const providerOptions = { + anthropic: { + cacheControl: { type: "ephemeral" }, + }, + openrouter: { + cacheControl: { type: "ephemeral" }, + }, + bedrock: { + cachePoint: { type: "default" }, + }, + openaiCompatible: { + cache_control: { type: "ephemeral" }, + }, + copilot: { + copilot_cache_control: { type: "ephemeral" }, + }, + alibaba: { + cacheControl: { type: "ephemeral" }, + }, } - export function temperature(model: Provider.Model) { - const id = model.id.toLowerCase() - if (id.includes("qwen")) return 0.55 - if (id.includes("claude")) return undefined - if (id.includes("gemini")) return 1.0 - if (id.includes("glm-4.6")) return 1.0 - if (id.includes("glm-4.7")) return 1.0 - if (id.includes("minimax-m2")) return 1.0 - if (id.includes("kimi-k2")) { - // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5 - if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) { - return 1.0 - } - return 0.6 - } - return undefined - } + for (const msg of unique([...system, ...final])) { + const useMessageLevelOptions = + model.providerID === "anthropic" || + model.providerID.includes("bedrock") || + model.api.npm === "@ai-sdk/amazon-bedrock" + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 - export function topP(model: Provider.Model) { - const id = model.id.toLowerCase() - if (id.includes("qwen")) return 1 - if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { - return 0.95 - } - return undefined - } - - export function topK(model: Provider.Model) { - const id = model.id.toLowerCase() - if (id.includes("minimax-m2")) { - if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 - return 20 - } - if (id.includes("gemini")) return 64 - return undefined - } - - const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] - const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - - export function variants(model: Provider.Model): Record> { - if (!model.capabilities.reasoning) return {} - - const id = model.id.toLowerCase() - const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => - model.api.id.includes(v), - ) - const adaptiveEfforts = ["low", "medium", "high", "max"] - if ( - id.includes("deepseek") || - id.includes("minimax") || - id.includes("glm") || - id.includes("mistral") || - id.includes("kimi") || - id.includes("k2p5") || - id.includes("qwen") || - id.includes("big-pickle") - ) - return {} - - // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks - if (id.includes("grok") && id.includes("grok-3-mini")) { - if (model.api.npm === "@openrouter/ai-sdk-provider") { - return { - low: { reasoning: { effort: "low" } }, - high: { reasoning: { effort: "high" } }, - } - } - return { - low: { reasoningEffort: "low" }, - high: { reasoningEffort: "high" }, + if (shouldUseContentOptions) { + const lastContent = msg.content[msg.content.length - 1] + if ( + lastContent && + typeof lastContent === "object" && + lastContent.type !== "tool-approval-request" && + lastContent.type !== "tool-approval-response" + ) { + lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + continue } } - if (id.includes("grok")) return {} - switch (model.api.npm) { - case "@openrouter/ai-sdk-provider": - if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {} - return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) + msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) + } - case "@ai-sdk/gateway": - if (model.id.includes("anthropic")) { - if (isAnthropicAdaptive) { - return Object.fromEntries( - adaptiveEfforts.map((effort) => [ - effort, - { - thinking: { - type: "adaptive", - }, - effort, - }, - ]), - ) - } - return { - high: { - thinking: { - type: "enabled", - budgetTokens: 16000, - }, - }, - max: { - thinking: { - type: "enabled", - budgetTokens: 31999, - }, - }, - } - } - if (model.id.includes("google")) { - if (id.includes("2.5")) { + return msgs +} + +function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + return msgs.map((msg) => { + if (msg.role !== "user" || !Array.isArray(msg.content)) return msg + + const filtered = msg.content.map((part) => { + if (part.type !== "file" && part.type !== "image") return part + + // Check for empty base64 image data + if (part.type === "image") { + const imageStr = String(part.image) + if (imageStr.startsWith("data:")) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + if (match && (!match[2] || match[2].length === 0)) { return { - high: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, - }, - }, - max: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, - }, - }, + type: "text" as const, + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", } } - return Object.fromEntries( - ["low", "high"].map((effort) => [ - effort, - { - includeThoughts: true, - thinkingLevel: effort, - }, - ]), - ) } - return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } - case "@ai-sdk/github-copilot": - if (model.id.includes("gemini")) { - // currently github copilot only returns thinking - return {} - } - if (model.id.includes("claude")) { - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) - } - const copilotEfforts = iife(() => { - if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3")) - return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - const arr = [...WIDELY_SUPPORTED_EFFORTS] - if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh") - return arr - }) - return Object.fromEntries( - copilotEfforts.map((effort) => [ - effort, - { - reasoningEffort: effort, - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ]), - ) + const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType + const filename = part.type === "file" ? part.filename : undefined + const modality = mimeToModality(mime) + if (!modality) return part + if (model.capabilities.input[modality]) return part - case "@ai-sdk/cerebras": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras - case "@ai-sdk/togetherai": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai - case "@ai-sdk/xai": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai - case "@ai-sdk/deepinfra": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra - case "venice-ai-sdk-provider": - // https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort - case "@ai-sdk/openai-compatible": - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + const name = filename ? `"${filename}"` : modality + return { + type: "text" as const, + text: `ERROR: Cannot read ${name} (this model does not support ${modality} input). Inform the user.`, + } + }) - case "@ai-sdk/azure": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure - if (id === "o1-mini") return {} - const azureEfforts = ["low", "medium", "high"] - if (id.includes("gpt-5-") || id === "gpt-5") { - azureEfforts.unshift("minimal") - } - return Object.fromEntries( - azureEfforts.map((effort) => [ - effort, - { - reasoningEffort: effort, - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ]), - ) - case "@ai-sdk/openai": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai - if (id === "gpt-5-pro") return {} - const openaiEfforts = iife(() => { - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return WIDELY_SUPPORTED_EFFORTS + return { ...msg, content: filtered } + }) +} + +export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + msgs = unsupportedParts(msgs, model) + msgs = normalizeMessages(msgs, model, options) + if ( + (model.providerID === "anthropic" || + model.providerID === "google-vertex-anthropic" || + model.api.id.includes("anthropic") || + model.api.id.includes("claude") || + model.id.includes("anthropic") || + model.id.includes("claude") || + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/alibaba") && + model.api.npm !== "@ai-sdk/gateway" + ) { + msgs = applyCaching(msgs, model) + } + + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID) { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } + return { + ...msg, + providerOptions: remap(msg.providerOptions), + content: msg.content.map((part) => { + if (part.type === "tool-approval-request" || part.type === "tool-approval-response") { + return { ...part } } - const arr = [...WIDELY_SUPPORTED_EFFORTS] - if (id.includes("gpt-5-") || id === "gpt-5") { - arr.unshift("minimal") - } - if (model.release_date >= "2025-11-13") { - arr.unshift("none") - } - if (model.release_date >= "2025-12-04") { - arr.push("xhigh") - } - return arr - }) - return Object.fromEntries( - openaiEfforts.map((effort) => [ - effort, - { - reasoningEffort: effort, - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ]), - ) + return { ...part, providerOptions: remap(part.providerOptions) } + }), + } as typeof msg + }) + } - case "@ai-sdk/anthropic": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic - case "@ai-sdk/google-vertex/anthropic": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider + return msgs +} +export function temperature(model: Provider.Model) { + const id = model.id.toLowerCase() + if (id.includes("qwen")) return 0.55 + if (id.includes("claude")) return undefined + if (id.includes("gemini")) return 1.0 + if (id.includes("glm-4.6")) return 1.0 + if (id.includes("glm-4.7")) return 1.0 + if (id.includes("minimax-m2")) return 1.0 + if (id.includes("kimi-k2")) { + // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5 + if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) { + return 1.0 + } + return 0.6 + } + return undefined +} + +export function topP(model: Provider.Model) { + const id = model.id.toLowerCase() + if (id.includes("qwen")) return 1 + if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + return 0.95 + } + return undefined +} + +export function topK(model: Provider.Model) { + const id = model.id.toLowerCase() + if (id.includes("minimax-m2")) { + if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 + return 20 + } + if (id.includes("gemini")) return 64 + return undefined +} + +const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] +const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + +export function variants(model: Provider.Model): Record> { + if (!model.capabilities.reasoning) return {} + + const id = model.id.toLowerCase() + const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => + model.api.id.includes(v), + ) + const adaptiveEfforts = ["low", "medium", "high", "max"] + if ( + id.includes("deepseek") || + id.includes("minimax") || + id.includes("glm") || + id.includes("mistral") || + id.includes("kimi") || + id.includes("k2p5") || + id.includes("qwen") || + id.includes("big-pickle") + ) + return {} + + // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks + if (id.includes("grok") && id.includes("grok-3-mini")) { + if (model.api.npm === "@openrouter/ai-sdk-provider") { + return { + low: { reasoning: { effort: "low" } }, + high: { reasoning: { effort: "high" } }, + } + } + return { + low: { reasoningEffort: "low" }, + high: { reasoningEffort: "high" }, + } + } + if (id.includes("grok")) return {} + + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {} + return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) + + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) { if (isAnthropicAdaptive) { return Object.fromEntries( adaptiveEfforts.map((effort) => [ @@ -594,72 +444,22 @@ export namespace ProviderTransform { ]), ) } - return { high: { thinking: { type: "enabled", - budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + budgetTokens: 16000, }, }, max: { thinking: { type: "enabled", - budgetTokens: Math.min(31_999, model.limit.output - 1), + budgetTokens: 31999, }, }, } - - case "@ai-sdk/amazon-bedrock": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock - if (isAnthropicAdaptive) { - return Object.fromEntries( - adaptiveEfforts.map((effort) => [ - effort, - { - reasoningConfig: { - type: "adaptive", - maxReasoningEffort: effort, - }, - }, - ]), - ) - } - // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens - if (model.api.id.includes("anthropic")) { - return { - high: { - reasoningConfig: { - type: "enabled", - budgetTokens: 16000, - }, - }, - max: { - reasoningConfig: { - type: "enabled", - budgetTokens: 31999, - }, - }, - } - } - - // For Amazon Nova models, use reasoningConfig with maxReasoningEffort - return Object.fromEntries( - WIDELY_SUPPORTED_EFFORTS.map((effort) => [ - effort, - { - reasoningConfig: { - type: "enabled", - maxReasoningEffort: effort, - }, - }, - ]), - ) - - case "@ai-sdk/google-vertex": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex - case "@ai-sdk/google": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai + } + if (model.id.includes("google")) { if (id.includes("2.5")) { return { high: { @@ -676,417 +476,615 @@ export namespace ProviderTransform { }, } } - let levels = ["low", "high"] - if (id.includes("3.1")) { - levels = ["low", "medium", "high"] - } - return Object.fromEntries( - levels.map((effort) => [ + ["low", "high"].map((effort) => [ effort, { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: effort, + includeThoughts: true, + thinkingLevel: effort, + }, + ]), + ) + } + return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + + case "@ai-sdk/github-copilot": + if (model.id.includes("gemini")) { + // currently github copilot only returns thinking + return {} + } + if (model.id.includes("claude")) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + const copilotEfforts = iife(() => { + if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3")) + return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + const arr = [...WIDELY_SUPPORTED_EFFORTS] + if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh") + return arr + }) + return Object.fromEntries( + copilotEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ]), + ) + + case "@ai-sdk/cerebras": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras + case "@ai-sdk/togetherai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai + case "@ai-sdk/xai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai + case "@ai-sdk/deepinfra": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra + case "venice-ai-sdk-provider": + // https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort + case "@ai-sdk/openai-compatible": + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + + case "@ai-sdk/azure": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure + if (id === "o1-mini") return {} + const azureEfforts = ["low", "medium", "high"] + if (id.includes("gpt-5-") || id === "gpt-5") { + azureEfforts.unshift("minimal") + } + return Object.fromEntries( + azureEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ]), + ) + case "@ai-sdk/openai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai + if (id === "gpt-5-pro") return {} + const openaiEfforts = iife(() => { + if (id.includes("codex")) { + if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + return WIDELY_SUPPORTED_EFFORTS + } + const arr = [...WIDELY_SUPPORTED_EFFORTS] + if (id.includes("gpt-5-") || id === "gpt-5") { + arr.unshift("minimal") + } + if (model.release_date >= "2025-11-13") { + arr.unshift("none") + } + if (model.release_date >= "2025-12-04") { + arr.push("xhigh") + } + return arr + }) + return Object.fromEntries( + openaiEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ]), + ) + + case "@ai-sdk/anthropic": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic + case "@ai-sdk/google-vertex/anthropic": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider + + if (isAnthropicAdaptive) { + return Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + thinking: { + type: "adaptive", + }, + effort, + }, + ]), + ) + } + + return { + high: { + thinking: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: Math.min(31_999, model.limit.output - 1), + }, + }, + } + + case "@ai-sdk/amazon-bedrock": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock + if (isAnthropicAdaptive) { + return Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, }, }, ]), ) - - case "@ai-sdk/mistral": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral - return {} - - case "@ai-sdk/cohere": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere - return {} - - case "@ai-sdk/groq": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq - const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS] - return Object.fromEntries( - groqEffort.map((effort) => [ - effort, - { - reasoningEffort: effort, + } + // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens + if (model.api.id.includes("anthropic")) { + return { + high: { + reasoningConfig: { + type: "enabled", + budgetTokens: 16000, }, - ]), - ) + }, + max: { + reasoningConfig: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + } - case "@ai-sdk/perplexity": - // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity - return {} + // For Amazon Nova models, use reasoningConfig with maxReasoningEffort + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [ + effort, + { + reasoningConfig: { + type: "enabled", + maxReasoningEffort: effort, + }, + }, + ]), + ) - case "@jerome-benoit/sap-ai-provider-v2": - if (model.api.id.includes("anthropic")) { - if (isAnthropicAdaptive) { - return Object.fromEntries( - adaptiveEfforts.map((effort) => [ - effort, - { - thinking: { - type: "adaptive", - }, - effort, + case "@ai-sdk/google-vertex": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex + case "@ai-sdk/google": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai + if (id.includes("2.5")) { + return { + high: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 16000, + }, + }, + max: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 24576, + }, + }, + } + } + let levels = ["low", "high"] + if (id.includes("3.1")) { + levels = ["low", "medium", "high"] + } + + return Object.fromEntries( + levels.map((effort) => [ + effort, + { + thinkingConfig: { + includeThoughts: true, + thinkingLevel: effort, + }, + }, + ]), + ) + + case "@ai-sdk/mistral": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral + return {} + + case "@ai-sdk/cohere": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere + return {} + + case "@ai-sdk/groq": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq + const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS] + return Object.fromEntries( + groqEffort.map((effort) => [ + effort, + { + reasoningEffort: effort, + }, + ]), + ) + + case "@ai-sdk/perplexity": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity + return {} + + case "@jerome-benoit/sap-ai-provider-v2": + if (model.api.id.includes("anthropic")) { + if (isAnthropicAdaptive) { + return Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + thinking: { + type: "adaptive", }, - ]), - ) - } - return { - high: { - thinking: { - type: "enabled", - budgetTokens: 16000, + effort, }, - }, - max: { - thinking: { - type: "enabled", - budgetTokens: 31999, - }, - }, - } + ]), + ) } - if (model.api.id.includes("gemini") && id.includes("2.5")) { - return { - high: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, - }, + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, }, - max: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, - }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, }, - } + }, } - if (model.api.id.includes("gpt") || /\bo[1-9]/.test(model.api.id)) { - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + if (model.api.id.includes("gemini") && id.includes("2.5")) { + return { + high: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 16000, + }, + }, + max: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 24576, + }, + }, } - return {} - } - return {} + } + if (model.api.id.includes("gpt") || /\bo[1-9]/.test(model.api.id)) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + return {} + } + return {} +} + +export function options(input: { + model: Provider.Model + sessionID: string + providerOptions?: Record +}): Record { + const result: Record = {} + + // openai and providers using openai package should set store to false by default. + if ( + input.model.providerID === "openai" || + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/github-copilot" + ) { + result["store"] = false } - export function options(input: { - model: Provider.Model - sessionID: string - providerOptions?: Record - }): Record { - const result: Record = {} - - // openai and providers using openai package should set store to false by default. - if ( - input.model.providerID === "openai" || - input.model.api.npm === "@ai-sdk/openai" || - input.model.api.npm === "@ai-sdk/github-copilot" - ) { - result["store"] = false + if (input.model.api.npm === "@openrouter/ai-sdk-provider") { + result["usage"] = { + include: true, } + if (input.model.api.id.includes("gemini-3")) { + result["reasoning"] = { effort: "high" } + } + } - if (input.model.api.npm === "@openrouter/ai-sdk-provider") { - result["usage"] = { - include: true, + if ( + input.model.providerID === "baseten" || + (input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id)) + ) { + result["chat_template_args"] = { enable_thinking: true } + } + + if ( + ["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) && + input.model.api.npm === "@ai-sdk/openai-compatible" + ) { + result["thinking"] = { + type: "enabled", + clear_thinking: false, + } + } + + if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { + result["promptCacheKey"] = input.sessionID + } + + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { + if (input.model.capabilities.reasoning) { + result["thinkingConfig"] = { + includeThoughts: true, } if (input.model.api.id.includes("gemini-3")) { - result["reasoning"] = { effort: "high" } + result["thinkingConfig"]["thinkingLevel"] = "high" } } + } - if ( - input.model.providerID === "baseten" || - (input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id)) - ) { - result["chat_template_args"] = { enable_thinking: true } + // Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK + const modelId = input.model.api.id.toLowerCase() + if ( + (input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") && + (modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5")) + ) { + result["thinking"] = { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)), } + } - if ( - ["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) && - input.model.api.npm === "@ai-sdk/openai-compatible" - ) { - result["thinking"] = { - type: "enabled", - clear_thinking: false, - } - } + // Enable thinking for reasoning models on alibaba-cn (DashScope). + // DashScope's OpenAI-compatible API requires `enable_thinking: true` in the request body + // to return reasoning_content. Without it, models like kimi-k2.5, qwen-plus, qwen3, qwq, + // deepseek-r1, etc. never output thinking/reasoning tokens. + // Note: kimi-k2-thinking is excluded as it returns reasoning_content by default. + if ( + input.model.providerID === "alibaba-cn" && + input.model.capabilities.reasoning && + input.model.api.npm === "@ai-sdk/openai-compatible" && + !modelId.includes("kimi-k2-thinking") + ) { + result["enable_thinking"] = true + } - if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { - result["promptCacheKey"] = input.sessionID - } - - if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { - if (input.model.capabilities.reasoning) { - result["thinkingConfig"] = { - includeThoughts: true, - } - if (input.model.api.id.includes("gemini-3")) { - result["thinkingConfig"]["thinkingLevel"] = "high" - } - } - } - - // Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK - const modelId = input.model.api.id.toLowerCase() - if ( - (input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") && - (modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5")) - ) { - result["thinking"] = { - type: "enabled", - budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)), - } - } - - // Enable thinking for reasoning models on alibaba-cn (DashScope). - // DashScope's OpenAI-compatible API requires `enable_thinking: true` in the request body - // to return reasoning_content. Without it, models like kimi-k2.5, qwen-plus, qwen3, qwq, - // deepseek-r1, etc. never output thinking/reasoning tokens. - // Note: kimi-k2-thinking is excluded as it returns reasoning_content by default. - if ( - input.model.providerID === "alibaba-cn" && - input.model.capabilities.reasoning && - input.model.api.npm === "@ai-sdk/openai-compatible" && - !modelId.includes("kimi-k2-thinking") - ) { - result["enable_thinking"] = true - } - - if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { - if (!input.model.api.id.includes("gpt-5-pro")) { - result["reasoningEffort"] = "medium" - // Only inject reasoningSummary for providers that support it natively. - // @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this - // parameter and return "Unknown parameter: 'reasoningSummary'". - if ( - input.model.api.npm === "@ai-sdk/openai" || - input.model.api.npm === "@ai-sdk/azure" || - input.model.api.npm === "@ai-sdk/github-copilot" - ) { - result["reasoningSummary"] = "auto" - } - } - - // Only set textVerbosity for non-chat gpt-5.x models - // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity + if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { + if (!input.model.api.id.includes("gpt-5-pro")) { + result["reasoningEffort"] = "medium" + // Only inject reasoningSummary for providers that support it natively. + // @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this + // parameter and return "Unknown parameter: 'reasoningSummary'". if ( - input.model.api.id.includes("gpt-5.") && - !input.model.api.id.includes("codex") && - !input.model.api.id.includes("-chat") && - input.model.providerID !== "azure" + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/github-copilot" ) { - result["textVerbosity"] = "low" - } - - if (input.model.providerID.startsWith("opencode")) { - result["promptCacheKey"] = input.sessionID - result["include"] = ["reasoning.encrypted_content"] result["reasoningSummary"] = "auto" } } - if (input.model.providerID === "venice") { - result["promptCacheKey"] = input.sessionID + // Only set textVerbosity for non-chat gpt-5.x models + // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity + if ( + input.model.api.id.includes("gpt-5.") && + !input.model.api.id.includes("codex") && + !input.model.api.id.includes("-chat") && + input.model.providerID !== "azure" + ) { + result["textVerbosity"] = "low" } - if (input.model.providerID === "openrouter") { - result["prompt_cache_key"] = input.sessionID + if (input.model.providerID.startsWith("opencode")) { + result["promptCacheKey"] = input.sessionID + result["include"] = ["reasoning.encrypted_content"] + result["reasoningSummary"] = "auto" } - if (input.model.api.npm === "@ai-sdk/gateway") { - result["gateway"] = { - caching: "auto", + } + + if (input.model.providerID === "venice") { + result["promptCacheKey"] = input.sessionID + } + + if (input.model.providerID === "openrouter") { + result["prompt_cache_key"] = input.sessionID + } + if (input.model.api.npm === "@ai-sdk/gateway") { + result["gateway"] = { + caching: "auto", + } + } + + return result +} + +export function smallOptions(model: Provider.Model) { + if ( + model.providerID === "openai" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/github-copilot" + ) { + if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("5.")) { + return { store: false, reasoningEffort: "low" } + } + return { store: false, reasoningEffort: "minimal" } + } + return { store: false } + } + if (model.providerID === "google") { + // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget + if (model.api.id.includes("gemini-3")) { + return { thinkingConfig: { thinkingLevel: "minimal" } } + } + return { thinkingConfig: { thinkingBudget: 0 } } + } + if (model.providerID === "openrouter") { + if (model.api.id.includes("google")) { + return { reasoning: { enabled: false } } + } + return { reasoningEffort: "minimal" } + } + + if (model.providerID === "venice") { + return { veniceParameters: { disableThinking: true } } + } + + return {} +} + +// Maps model ID prefix to provider slug used in providerOptions. +// Example: "amazon/nova-2-lite" → "bedrock" +const SLUG_OVERRIDES: Record = { + amazon: "bedrock", +} + +export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + if (model.api.npm === "@ai-sdk/gateway") { + // Gateway providerOptions are split across two namespaces: + // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) + // - ``: provider-specific model options (anthropic/openai/...) + // We keep `gateway` as-is and route every other top-level option under the + // model-derived upstream slug. + const i = model.api.id.indexOf("/") + const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined + const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined + const gateway = options.gateway + const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) + const has = Object.keys(rest).length > 0 + + const result: Record = {} + if (gateway !== undefined) result.gateway = gateway + + if (has) { + if (slug) { + // Route model-specific options under the provider slug + result[slug] = rest + } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) { + result.gateway = { ...gateway, ...rest } + } else { + result.gateway = rest } } return result } - export function smallOptions(model: Provider.Model) { - if ( - model.providerID === "openai" || - model.api.npm === "@ai-sdk/openai" || - model.api.npm === "@ai-sdk/github-copilot" - ) { - if (model.api.id.includes("gpt-5")) { - if (model.api.id.includes("5.")) { - return { store: false, reasoningEffort: "low" } + const key = sdkKey(model.api.npm) ?? model.providerID + // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from + // providerOptions["openai"], but OpenAIResponsesLanguageModel checks + // "azure" first. Pass both so model options work on either code path. + if (model.api.npm === "@ai-sdk/azure") { + return { openai: options, azure: options } + } + return { [key]: options } +} + +export function maxOutputTokens(model: Provider.Model): number { + return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX +} + +export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { + /* + if (["openai", "azure"].includes(providerID)) { + if (schema.type === "object" && schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + if (schema.required?.includes(key)) continue + schema.properties[key] = { + anyOf: [ + value as JSONSchema.JSONSchema, + { + type: "null", + }, + ], } - return { store: false, reasoningEffort: "minimal" } } - return { store: false } } - if (model.providerID === "google") { - // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget - if (model.api.id.includes("gemini-3")) { - return { thinkingConfig: { thinkingLevel: "minimal" } } - } - return { thinkingConfig: { thinkingBudget: 0 } } - } - if (model.providerID === "openrouter") { - if (model.api.id.includes("google")) { - return { reasoning: { enabled: false } } - } - return { reasoningEffort: "minimal" } - } - - if (model.providerID === "venice") { - return { veniceParameters: { disableThinking: true } } - } - - return {} } + */ - // Maps model ID prefix to provider slug used in providerOptions. - // Example: "amazon/nova-2-lite" → "bedrock" - const SLUG_OVERRIDES: Record = { - amazon: "bedrock", - } + // Convert integer enums to string enums for Google/Gemini + if (model.providerID === "google" || model.api.id.includes("gemini")) { + const isPlainObject = (node: unknown): node is Record => + typeof node === "object" && node !== null && !Array.isArray(node) + const hasCombiner = (node: unknown) => + isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) + const hasSchemaIntent = (node: unknown) => { + if (!isPlainObject(node)) return false + if (hasCombiner(node)) return true + return [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", + ].some((key) => key in node) + } - export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - if (model.api.npm === "@ai-sdk/gateway") { - // Gateway providerOptions are split across two namespaces: - // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) - // - ``: provider-specific model options (anthropic/openai/...) - // We keep `gateway` as-is and route every other top-level option under the - // model-derived upstream slug. - const i = model.api.id.indexOf("/") - const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined - const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined - const gateway = options.gateway - const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) - const has = Object.keys(rest).length > 0 + const sanitizeGemini = (obj: any): any => { + if (obj === null || typeof obj !== "object") { + return obj + } - const result: Record = {} - if (gateway !== undefined) result.gateway = gateway + if (Array.isArray(obj)) { + return obj.map(sanitizeGemini) + } - if (has) { - if (slug) { - // Route model-specific options under the provider slug - result[slug] = rest - } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) { - result.gateway = { ...gateway, ...rest } + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + if (key === "enum" && Array.isArray(value)) { + // Convert all enum values to strings + result[key] = value.map((v) => String(v)) + // If we have integer type with enum, change type to string + if (result.type === "integer" || result.type === "number") { + result.type = "string" + } + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeGemini(value) } else { - result.gateway = rest + result[key] = value } } + // Filter required array to only include fields that exist in properties + if (result.type === "object" && result.properties && Array.isArray(result.required)) { + result.required = result.required.filter((field: any) => field in result.properties) + } + + if (result.type === "array" && !hasCombiner(result)) { + if (result.items == null) { + result.items = {} + } + // Ensure items has a type only when it's still schema-empty. + if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) { + result.items.type = "string" + } + } + + // Remove properties/required from non-object types (Gemini rejects these) + if (result.type && result.type !== "object" && !hasCombiner(result)) { + delete result.properties + delete result.required + } + return result } - const key = sdkKey(model.api.npm) ?? model.providerID - // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from - // providerOptions["openai"], but OpenAIResponsesLanguageModel checks - // "azure" first. Pass both so model options work on either code path. - if (model.api.npm === "@ai-sdk/azure") { - return { openai: options, azure: options } - } - return { [key]: options } + schema = sanitizeGemini(schema) } - export function maxOutputTokens(model: Provider.Model): number { - return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX - } - - export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { - /* - if (["openai", "azure"].includes(providerID)) { - if (schema.type === "object" && schema.properties) { - for (const [key, value] of Object.entries(schema.properties)) { - if (schema.required?.includes(key)) continue - schema.properties[key] = { - anyOf: [ - value as JSONSchema.JSONSchema, - { - type: "null", - }, - ], - } - } - } - } - */ - - // Convert integer enums to string enums for Google/Gemini - if (model.providerID === "google" || model.api.id.includes("gemini")) { - const isPlainObject = (node: unknown): node is Record => - typeof node === "object" && node !== null && !Array.isArray(node) - const hasCombiner = (node: unknown) => - isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) - const hasSchemaIntent = (node: unknown) => { - if (!isPlainObject(node)) return false - if (hasCombiner(node)) return true - return [ - "type", - "properties", - "items", - "prefixItems", - "enum", - "const", - "$ref", - "additionalProperties", - "patternProperties", - "required", - "not", - "if", - "then", - "else", - ].some((key) => key in node) - } - - const sanitizeGemini = (obj: any): any => { - if (obj === null || typeof obj !== "object") { - return obj - } - - if (Array.isArray(obj)) { - return obj.map(sanitizeGemini) - } - - const result: any = {} - for (const [key, value] of Object.entries(obj)) { - if (key === "enum" && Array.isArray(value)) { - // Convert all enum values to strings - result[key] = value.map((v) => String(v)) - // If we have integer type with enum, change type to string - if (result.type === "integer" || result.type === "number") { - result.type = "string" - } - } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeGemini(value) - } else { - result[key] = value - } - } - - // Filter required array to only include fields that exist in properties - if (result.type === "object" && result.properties && Array.isArray(result.required)) { - result.required = result.required.filter((field: any) => field in result.properties) - } - - if (result.type === "array" && !hasCombiner(result)) { - if (result.items == null) { - result.items = {} - } - // Ensure items has a type only when it's still schema-empty. - if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) { - result.items.type = "string" - } - } - - // Remove properties/required from non-object types (Gemini rejects these) - if (result.type && result.type !== "object" && !hasCombiner(result)) { - delete result.properties - delete result.required - } - - return result - } - - schema = sanitizeGemini(schema) - } - - return schema as JSONSchema7 - } + return schema as JSONSchema7 } diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts index e59f23f123..31dd1446a0 100644 --- a/packages/opencode/src/server/instance/httpapi/provider.ts +++ b/packages/opencode/src/server/instance/httpapi/provider.ts @@ -1,4 +1,4 @@ -import { ProviderAuth } from "@/provider/auth" +import { ProviderAuth } from "@/provider" import { Effect, Layer } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 0057218f3b..c1580437da 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -3,8 +3,8 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Config } from "../../config" import { Provider } from "../../provider" -import { ModelsDev } from "../../provider/models" -import { ProviderAuth } from "../../provider/auth" +import { ModelsDev } from "../../provider" +import { ProviderAuth } from "../../provider" import { ProviderID } from "../../provider/schema" import { AppRuntime } from "../../effect/app-runtime" import { mapValues } from "remeda" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0652a599a2..2d1577e7e3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -5,7 +5,7 @@ import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" -import { ProviderTransform } from "@/provider/transform" +import { ProviderTransform } from "@/provider" import { Config } from "@/config" import { Instance } from "@/project/instance" import type { Agent } from "@/agent/agent" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5dcf0dcd1c..f5ba74826d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -8,7 +8,7 @@ import { Snapshot } from "@/snapshot" import { SyncEvent } from "../sync" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage" import { MessageTable, PartTable, SessionTable } from "./session.sql" -import { ProviderError } from "@/provider/error" +import { ProviderError } from "@/provider" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import type { SystemError } from "bun" diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 10f4bccda3..6f48a760df 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,6 +1,6 @@ import type { Config } from "@/config" import type { Provider } from "@/provider" -import { ProviderTransform } from "@/provider/transform" +import { ProviderTransform } from "@/provider" import type { MessageV2 } from "./message-v2" const COMPACTION_BUFFER = 20_000 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 44073c8501..4b8b95baa8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -12,7 +12,7 @@ import { ModelID, ProviderID } from "../provider/schema" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { SessionCompaction } from "./compaction" import { Bus } from "../bus" -import { ProviderTransform } from "../provider/transform" +import { ProviderTransform } from "../provider" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 0c619c2edc..b570d8b141 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect } from "effect" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" -import { ProviderAuth } from "../../src/provider/auth" +import { ProviderAuth } from "../../src/provider" import { ProviderID } from "../../src/provider/schema" describe("plugin.auth-override", () => { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 300a5b9031..df8fc4e966 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "../fixture/fixture" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" -import { ModelsDev } from "../../src/provider/models" +import { ModelsDev } from "../../src/provider" import { Provider } from "../../src/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util" diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0e0810d0e9..0666d0f641 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { ProviderTransform } from "../../src/provider/transform" +import { ProviderTransform } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" describe("ProviderTransform.options - setCacheKey", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index f26bef6052..4d82096f3f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -7,8 +7,8 @@ import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider" -import { ProviderTransform } from "../../src/provider/transform" -import { ModelsDev } from "../../src/provider/models" +import { ProviderTransform } from "../../src/provider" +import { ModelsDev } from "../../src/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" From 150ab07a833f0b10f4af17b3dd713cfedb16a6ff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 16 Apr 2026 05:03:50 +0000 Subject: [PATCH 66/75] chore: generate --- packages/opencode/src/provider/error.ts | 4 +--- packages/opencode/src/provider/transform.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 42378b6866..a2409559f5 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -185,9 +185,7 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal type: "api_error", message: m, statusCode: input.error.statusCode, - isRetryable: input.providerID.startsWith("openai") - ? isOpenAiErrorRetryable(input.error) - : input.error.isRetryable, + isRetryable: input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable, responseHeaders: input.error.responseHeaders, responseBody: input.error.responseBody, metadata, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 52632f075e..c940b31c8c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -393,9 +393,7 @@ export function variants(model: Provider.Model): Record - model.api.id.includes(v), - ) + const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => model.api.id.includes(v)) const adaptiveEfforts = ["low", "medium", "high", "max"] if ( id.includes("deepseek") || From 675a46e23e679c294355435584ae662a7c0903c7 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 16 Apr 2026 02:03:03 -0400 Subject: [PATCH 67/75] CLI perf: reduce deps (#22652) --- bun.lock | 2 + packages/opencode/.gitignore | 3 + packages/opencode/package.json | 1 + packages/opencode/script/schema.ts | 2 +- packages/opencode/src/acp/agent.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 5 +- packages/opencode/src/cli/cmd/tui/app.tsx | 7 +- packages/opencode/src/cli/cmd/tui/attach.ts | 9 +- .../cli/cmd/tui/component/dialog-agent.tsx | 2 +- .../cli/cmd/tui/component/error-component.tsx | 4 +- .../src/cli/cmd/tui/component/prompt/cwd.ts | 0 .../cli/cmd/tui/component/prompt/index.tsx | 53 +- .../opencode/src/cli/cmd/tui/config/cwd.ts | 5 + .../{ => cli/cmd/tui}/config/tui-migrate.ts | 15 +- .../{ => cli/cmd/tui}/config/tui-schema.ts | 7 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 208 ++++++++ .../src/cli/cmd/tui/context/keybind.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 40 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 8 +- .../src/cli/cmd/tui/context/tui-config.tsx | 2 +- packages/opencode/src/cli/cmd/tui/layer.ts | 6 + .../opencode/src/cli/cmd/tui/plugin/api.tsx | 6 +- .../src/cli/cmd/tui/plugin/runtime.ts | 107 ++-- .../cli/cmd/tui/routes/session/sidebar.tsx | 4 +- packages/opencode/src/cli/cmd/tui/thread.ts | 13 +- .../opencode/src/cli/cmd/tui/ui/toast.tsx | 5 +- .../src/cli/cmd/tui/util/clipboard.ts | 25 +- .../opencode/src/cli/cmd/tui/util/scroll.ts | 2 +- packages/opencode/src/cli/cmd/upgrade.ts | 5 +- packages/opencode/src/cli/effect/runtime.ts | 20 + packages/opencode/src/cli/error.ts | 100 ++-- packages/opencode/src/cli/network.ts | 5 +- packages/opencode/src/cli/upgrade.ts | 5 +- packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/config/index.ts | 1 - packages/opencode/src/config/keybinds.ts | 164 ++++++ packages/opencode/src/config/paths.ts | 4 +- packages/opencode/src/config/plugin.ts | 75 +++ packages/opencode/src/config/tui.ts | 212 -------- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/effect/observability.ts | 8 +- packages/opencode/src/file/file.ts | 5 +- packages/opencode/src/index.ts | 5 +- .../opencode/src/installation/installation.ts | 14 +- packages/opencode/src/installation/meta.ts | 7 - packages/opencode/src/installation/version.ts | 8 + packages/opencode/src/mcp/mcp.ts | 5 +- packages/opencode/src/npm/npm.ts | 3 +- packages/opencode/src/plugin/codex.ts | 7 +- .../src/plugin/github-copilot/copilot.ts | 9 +- packages/opencode/src/plugin/install.ts | 2 +- packages/opencode/src/plugin/loader.ts | 26 +- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/src/provider/provider.ts | 108 ++-- .../opencode/src/server/instance/global.ts | 3 +- packages/opencode/src/session/llm.ts | 3 +- packages/opencode/src/session/session.ts | 3 +- packages/opencode/src/storage/db.ts | 6 +- packages/opencode/src/temporary.ts | 33 ++ packages/opencode/src/util/filesystem.ts | 4 +- .../opencode/test/cli/tui/plugin-add.test.ts | 22 +- .../test/cli/tui/plugin-install.test.ts | 8 +- .../test/cli/tui/plugin-lifecycle.test.ts | 16 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 58 +-- .../test/cli/tui/plugin-loader-pure.test.ts | 9 +- .../test/cli/tui/plugin-loader.test.ts | 95 +++- .../test/cli/tui/plugin-toggle.test.ts | 16 +- packages/opencode/test/cli/tui/thread.test.ts | 11 +- packages/opencode/test/config/config.test.ts | 21 +- packages/opencode/test/config/plugin.test.ts | 0 packages/opencode/test/config/tui.test.ts | 487 ++++++------------ packages/opencode/test/file/index.test.ts | 4 +- packages/opencode/test/fixture/tui-runtime.ts | 26 +- packages/opencode/test/storage/db.test.ts | 6 +- packages/opencode/test/tool/read.test.ts | 1 + .../opencode/test/util/filesystem.test.ts | 26 +- packages/opencode/time.ts | 4 + packages/opencode/trace-imports.ts | 153 ++++++ packages/opencode/tsconfig.json | 3 +- packages/shared/package.json | 7 +- packages/shared/src/npm.ts | 25 +- packages/shared/src/util/error.ts | 6 + packages/shared/src/util/flock.ts | 10 +- packages/shared/tsconfig.json | 9 - 84 files changed, 1415 insertions(+), 1011 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts create mode 100644 packages/opencode/src/cli/cmd/tui/config/cwd.ts rename packages/opencode/src/{ => cli/cmd/tui}/config/tui-migrate.ts (91%) rename packages/opencode/src/{ => cli/cmd/tui}/config/tui-schema.ts (78%) create mode 100644 packages/opencode/src/cli/cmd/tui/config/tui.ts create mode 100644 packages/opencode/src/cli/cmd/tui/layer.ts create mode 100644 packages/opencode/src/cli/effect/runtime.ts create mode 100644 packages/opencode/src/config/keybinds.ts create mode 100644 packages/opencode/src/config/plugin.ts delete mode 100644 packages/opencode/src/config/tui.ts delete mode 100644 packages/opencode/src/installation/meta.ts create mode 100644 packages/opencode/src/installation/version.ts create mode 100644 packages/opencode/src/temporary.ts create mode 100644 packages/opencode/test/config/plugin.test.ts create mode 100755 packages/opencode/time.ts create mode 100755 packages/opencode/trace-imports.ts diff --git a/bun.lock b/bun.lock index a011a648fe..644de37f2e 100644 --- a/bun.lock +++ b/bun.lock @@ -523,7 +523,9 @@ "zod": "catalog:", }, "devDependencies": { + "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", "@types/semver": "catalog:", }, }, diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 348f05113e..2b20d9c312 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -1,6 +1,9 @@ research dist +dist-* gen app.log src/provider/models-snapshot.js src/provider/models-snapshot.d.ts +script/build-*.ts +temporary-*.md diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c0f82c1495..7ed33ebe09 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -14,6 +14,7 @@ "fix-node-pty": "bun run script/fix-node-pty.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", + "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "db": "bun drizzle-kit" }, "bin": { diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index cf63d67438..c0f302f21a 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { Config } from "../src/config" -import { TuiConfig } from "../src/config" +import { TuiConfig } from "../src/cli/cmd/tui/config/tui" function generate(schema: z.ZodType) { const result = z.toJSONSchema(schema, { diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 57cce66680..53bc7ed5fb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -49,6 +49,7 @@ import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" +import { InstallationVersion } from "@/installation/version" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -570,7 +571,7 @@ export namespace ACP { authMethods: [authMethod], agentInfo: { name: "OpenCode", - version: Installation.VERSION, + version: InstallationVersion, }, } } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06c03d9f49..dc6d5e8896 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config" import { Instance } from "../../project/instance" import { Installation } from "../../installation" +import { InstallationVersion } from "../../installation/version" import path from "path" import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" @@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({ params: { protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: "opencode-debug", version: Installation.VERSION }, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, id: 1, }), @@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({ try { const client = new Client({ name: "opencode-debug", - version: Installation.VERSION, + version: InstallationVersion, }) await client.connect(transport) prompts.log.success("Connection successful (already authenticated)") diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5102169b5c..8255c007d0 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" -import { TuiConfig } from "@/config" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" @@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise }) { renderer, }) const [ready, setReady] = createSignal(false) - TuiPluginRuntime.init(api) + TuiPluginRuntime.init({ + api, + config: tuiConfig, + }) .catch((error) => { console.error("Failed to load TUI plugins", error) }) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 9fcbf4c1f3..9a93f3f57a 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -2,9 +2,7 @@ import { cmd } from "../cmd" import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config" -import { Instance } from "@/project/instance" -import { existsSync } from "fs" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" export const AttachCommand = cmd({ command: "attach ", @@ -66,10 +64,7 @@ export const AttachCommand = cmd({ const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() - const config = await Instance.provide({ - directory: directory && existsSync(directory) ? directory : process.cwd(), - fn: () => TuiConfig.get(), - }) + const config = await TuiConfig.get() await tui({ url: args.url, config, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 365a22445b..017e52d2b4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -20,7 +20,7 @@ export function DialogAgent() { return ( { local.agent.set(option.value) diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index 38df35a04a..c74d3bbc63 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { createSignal } from "solid-js" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { win32FlushInputBuffer } from "../win32" import { getScrollAcceleration } from "../util/scroll" @@ -53,7 +53,7 @@ export function ErrorComponent(props: { ) } - issueURL.searchParams.set("opencode-version", Installation.VERSION) + issueURL.searchParams.set("opencode-version", InstallationVersion) const copyIssueURL = () => { void Clipboard.copy(issueURL.toString()).then(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 20003d8467..b4ab82729f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) { if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return + const agent = local.agent.current() + if (!agent) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() @@ -662,7 +664,7 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { void sdk.client.session.shell({ sessionID, - agent: local.agent.current().name, + agent: agent.name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, @@ -689,7 +691,7 @@ export function Prompt(props: PromptProps) { sessionID, command: command.slice(1), arguments: args, - agent: local.agent.current().name, + agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, @@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) { sessionID, ...selectedModel, messageID, - agent: local.agent.current().name, + agent: agent.name, model: selectedModel, variant, parts: [ @@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary - return local.agent.color(local.agent.current().name) + const agent = local.agent.current() + if (!agent) return theme.border + return local.agent.color(agent.name) }) const showVariant = createMemo(() => { @@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const color = local.agent.color(local.agent.current().name) + const agent = local.agent.current() + const color = agent ? local.agent.color(agent.name) : theme.border return { frames: createFrames({ color, @@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) { const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { - const mime = Filesystem.mimeType(filepath) + const mime = await Filesystem.mimeType(filepath) const filename = path.basename(filepath) // Handle SVG as raw text content, not as base64 image if (mime === "image/svg+xml") { @@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) { /> - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - - {local.model.parsed().model} - - {currentProviderLabel()} - - · - - {local.model.variant.current()} - - - + }> + {(agent) => ( + <> + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + + + + {local.model.parsed().model} + + {currentProviderLabel()} + + · + + {local.model.variant.current()} + + + + + + )} diff --git a/packages/opencode/src/cli/cmd/tui/config/cwd.ts b/packages/opencode/src/cli/cmd/tui/config/cwd.ts new file mode 100644 index 0000000000..22f342d8d3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/cwd.ts @@ -0,0 +1,5 @@ +import { Context } from "effect" + +export const CurrentWorkingDirectory = Context.Reference("CurrentWorkingDirectory", { + defaultValue: () => process.cwd(), +}) diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts similarity index 91% rename from packages/opencode/src/config/tui-migrate.ts rename to packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index ed19474be2..3ce5c4b739 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -2,13 +2,11 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" import z from "zod" -import * as ConfigPaths from "./paths" import { TuiInfo, TuiOptions } from "./tui-schema" -import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" -import { Log } from "@/util" -import { Filesystem } from "@/util" import { Global } from "@/global" +import { Filesystem, Log } from "@/util" +import * as ConfigPaths from "@/config/paths" const log = Log.create({ service: "tui.migrate" }) @@ -26,9 +24,9 @@ const TuiLegacy = z .strip() interface MigrateInput { + cwd: string directories: string[] custom?: string - managed: string } /** @@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) { }) } -async function opencodeFiles(input: { directories: string[]; managed: string }) { - const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree) +async function opencodeFiles(input: { directories: string[]; cwd: string }) { + const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd) const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")] for (const dir of unique(input.directories)) { files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) } if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG) - files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode")) const existing = await Promise.all( unique(files).map(async (file) => { diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts similarity index 78% rename from packages/opencode/src/config/tui-schema.ts rename to packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 3be988370d..66569efea5 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,9 +1,10 @@ import z from "zod" -import * as Config from "./config" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" const KeybindOverride = z .object( - Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< + Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< string, z.ZodOptional >, @@ -30,7 +31,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), - plugin: Config.PluginSpec.array().optional(), + plugin: ConfigPlugin.Spec.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts new file mode 100644 index 0000000000..6f2c161fb5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -0,0 +1,208 @@ +import z from "zod" +import { mergeDeep, unique } from "remeda" +import { Context, Effect, Fiber, Layer } from "effect" +import * as ConfigPaths from "@/config/paths" +import { migrateTuiConfig } from "./tui-migrate" +import { TuiInfo } from "./tui-schema" +import { Flag } from "@/flag/flag" +import { isRecord } from "@/util/record" +import { Global } from "@/global" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Npm } from "@opencode-ai/shared/npm" +import { CurrentWorkingDirectory } from "./cwd" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" +import { InstallationLocal, InstallationVersion } from "@/installation/version" +import { makeRuntime } from "@/cli/effect/runtime" +import { Filesystem, Log } from "@/util" + +export namespace TuiConfig { + const log = Log.create({ service: "tui.config" }) + + export const Info = TuiInfo + + type Acc = { + result: Info + } + + type State = { + config: Info + deps: Array> + } + + export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: ConfigPlugin.Origin[] + } + + export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect + } + + export class Service extends Context.Service()("@opencode/TuiConfig") {} + + function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { + if (Filesystem.contains(ctx.directory, file)) return "local" + // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" + return "global" + } + + function customPath() { + return Flag.OPENCODE_TUI_CONFIG + } + + function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { + delete data.tui + return data + } + + const tui = data.tui + delete data.tui + return { + ...tui, + ...data, + } + } + + async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { + const data = await loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins + } + + async function loadState(ctx: { directory: string }) { + let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) + const directories = await ConfigPaths.directories(ctx.directory) + const custom = customPath() + await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) + // Re-compute after migration since migrateTuiConfig may have created new tui.json files + projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) + + const acc: Acc = { + result: {}, + } + + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + await mergeFile(acc, file, ctx) + } + + if (custom) { + await mergeFile(acc, custom, ctx) + log.debug("loaded custom tui config", { path: custom }) + } + + for (const file of projectFiles) { + await mergeFile(acc, file, ctx) + } + + const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) + + for (const dir of dirs) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + await mergeFile(acc, file, ctx) + } + } + + const keybinds = { ...(acc.result.keybinds ?? {}) } + if (process.platform === "win32") { + // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. + keybinds.terminal_suspend = "none" + keybinds.input_undo ??= unique([ + "ctrl+z", + ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ]).join(",") + } + acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + + return { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } + } + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const directory = yield* CurrentWorkingDirectory + const npm = yield* Npm.Service + const data = yield* Effect.promise(() => loadState({ directory })) + const deps = yield* Effect.forEach( + data.dirs, + (dir) => + npm + .install(dir, { + add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + }) + .pipe(Effect.forkScoped), + { + concurrency: "unbounded", + }, + ) + + const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), + ) + return Service.of({ get, waitForDependencies }) + }).pipe(Effect.withSpan("TuiConfig.layer")), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) + } + + export async function get() { + return runPromise((svc) => svc.get()) + } + + async function loadFile(filepath: string): Promise { + const text = await ConfigPaths.readFile(filepath) + if (!text) return {} + return load(text, filepath).catch((error) => { + log.warn("failed to load tui config", { path: filepath, error }) + return {} + }) + } + + async function load(text: string, configFilepath: string): Promise { + const raw = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!isRecord(raw)) return {} + + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const normalized = normalize(raw) + + const parsed = Info.safeParse(normalized) + if (!parsed.success) { + log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) + return {} + } + + const data = parsed.data + if (data.plugin) { + for (let i = 0; i < data.plugin.length; i++) { + data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index b1dcdd7808..bf40f6b87d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" import { Keybind } from "@/util" import { pipe, mapValues } from "remeda" -import type { TuiConfig } from "@/config" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import type { ParsedKey, Renderable } from "@opentui/core" import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 4c298ec113..bb73c65378 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,4 +1,5 @@ import { createStore } from "solid-js/store" +import { createSimpleContext } from "./helper" import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" @@ -6,14 +7,20 @@ import { uniqueBy } from "remeda" import path from "path" import { Global } from "@/global" import { iife } from "@/util/iife" -import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" -import { Provider } from "@/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util" +export function parseModel(model: string) { + const [providerID, ...rest] = model.split("/") + return { + providerID: providerID, + modelID: rest.join("/"), + } +} + export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = iife(() => { const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden)) - const [agentStore, setAgentStore] = createStore<{ - current: string - }>({ - current: agents()[0].name, + const [agentStore, setAgentStore] = createStore({ + current: undefined as string | undefined, }) const { theme } = useTheme() const colors = createMemo(() => [ @@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((x) => x.name === agentStore.current) ?? agents()[0] + return agents().find((x) => x.name === agentStore.current) ?? agents().at(0) }, set(name: string) { if (!agents().some((x) => x.name === name)) @@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() const fallbackModel = createMemo(() => { if (args.model) { - const { providerID, modelID } = Provider.parseModel(args.model) + const { providerID, modelID } = parseModel(args.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } if (sync.data.config.model) { - const { providerID, modelID } = Provider.parseModel(sync.data.config.model) + const { providerID, modelID } = parseModel(sync.data.config.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() return ( getFirstValidModel( - () => modelStore.model[a.name], - () => a.model, + () => a && modelStore.model[a.name], + () => a && a.model, fallbackModel, ) ?? undefined ) @@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - setModelStore("model", agent.current().name, { ...val }) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, { ...val }) }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const next = favorites[index] if (!next) return - setModelStore("model", agent.current().name, { ...next }) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, { ...next }) const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() setModelStore( @@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return } - setModelStore("model", agent.current().name, model) + const a = agent.current() + if (!a) return + setModelStore("model", a.name, model) if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) if (uniq.length > 10) uniq.pop() @@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { const value = agent.current() + if (!value) return if (value.model) { if (isModelValid(value.model)) model.set({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2558f9751f..46227e28aa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,7 +29,7 @@ import { useExit } from "./exit" import { useArgs } from "./args" import { batch, createEffect, on } from "solid-js" import { Log } from "@/util" -import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" +import { emptyConsoleState, type ConsoleState } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider: Provider[] provider_default: Record provider_next: ProviderListResponse - console_state: ConsoleStateType + console_state: ConsoleState provider_auth: Record agent: Agent[] command: Command[] @@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console .get({ workspace }, { throwOnError: true }) - .then((x) => ConsoleState.parse(x.data)) + .then((x) => x.data) .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) @@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ] await Promise.all(blockingRequests) - .then(() => { + .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) const consoleStateResponse = consoleStatePromise diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index cfe59ba803..05fdd025c7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -1,4 +1,4 @@ -import { TuiConfig } from "@/config" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts new file mode 100644 index 0000000000..734106f8a6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layer.ts @@ -0,0 +1,6 @@ +import { Layer } from "effect" +import { TuiConfig } from "./config/tui" +import { Npm } from "@opencode-ai/shared/npm" +import { Observability } from "@/effect/observability" + +export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 42988fcb1f..d2b495ca31 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" -import type { TuiConfig } from "@/config" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createPluginKeybind } from "../context/plugin-keybinds" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" @@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" type RouteEntry = { key: symbol @@ -189,7 +189,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { function appApi(): TuiPluginApi["app"] { return { get version() { - return Installation.VERSION + return InstallationVersion }, } } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index da003607c4..af37ffbd76 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1,4 +1,4 @@ -import "@opentui/solid/runtime-plugin-support" +// import "@opentui/solid/runtime-plugin-support" import { type TuiDispose, type TuiPlugin, @@ -12,13 +12,10 @@ import { } from "@opencode-ai/plugin/tui" import path from "path" import { fileURLToPath } from "url" - -import { Config } from "@/config" -import { TuiConfig } from "@/config" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" import { readPackageThemes, readPluginId, @@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" +import { ConfigPlugin } from "@/config/plugin" type PluginLoad = { - options: Config.PluginOptions | undefined + options: ConfigPlugin.Options | undefined spec: string target: string retry: boolean source: PluginSource | "internal" id: string module: TuiPluginModule - origin: Config.PluginOrigin + origin: ConfigPlugin.Origin theme_root: string theme_files: string[] } @@ -77,7 +75,7 @@ type RuntimeState = { slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map - pending: Map + pending: Map } const log = Log.create({ service: "tui.plugin" }) @@ -147,7 +145,7 @@ function resolveRoot(root: string) { } function createThemeInstaller( - meta: Config.PluginOrigin, + meta: ConfigPlugin.Origin, root: string, spec: string, plugin: PluginEntry, @@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I } } -async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise) { +async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise) { return PluginLoader.loadExternal({ items: list, kind: "tui", @@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] return { plugins, ok } } -function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin { +function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin { return { spec, scope: "local", @@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { if (!spec) return false const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec) - const next = Config.pluginSpecifier(cfg.spec) + const next = ConfigPlugin.pluginSpecifier(cfg.spec) if (state.plugins.some((plugin) => plugin.load.spec === next)) { state.pending.delete(spec) return true } - - const ready = await Instance.provide({ - directory: state.directory, - fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), - }).catch((error) => { - fail("failed to add tui plugin", { path: next, error }) - return [] as PluginLoad[] - }) + const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()) if (!ready.length) { return false } @@ -905,7 +896,7 @@ async function installPluginBySpec( const tui = manifest.targets.find((item) => item.kind === "tui") if (tui) { const file = patch.items.find((item) => item.kind === "tui")?.file - const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec + const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec state.pending.set(spec, { spec: next, scope: global ? "global" : "local", @@ -926,7 +917,7 @@ export namespace TuiPluginRuntime { let runtime: RuntimeState | undefined export const Slot = View - export async function init(api: HostPluginApi) { + export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -936,7 +927,7 @@ export namespace TuiPluginRuntime { } dir = cwd - loaded = load(api) + loaded = load(input) return loaded } @@ -975,7 +966,8 @@ export namespace TuiPluginRuntime { } } - async function load(api: Api) { + async function load(input: { api: Api; config: TuiConfig.Info }) { + const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) const next: RuntimeState = { @@ -987,45 +979,40 @@ export namespace TuiPluginRuntime { pending: new Map(), } runtime = next + try { + const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) + } - await Instance.provide({ - directory: cwd, - fn: async () => { - const config = await TuiConfig.get() - const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { - log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) - } + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { id: item.id }) + const entry = loadInternalPlugin(item) + const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: true, + }) + } - for (const item of INTERNAL_TUI_PLUGINS) { - log.info("loading internal tui plugin", { id: item.id }) - const entry = loadInternalPlugin(item) - const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - addPluginEntry(next, { - id: entry.id, - load: entry, - meta, - themes: {}, - plugin: entry.module.tui, - enabled: true, - }) - } + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + await addExternalPluginEntries(next, ready) - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) - await addExternalPluginEntries(next, ready) - - applyInitialPluginEnabledState(next, config) - for (const plugin of next.plugins) { - if (!plugin.enabled) continue - // Keep plugin execution sequential for deterministic side effects: - // command registration order affects keybind/command precedence, - // route registration is last-wins when ids collide, - // and hook chains rely on stable plugin ordering. - await activatePluginEntry(next, plugin, false) - } - }, - }).catch((error) => { + applyInitialPluginEnabledState(next, config) + for (const plugin of next.plugins) { + if (!plugin.enabled) continue + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + await activatePluginEntry(next, plugin, false) + } + } catch (error) { fail("failed to load tui plugins", { directory: cwd, error }) - }) + } } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 3c87cfe472..06bc270644 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" -import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" @@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Code {" "} - {Installation.VERSION} + {InstallationVersion} diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 89b32d166e..96ceb905c5 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -8,14 +8,13 @@ import { UI } from "@/cli/ui" import { Log } from "@/util" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" -import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" -import { TuiConfig } from "@/config" -import { Instance } from "@/project/instance" import { writeHeapSnapshot } from "v8" +import { TuiConfig } from "./config/tui" declare global { const OPENCODE_WORKER_PATH: string @@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({ } const prompt = await input(args.prompt) - const config = await Instance.provide({ - directory: cwd, - fn: () => TuiConfig.get(), - }) + const config = await TuiConfig.get() - const network = await resolveNetworkOptions(args) + const network = resolveNetworkOptionsNoConfig(args) const external = process.argv.includes("--port") || process.argv.includes("--hostname") || @@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({ process.exit(0) }, }) +// scratch diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb..f534d90b77 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "../component/border" import { TextAttributes } from "@opentui/core" import z from "zod" -import { TuiEvent } from "../event" +import { type TuiEvent } from "../event" export type ToastOptions = z.infer @@ -56,8 +56,7 @@ function init() { const toast = { show(options: ToastOptions) { - const parsedOptions = TuiEvent.ToastShow.properties.parse(options) - const { duration, ...currentToast } = parsedOptions + const { duration, ...currentToast } = options setStore("currentToast", currentToast) if (timeoutHandle) clearTimeout(timeoutHandle) timeoutHandle = setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 6968b07eb4..8c535833c6 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,12 +1,21 @@ import { platform, release } from "os" -import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../../../util" -import { Process } from "../../../../util" -import { which } from "../../../../util/which" +import * as Filesystem from "../../../../util/filesystem" +import * as Process from "../../../../util/process" + +// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup +const getWhich = lazy(async () => { + const { which } = await import("../../../../util/which") + return which +}) + +const getClipboardy = lazy(async () => { + const { default: clipboardy } = await import("clipboardy") + return clipboardy +}) /** * Writes text to clipboard via OSC 52 escape sequence. @@ -94,14 +103,16 @@ export async function read(): Promise { } } + const clipboardy = await getClipboardy() const text = await clipboardy.read().catch(() => {}) if (text) { return { data: text, mime: "text/plain" } } } -const getCopyMethod = lazy(() => { +const getCopyMethod = lazy(async () => { const os = platform() + const which = await getWhich() if (os === "darwin" && which("osascript")) { console.log("clipboard: using osascript") @@ -180,11 +191,13 @@ const getCopyMethod = lazy(() => { console.log("clipboard: no native support") return async (text: string) => { + const clipboardy = await getClipboardy() await clipboardy.write(text).catch(() => {}) } }) export async function copy(text: string): Promise { writeOsc52(text) - await getCopyMethod()(text) + const method = await getCopyMethod() + await method(text) } diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index d27bdb90ce..30d0069639 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -1,5 +1,5 @@ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core" -import type { TuiConfig } from "@/config" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" export class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 3ffa0f228c..b80648c24f 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -3,6 +3,7 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "../../installation" +import { InstallationVersion } from "../../installation/version" export const UpgradeCommand = { command: "upgrade [target]", @@ -47,13 +48,13 @@ export const UpgradeCommand = { ? args.target.replace(/^v/, "") : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest())) - if (Installation.VERSION === target) { + if (InstallationVersion === target) { prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.outro("Done") return } - prompts.log.info(`From ${Installation.VERSION} → ${target}`) + prompts.log.info(`From ${InstallationVersion} → ${target}`) const spinner = prompts.spinner() spinner.start("Upgrading...") const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/cli/effect/runtime.ts new file mode 100644 index 0000000000..4d85fa55b6 --- /dev/null +++ b/packages/opencode/src/cli/effect/runtime.ts @@ -0,0 +1,20 @@ +import { Observability } from "@/effect/observability" +import { Layer, type Context, ManagedRuntime, type Effect } from "effect" + +export const memoMap = Layer.makeMemoMapUnsafe() + +export function makeRuntime(service: Context.Service, layer: Layer.Layer) { + let rt: ManagedRuntime.ManagedRuntime | undefined + const getRuntime = () => + (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer, { memoMap })) + + return { + runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(service.use(fn)), + runPromiseExit: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromiseExit(service.use(fn), options), + runPromise: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromise(service.use(fn), options), + runFork: (fn: (svc: S) => Effect.Effect) => getRuntime().runFork(service.use(fn)), + runCallback: (fn: (svc: S) => Effect.Effect) => getRuntime().runCallback(service.use(fn)), + } +} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 735f1a721e..89b557e2d2 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,48 +1,80 @@ -import { AccountServiceError, AccountTransportError } from "@/account" -import { ConfigMarkdown } from "@/config" +import { NamedError } from "@opencode-ai/shared/util/error" import { errorFormat } from "@/util/error" -import { Config } from "../config" -import { MCP } from "../mcp" -import { Provider } from "../provider" -import { UI } from "./ui" + +interface ErrorLike { + name?: string + _tag?: string + message?: string + data?: Record +} + +function isTaggedError(error: unknown, tag: string): boolean { + return ( + typeof error === "object" && error !== null && "_tag" in error && (error as Record)._tag === tag + ) +} export function FormatError(input: unknown) { - if (MCP.Failed.isInstance(input)) - return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` - if (input instanceof AccountTransportError || input instanceof AccountServiceError) { - return input.message + // MCPFailed: { name: string } + if (NamedError.hasName(input, "MCPFailed")) { + return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` } - if (Provider.ModelNotFoundError.isInstance(input)) { - const { providerID, modelID, suggestions } = input.data + + // AccountServiceError, AccountTransportError: TaggedErrorClass + if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { + return (input as ErrorLike).message ?? "" + } + + // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } + if (NamedError.hasName(input, "ProviderModelNotFoundError")) { + const data = (input as ErrorLike).data + const suggestions = data?.suggestions as string[] | undefined return [ - `Model not found: ${providerID}/${modelID}`, + `Model not found: ${data?.providerID}/${data?.modelID}`, ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), `Try: \`opencode models\` to list available models`, `Or check your config (opencode.json) provider/model names`, ].join("\n") } - if (Provider.InitError.isInstance(input)) { - return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` - } - if (Config.JsonError.isInstance(input)) { - return ( - `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") - ) - } - if (Config.ConfigDirectoryTypoError.isInstance(input)) { - return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` - } - if (ConfigMarkdown.FrontmatterError.isInstance(input)) { - return input.data.message - } - if (Config.InvalidError.isInstance(input)) - return [ - `Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` + - (input.data.message ? `: ${input.data.message}` : ""), - ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), - ].join("\n") - if (UI.CancelledError.isInstance(input)) return "" + // ProviderInitError: { providerID: string } + if (NamedError.hasName(input, "ProviderInitError")) { + return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.` + } + + // ConfigJsonError: { path: string, message?: string } + if (NamedError.hasName(input, "ConfigJsonError")) { + const data = (input as ErrorLike).data + return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "") + } + + // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } + if (NamedError.hasName(input, "ConfigDirectoryTypoError")) { + const data = (input as ErrorLike).data + return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.` + } + + // ConfigFrontmatterError: { message: string } + if (NamedError.hasName(input, "ConfigFrontmatterError")) { + return (input as ErrorLike).data?.message ?? "" + } + + // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } + if (NamedError.hasName(input, "ConfigInvalidError")) { + const data = (input as ErrorLike).data + const path = data?.path + const message = data?.message + const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined + return [ + `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), + ...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), + ].join("\n") + } + + // UICancelledError: void (no data) + if (NamedError.hasName(input, "UICancelledError")) { + return "" + } } export function FormatUnknownError(input: unknown): string { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index ea281aafb9..a489ea14c5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } - export async function resolveNetworkOptions(args: NetworkOptions) { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) + return resolveNetworkOptionsNoConfig(args, config) +} + +export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) { const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2628f9673f..7c6f08874b 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -3,6 +3,7 @@ import { Config } from "@/config" import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@/flag/flag" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) @@ -15,10 +16,10 @@ export async function upgrade() { return } - if (Installation.VERSION === latest) return + if (InstallationVersion === latest) return if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return - const kind = Installation.getReleaseType(Installation.VERSION, latest) + const kind = Installation.getReleaseType(InstallationVersion, latest) if (config.autoupdate === "notify" || kind !== "patch") { await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 66471e908a..ecdf20c892 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -21,6 +21,7 @@ import { import { Instance, type InstanceContext } from "../project/instance" import * as LSPServer from "../lsp/server" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import * as ConfigMarkdown from "./markdown" import { existsSync } from "fs" import { Bus } from "@/bus" @@ -1266,7 +1267,7 @@ export const layer: Layer.Layer< const pkg = path.join(dir, "package.json") const gitignore = path.join(dir, ".gitignore") const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json") - const target = Installation.isLocal() ? "*" : Installation.VERSION + const target = Installation.isLocal() ? "*" : InstallationVersion const json = yield* fs.readJson(pkg).pipe( Effect.catch(() => Effect.succeed({} satisfies Package)), Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})), diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index d878fc99a2..fbcca1aa9a 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -1,4 +1,3 @@ export * as Config from "./config" export * as ConfigMarkdown from "./markdown" export * as ConfigPaths from "./paths" -export * as TuiConfig from "./tui" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts new file mode 100644 index 0000000000..9b8d9e2834 --- /dev/null +++ b/packages/opencode/src/config/keybinds.ts @@ -0,0 +1,164 @@ +import z from "zod" + +export namespace ConfigKeybinds { + export const Keybinds = z + .object({ + leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), + editor_open: z.string().optional().default("e").describe("Open external editor"), + theme_list: z.string().optional().default("t").describe("List available themes"), + sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), + scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), + username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + status_view: z.string().optional().default("s").describe("View status"), + session_export: z.string().optional().default("x").describe("Export session to editor"), + session_new: z.string().optional().default("n").describe("Create a new session"), + session_list: z.string().optional().default("l").describe("List all sessions"), + session_timeline: z.string().optional().default("g").describe("Show session timeline"), + session_fork: z.string().optional().default("none").describe("Fork session from message"), + session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), + session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), + stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), + model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), + model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_share: z.string().optional().default("none").describe("Share current session"), + session_unshare: z.string().optional().default("none").describe("Unshare current session"), + session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), + session_compact: z.string().optional().default("c").describe("Compact the session"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), + messages_half_page_down: z + .string() + .optional() + .default("ctrl+alt+d") + .describe("Scroll messages down by half page"), + messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), + messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), + messages_next: z.string().optional().default("none").describe("Navigate to next message"), + messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), + messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), + messages_copy: z.string().optional().default("y").describe("Copy message"), + messages_undo: z.string().optional().default("u").describe("Undo message"), + messages_redo: z.string().optional().default("r").describe("Redo message"), + messages_toggle_conceal: z + .string() + .optional() + .default("h") + .describe("Toggle code block concealment in messages"), + tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), + model_list: z.string().optional().default("m").describe("List available models"), + model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), + model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), + command_list: z.string().optional().default("ctrl+p").describe("List available commands"), + agent_list: z.string().optional().default("a").describe("List agents"), + agent_cycle: z.string().optional().default("tab").describe("Next agent"), + agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), + variant_list: z.string().optional().default("none").describe("List model variants"), + input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), + input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_submit: z.string().optional().default("return").describe("Submit input"), + input_newline: z + .string() + .optional() + .default("shift+return,ctrl+return,alt+return,ctrl+j") + .describe("Insert newline in input"), + input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), + input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), + input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), + input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), + input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), + input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), + input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), + input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), + input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), + input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), + input_select_line_home: z + .string() + .optional() + .default("ctrl+shift+a") + .describe("Select to start of line in input"), + input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), + input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), + input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), + input_select_visual_line_home: z + .string() + .optional() + .default("alt+shift+a") + .describe("Select to start of visual line in input"), + input_select_visual_line_end: z + .string() + .optional() + .default("alt+shift+e") + .describe("Select to end of visual line in input"), + input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), + input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), + input_select_buffer_home: z + .string() + .optional() + .default("shift+home") + .describe("Select to start of buffer in input"), + input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), + input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), + input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), + input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), + input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), + input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), + input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), + input_word_forward: z + .string() + .optional() + .default("alt+f,alt+right,ctrl+right") + .describe("Move word forward in input"), + input_word_backward: z + .string() + .optional() + .default("alt+b,alt+left,ctrl+left") + .describe("Move word backward in input"), + input_select_word_forward: z + .string() + .optional() + .default("alt+shift+f,alt+shift+right") + .describe("Select word forward in input"), + input_select_word_backward: z + .string() + .optional() + .default("alt+shift+b,alt+shift+left") + .describe("Select word backward in input"), + input_delete_word_forward: z + .string() + .optional() + .default("alt+d,alt+delete,ctrl+delete") + .describe("Delete word forward in input"), + input_delete_word_backward: z + .string() + .optional() + .default("ctrl+w,ctrl+backspace,alt+backspace") + .describe("Delete word backward in input"), + history_previous: z.string().optional().default("up").describe("Previous history item"), + history_next: z.string().optional().default("down").describe("Next history item"), + session_child_first: z.string().optional().default("down").describe("Go to first child session"), + session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), + session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), + session_parent: z.string().optional().default("up").describe("Go to parent session"), + terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), + tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), + plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), + display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), + }) + .strict() + .meta({ + ref: "KeybindsConfig", + }) +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 82dde2df9f..eeb9d62d3f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -7,11 +7,11 @@ import { Filesystem } from "@/util" import { Flag } from "@/flag/flag" import { Global } from "@/global" -export async function projectFiles(name: string, directory: string, worktree: string) { +export async function projectFiles(name: string, directory: string, worktree?: string) { return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) } -export async function directories(directory: string, worktree: string) { +export async function directories(directory: string, worktree?: string) { return [ Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts new file mode 100644 index 0000000000..d13a9d5adc --- /dev/null +++ b/packages/opencode/src/config/plugin.ts @@ -0,0 +1,75 @@ +import { Glob } from "@opencode-ai/shared/util/glob" +import z from "zod" +import { pathToFileURL } from "url" +import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import path from "path" + +export namespace ConfigPlugin { + const Options = z.record(z.string(), z.unknown()) + export type Options = z.infer + + export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) + export type Spec = z.infer + + export type Scope = "global" | "local" + + export type Origin = { + spec: Spec + source: string + scope: Scope + } + + export async function load(dir: string) { + const plugins: ConfigPlugin.Spec[] = [] + + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) + } + return plugins + } + + export function pluginSpecifier(plugin: ConfigPlugin.Spec): string { + return Array.isArray(plugin) ? plugin[0] : plugin + } + + export function pluginOptions(plugin: Spec): Options | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined + } + + export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } + + export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { + const seen = new Set() + const list: Origin[] = [] + + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) + } + + return list.toReversed() + } +} diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts deleted file mode 100644 index 3cde908b03..0000000000 --- a/packages/opencode/src/config/tui.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { existsSync } from "fs" -import z from "zod" -import { mergeDeep, unique } from "remeda" -import { Context, Effect, Fiber, Layer } from "effect" -import * as Config from "./config" -import * as ConfigPaths from "./paths" -import { migrateTuiConfig } from "./tui-migrate" -import { TuiInfo } from "./tui-schema" -import { Flag } from "@/flag/flag" -import { Log } from "@/util" -import { isRecord } from "@/util/record" -import { Global } from "@/global" -import { InstanceState } from "@/effect" -import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" - -const log = Log.create({ service: "tui.config" }) - -export const Info = TuiInfo - -type Acc = { - result: Info -} - -type State = { - config: Info - deps: Array> -} - -export type Info = z.output & { - // Internal resolved plugin list used by runtime loading. - plugin_origins?: Config.PluginOrigin[] -} - -export interface Interface { - readonly get: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/TuiConfig") {} - -function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope { - if (AppFileSystem.contains(ctx.directory, file)) return "local" - if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local" - return "global" -} - -function customPath() { - return Flag.OPENCODE_TUI_CONFIG -} - -function normalize(raw: Record) { - const data = { ...raw } - if (!("tui" in data)) return data - if (!isRecord(data.tui)) { - delete data.tui - return data - } - - const tui = data.tui - delete data.tui - return { - ...tui, - ...data, - } -} - -async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = Config.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins -} - -async function loadState(ctx: { directory: string; worktree: string }) { - let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree) - const custom = customPath() - const managed = Config.managedConfigDir() - await migrateTuiConfig({ directories, custom, managed }) - // Re-compute after migration since migrateTuiConfig may have created new tui.json files - projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? [] - : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree) - - const acc: Acc = { - result: {}, - } - - for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - await mergeFile(acc, file, ctx) - } - - if (custom) { - await mergeFile(acc, custom, ctx) - log.debug("loaded custom tui config", { path: custom }) - } - - for (const file of projectFiles) { - await mergeFile(acc, file, ctx) - } - - const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) - - for (const dir of dirs) { - if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue - for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - if (existsSync(managed)) { - for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - const keybinds = { ...acc.result.keybinds } - if (process.platform === "win32") { - // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. - keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( - ",", - ) - } - acc.result.keybinds = Config.Keybinds.parse(keybinds) - - return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], - } -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const state = yield* InstanceState.make( - Effect.fn("TuiConfig.state")(function* (ctx) { - const data = yield* Effect.promise(() => loadState(ctx)) - const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), { - concurrency: "unbounded", - }) - return { config: data.config, deps } - }), - ) - - const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config)) - - const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => - InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ), - ) - - return Service.of({ get, waitForDependencies }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function get() { - return runPromise((svc) => svc.get()) -} - -export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) -} - -async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) -} - -async function load(text: string, configFilepath: string): Promise { - const raw = await ConfigPaths.parseText(text, configFilepath, "empty") - if (!isRecord(raw)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - const normalized = normalize(raw) - - const parsed = Info.safeParse(normalized) - if (!parsed.success) { - log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) - return {} - } - - const data = parsed.data - if (data.plugin) { - for (let i = 0; i < data.plugin.length; i++) { - data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath) - } - } - - return data -} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index aabafc5b4d..f06c41e319 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,8 +47,10 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" +import { Npm } from "@opencode-ai/shared/npm" export const AppLayer = Layer.mergeAll( + Npm.defaultLayer, AppFileSystem.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index 2f4040113d..efd16ffc09 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" import * as EffectLogger from "./logger" import { Flag } from "@/flag/flag" -import { CHANNEL, VERSION } from "@/installation/meta" +import { InstallationChannel, InstallationVersion } from "@/installation/version" const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT export const enabled = !!base @@ -21,9 +21,9 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS const resource = { serviceName: "opencode", - serviceVersion: VERSION, + serviceVersion: InstallationVersion, attributes: { - "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, + "deployment.environment.name": InstallationChannel, "opencode.client": Flag.OPENCODE_CLIENT, }, } @@ -76,3 +76,5 @@ export const layer = !base return Layer.mergeAll(trace, logs) }), ) + +export const Observability = { enabled, layer } diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts index 2269065913..ee8df2b0b9 100644 --- a/packages/opencode/src/file/file.ts +++ b/packages/opencode/src/file/file.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" @@ -345,6 +345,7 @@ export const layer = Layer.effect( const appFs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service const git = yield* Git.Service + const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -419,7 +420,7 @@ export const layer = Layer.effect( }) const init = Effect.fn("File.init")(function* () { - yield* ensure() + yield* ensure().pipe(Effect.forkIn(scope)) }) const status = Effect.fn("File.status")(function* () { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d9f4651fbf..67de87c2aa 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -11,6 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" +import { InstallationVersion } from "./installation/version" import { NamedError } from "@opencode-ai/shared/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" @@ -68,7 +69,7 @@ const cli = yargs(args) .wrap(100) .help("help", "show help") .alias("help", "h") - .version("version", "show version number", Installation.VERSION) + .version("version", "show version number", InstallationVersion) .alias("version", "v") .option("print-logs", { describe: "print logs to stderr", @@ -105,7 +106,7 @@ const cli = yargs(args) process.env.OPENCODE_PID = String(process.pid) Log.Default.info("opencode", { - version: Installation.VERSION, + version: InstallationVersion, args: process.argv.slice(2), }) diff --git a/packages/opencode/src/installation/installation.ts b/packages/opencode/src/installation/installation.ts index dcaa0cd723..96a99b77a3 100644 --- a/packages/opencode/src/installation/installation.ts +++ b/packages/opencode/src/installation/installation.ts @@ -8,9 +8,9 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" import { Log } from "../util" -import { CHANNEL as channel, VERSION as version } from "./meta" import semver from "semver" +import { InstallationChannel, InstallationVersion } from "./version" const log = Log.create({ service: "installation" }) @@ -54,16 +54,14 @@ export const Info = z }) export type Info = z.infer -export const VERSION = version -export const CHANNEL = channel -export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` +export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` export function isPreview() { - return CHANNEL !== "latest" + return InstallationChannel !== "latest" } export function isLocal() { - return CHANNEL === "local" + return InstallationChannel === "local" } export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { @@ -222,7 +220,7 @@ export const layer: Layer.Layer Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) + const client = new Client({ name: "opencode", version: InstallationVersion }) return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), @@ -763,7 +764,7 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: Installation.VERSION }) + const client = new Client({ name: "opencode", version: InstallationVersion }) return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) diff --git a/packages/opencode/src/npm/npm.ts b/packages/opencode/src/npm/npm.ts index 7f17446057..d74c10d555 100644 --- a/packages/opencode/src/npm/npm.ts +++ b/packages/opencode/src/npm/npm.ts @@ -7,7 +7,6 @@ import path from "path" import { readdir, rm } from "fs/promises" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" -import { Arborist } from "@npmcli/arborist" const log = Log.create({ service: "npm" }) const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined @@ -61,6 +60,7 @@ export async function outdated(pkg: string, cachedVersion: string): Promise { + const { Arborist } = await import("@npmcli/arborist") const arb = new Arborist({ path: dir, binLinks: true, diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index e0f1afa63f..c61cb78509 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util" import { Installation } from "../installation" +import { InstallationVersion } from "../installation/version" import { OAUTH_DUMMY_KEY } from "../auth" import os from "os" import { setTimeout as sleep } from "node:timers/promises" @@ -510,7 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { method: "POST", headers: { "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID }), }) @@ -534,7 +535,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { method: "POST", headers: { "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ device_auth_id: deviceData.device_auth_id, @@ -594,7 +595,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "chat.headers": async (input, output) => { if (input.model.providerID !== "openai") return output.headers.originator = "opencode" - output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})` + output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})` output.headers.session_id = input.sessionID }, "chat.params": async (input, output) => { diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c1318287c5..c9b7e3c1c7 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Model } from "@opencode-ai/sdk/v2" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { iife } from "@/util/iife" import { Log } from "../../util" import { setTimeout as sleep } from "node:timers/promises" @@ -70,7 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { base(auth.enterpriseUrl), { Authorization: `Bearer ${auth.refresh}`, - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, provider.models, ).catch((error) => { @@ -150,7 +151,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const headers: Record = { "x-initiator": isAgent ? "agent" : "user", ...(init?.headers as Record), - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, Authorization: `Bearer ${info.refresh}`, "Openai-Intent": "conversation-edits", } @@ -226,7 +227,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { headers: { Accept: "application/json", "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID, @@ -256,7 +257,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { headers: { Accept: "application/json", "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }, body: JSON.stringify({ client_id: CLIENT_ID, diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 8b7e30c40e..0525a7ba0b 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -7,7 +7,7 @@ import { printParseErrorCode, } from "jsonc-parser" -import { ConfigPaths } from "@/config" +import * as ConfigPaths from "@/config/paths" import { Global } from "@/global" import { Filesystem } from "@/util" import { Flock } from "@opencode-ai/shared/util/flock" diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 12617f9010..0245d311e0 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -1,5 +1,3 @@ -import { Config } from "@/config" -import { Installation } from "@/installation" import { checkPluginCompatibility, createPluginEntry, @@ -10,11 +8,13 @@ import { type PluginPackage, type PluginSource, } from "./shared" +import { ConfigPlugin } from "@/config/plugin" +import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { export type Plan = { spec: string - options: Config.PluginOptions | undefined + options: ConfigPlugin.Options | undefined deprecated: boolean } export type Resolved = Plan & { @@ -33,7 +33,7 @@ export namespace PluginLoader { mod: Record } - type Candidate = { origin: Config.PluginOrigin; plan: Plan } + type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Report = { start?: (candidate: Candidate, retry: boolean) => void missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void @@ -46,9 +46,9 @@ export namespace PluginLoader { ) => void } - function plan(item: Config.PluginSpec): Plan { - const spec = Config.pluginSpecifier(item) - return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } + function plan(item: ConfigPlugin.Spec): Plan { + const spec = ConfigPlugin.pluginSpecifier(item) + return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } export async function resolve( @@ -88,7 +88,7 @@ export namespace PluginLoader { if (base.source === "npm") { try { - await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) + await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) } catch (error) { return { ok: false, stage: "compatibility", error } } @@ -111,8 +111,8 @@ export namespace PluginLoader { candidate: Candidate, kind: PluginKind, retry: boolean, - finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, - missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise) | undefined, + finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, report: Report | undefined, ): Promise { const plan = candidate.plan @@ -141,11 +141,11 @@ export namespace PluginLoader { } type Input = { - items: Config.PluginOrigin[] + items: ConfigPlugin.Origin[] kind: PluginKind wait?: () => Promise - finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise - missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise + finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise + missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise report?: Report } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a405607bea..e506d2feda 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -26,7 +26,7 @@ export const InstanceBootstrap = Effect.gen(function* () { Vcs.Service, Snapshot.Service, ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ) + ).pipe(Effect.withSpan("InstanceBootstrap.init")) yield* Bus.Service.use((svc) => svc.subscribeCallback(Command.Event.Executed, async (payload) => { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 77a45cb1be..43ae9a5e9f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -14,6 +14,7 @@ import * as ModelsDev from "./models" import { Auth } from "../auth" import { Env } from "../env" import { Instance } from "../project/instance" +import { InstallationVersion } from "../installation/version" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { Global } from "../global" @@ -24,39 +25,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" -// Direct imports for bundled providers -import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" -import { createAnthropic } from "@ai-sdk/anthropic" -import { createAzure } from "@ai-sdk/azure" -import { createGoogleGenerativeAI } from "@ai-sdk/google" -import { createVertex } from "@ai-sdk/google-vertex" -import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" -import { createOpenAI } from "@ai-sdk/openai" -import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot" -import { createXai } from "@ai-sdk/xai" -import { createMistral } from "@ai-sdk/mistral" -import { createGroq } from "@ai-sdk/groq" -import { createDeepInfra } from "@ai-sdk/deepinfra" -import { createCerebras } from "@ai-sdk/cerebras" -import { createCohere } from "@ai-sdk/cohere" -import { createGateway } from "@ai-sdk/gateway" -import { createTogetherAI } from "@ai-sdk/togetherai" -import { createPerplexity } from "@ai-sdk/perplexity" -import { createVercel } from "@ai-sdk/vercel" -import { createVenice } from "venice-ai-sdk-provider" -import { createAlibaba } from "@ai-sdk/alibaba" -import { - createGitLab, - VERSION as GITLAB_PROVIDER_VERSION, - isWorkflowModel, - discoverWorkflowModels, -} from "gitlab-ai-provider" -import { fromNodeProviderChain } from "@aws-sdk/credential-providers" -import { GoogleAuth } from "google-auth-library" import * as ProviderTransform from "./transform" -import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" const log = Log.create({ service: "provider" }) @@ -119,30 +88,31 @@ type BundledSDK = { languageModel(modelId: string): LanguageModelV3 } -const BUNDLED_PROVIDERS: Record BundledSDK> = { - "@ai-sdk/amazon-bedrock": createAmazonBedrock, - "@ai-sdk/anthropic": createAnthropic, - "@ai-sdk/azure": createAzure, - "@ai-sdk/google": createGoogleGenerativeAI, - "@ai-sdk/google-vertex": createVertex, - "@ai-sdk/google-vertex/anthropic": createVertexAnthropic, - "@ai-sdk/openai": createOpenAI, - "@ai-sdk/openai-compatible": createOpenAICompatible, - "@openrouter/ai-sdk-provider": createOpenRouter, - "@ai-sdk/xai": createXai, - "@ai-sdk/mistral": createMistral, - "@ai-sdk/groq": createGroq, - "@ai-sdk/deepinfra": createDeepInfra, - "@ai-sdk/cerebras": createCerebras, - "@ai-sdk/cohere": createCohere, - "@ai-sdk/gateway": createGateway, - "@ai-sdk/togetherai": createTogetherAI, - "@ai-sdk/perplexity": createPerplexity, - "@ai-sdk/vercel": createVercel, - "@ai-sdk/alibaba": createAlibaba, - "gitlab-ai-provider": createGitLab, - "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, - "venice-ai-sdk-provider": createVenice, +const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK>> = { + "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock), + "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic), + "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure), + "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI), + "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex), + "@ai-sdk/google-vertex/anthropic": () => + import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic), + "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI), + "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible), + "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter), + "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai), + "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral), + "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq), + "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra), + "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras), + "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere), + "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway), + "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI), + "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity), + "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), + "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), + "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), + "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible), + "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise @@ -307,7 +277,9 @@ function custom(dep: CustomDep): Record { if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) return { autoload: false } - const providerOptions: AmazonBedrockProviderSettings = { + const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) + + const providerOptions: Record = { region: defaultRegion, } @@ -465,6 +437,7 @@ function custom(dep: CustomDep): Record { project, location, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const { GoogleAuth } = await import("google-auth-library") const auth = new GoogleAuth() const client = await auth.getApplicationDefault() const token = await client.credential.getAccessToken() @@ -534,6 +507,12 @@ function custom(dep: CustomDep): Record { }, }), gitlab: Effect.fnUntraced(function* (input: Info) { + const { + VERSION: GITLAB_PROVIDER_VERSION, + isWorkflowModel, + discoverWorkflowModels, + } = yield* Effect.promise(() => import("gitlab-ai-provider")) + const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" const auth = yield* dep.auth(input.id) @@ -547,7 +526,7 @@ function custom(dep: CustomDep): Record { const providerConfig = (yield* dep.config()).provider?.["gitlab"] const aiGatewayHeaders = { - "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "anthropic-beta": "context-1m-2025-08-07", ...providerConfig?.options?.aiGatewayHeaders, } @@ -566,7 +545,7 @@ function custom(dep: CustomDep): Record { aiGatewayHeaders, featureFlags, }, - async getModel(sdk: ReturnType, modelID: string, options?: Record) { + async getModel(sdk: any, modelID: string, options?: Record) { if (modelID.startsWith("duo-workflow-")) { const workflowRef = options?.workflowRef as string | undefined // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef @@ -701,7 +680,7 @@ function custom(dep: CustomDep): Record { options: { apiKey, headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, }, }, async getModel(sdk: any, modelID: string) { @@ -772,7 +751,7 @@ function custom(dep: CustomDep): Record { skipCache: input.options?.skipCache, collectLog: input.options?.collectLog, headers: { - "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, }, } @@ -1454,13 +1433,14 @@ const layer: Layer.Layer< return wrapSSE(res, chunkTimeout, chunkAbortCtl) } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { + const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] + if (bundledLoader) { log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm, }) - const loaded = bundledFn({ + const factory = await bundledLoader() + const loaded = factory({ name: model.providerID, ...options, }) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index ac73bb64d8..8208cf9669 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -10,6 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Instance } from "../../project/instance" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { Log } from "../../util" import { lazy } from "../../util/lazy" import { Config } from "../../config" @@ -89,7 +90,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - return c.json({ healthy: true, version: Installation.VERSION }) + return c.json({ healthy: true, version: InstallationVersion }) }, ) .get( diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 2d1577e7e3..d38c29765a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,6 +20,7 @@ import { Wildcard } from "@/util" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -365,7 +366,7 @@ export namespace LLM { : { "x-session-affinity": input.sessionID, ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${Installation.VERSION}`, + "User-Agent": `opencode/${InstallationVersion}`, }), ...input.model.headers, ...headers, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9ebddf8dee..e288aec73a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -7,6 +7,7 @@ import z from "zod" import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "../flag/flag" import { Installation } from "../installation" +import { InstallationVersion } from "../installation/version" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage" import { SyncEvent } from "../sync" @@ -399,7 +400,7 @@ export const layer: Layer.Layer = const result: Info = { id: SessionID.descending(input.id), slug: Slug.create(), - version: Installation.VERSION, + version: InstallationVersion, projectID: ctx.project.id, directory: input.directory, workspaceID: input.workspaceID, diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1b6b2d9b37..2c0076452e 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -11,7 +11,7 @@ import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "../flag/flag" -import { CHANNEL } from "../installation/meta" +import { InstallationChannel } from "../installation/version" import { InstanceState } from "@/effect" import { iife } from "@/util/iife" import { init } from "#db" @@ -28,9 +28,9 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export function getChannelPath() { - if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") - const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") + const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) } diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts new file mode 100644 index 0000000000..bbb97e0f0f --- /dev/null +++ b/packages/opencode/src/temporary.ts @@ -0,0 +1,33 @@ +import yargs from "yargs" +import { TuiThreadCommand } from "./cli/cmd/tui/thread" +import { InstallationVersion } from "./installation/version" +import { hideBin } from "yargs/helpers" +import { Log } from "./node" + +Log.init({ + print: false, +}) + +const cli = yargs(hideBin(process.argv)) + .parserConfiguration({ "populate--": true }) + .scriptName("opencode") + .wrap(100) + .help("help", "show help") + .alias("help", "h") + .version("version", "show version number", InstallationVersion) + .alias("version", "v") + .option("print-logs", { + describe: "print logs to stderr", + type: "boolean", + }) + .option("log-level", { + describe: "log level", + type: "string", + choices: ["DEBUG", "INFO", "WARN", "ERROR"], + }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) + .command(TuiThreadCommand) + .parse() diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index c3f59d3297..3ff2c6e3f4 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,6 +1,5 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" -import { lookup } from "mime-types" import { realpathSync } from "fs" import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" @@ -101,7 +100,8 @@ export async function writeStream( } } -export function mimeType(p: string): string { +export async function mimeType(p: string): Promise { + const { lookup } = await import("mime-types") return lookup(p) || "application/octet-stream" } diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 11865beddd..972da0f50f 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,15 +31,18 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ + api: createTuiPluginApi(), + config, + }) await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") @@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -72,10 +74,10 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { await Bun.write( path.join(tmp.extra.mod, "index.ts"), @@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ + api: createTuiPluginApi(), + config, + }) await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") @@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index bd490ac4f9..ca7e8fcd21 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,11 +50,10 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const cfg: Awaited> = { + const config: TuiConfig.Info = { plugin: [], plugin_origins: undefined, } - const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ @@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => { }) try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec) expect(out).toMatchObject({ ok: true, @@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index 078e4484db..8725fe8b9b 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async () }, }) - const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]]) + const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.dispose() const marker = await fs.readFile(tmp.extra.marker, "utf8") @@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => { }, }) - const restore = mockTuiRuntime(tmp.path, [ + const { config, restore } = mockTuiRuntime(tmp.path, [ [tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }], [tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }], ]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // bad plugin's onDispose ran during rollback await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned") // good plugin still loaded @@ -155,11 +155,11 @@ export default { }, }) - const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec]) + const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec]) const err = spyOn(console, "error").mockImplementation(() => {}) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const marker = await fs.readFile(tmp.extra.marker, "utf8") expect(marker).toContain("one") @@ -202,10 +202,10 @@ test( }, }) - const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec]) + const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec]) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const done = await new Promise((resolve) => { const timer = setTimeout(() => resolve("timeout"), 7000) diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 7020ac7426..395e8ce429 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export") expect(hit?.enabled).toBe(true) @@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -106,7 +105,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -169,7 +167,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () = source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // plugin code never ran await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() // plugin not listed @@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () = await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -232,7 +229,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () => source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -291,7 +287,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => { const error = spyOn(console, "error").mockImplementation(() => {}) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) expect(error).not.toHaveBeenCalled() @@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() warn.mockRestore() error.mockRestore() @@ -357,7 +352,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -405,7 +399,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [tmp.extra.spec], plugin_origins: [ { @@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true) } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -463,7 +456,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin") } finally { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index 25233adaa5..ba7a4b3959 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() if (pure === undefined) { delete process.env.OPENCODE_PURE diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 4dc2aeccd4..dc64fb3365 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -5,8 +5,8 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { Global } from "../../../src/global" -import { TuiConfig } from "../../../src/config" -import { Filesystem } from "../../../src/util" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" +import { Filesystem } from "../../../src/util/" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -328,8 +328,55 @@ export default { try { expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) - await TuiPluginRuntime.init( - createTuiPluginApi({ + const localOpts = { + fn_marker: tmp.extra.fnMarker, + marker: tmp.extra.localMarker, + source: tmp.extra.localDest.replace(".opencode/themes/", ""), + dest: tmp.extra.localDest, + theme_path: `./${tmp.extra.localThemeFile}`, + theme_name: tmp.extra.localThemeName, + kv_key: "plugin_state_key", + session_id: "ses_test", + keybinds: { modal: "ctrl+alt+m", close: "q" }, + } + const invalidOpts = { + marker: tmp.extra.invalidMarker, + theme_path: `./${tmp.extra.invalidThemeFile}`, + theme_name: tmp.extra.invalidThemeName, + } + const preloadedOpts = { + marker: tmp.extra.preloadedMarker, + dest: tmp.extra.preloadedDest, + theme_path: `./${tmp.extra.preloadedThemeFile}`, + theme_name: tmp.extra.preloadedThemeName, + } + const globalOpts = { + marker: tmp.extra.globalMarker, + theme_path: `./${tmp.extra.globalThemeFile}`, + theme_name: tmp.extra.globalThemeName, + } + + const config: TuiConfig.Info = { + plugin: [ + [tmp.extra.localSpec, localOpts], + [tmp.extra.invalidSpec, invalidOpts], + [tmp.extra.preloadedSpec, preloadedOpts], + [tmp.extra.globalSpec, globalOpts], + ], + plugin_origins: [ + { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") }, + { + spec: [tmp.extra.globalSpec, globalOpts], + scope: "global", + source: path.join(Global.Path.config, "tui.json"), + }, + ], + } + + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ tuiConfig: { theme: "smoke", diff_style: "stacked", @@ -366,7 +413,8 @@ export default { }, }, }), - ) + config, + }) const local = await row(tmp.extra.localMarker) const global = await row(tmp.extra.globalMarker) const invalid = await row(tmp.extra.invalidMarker) @@ -459,7 +507,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) // bad plugin was skipped (no metadata entry) await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow() // good plugin loaded fine @@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -555,7 +602,18 @@ export default { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { - await TuiPluginRuntime.init(createTuiPluginApi()) + const a = path.join(tmp.path, "order-a.ts") + const b = path.join(tmp.path, "order-b.ts") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + const config: TuiConfig.Info = { + plugin: [aSpec, bSpec], + plugin_origins: [ + { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + ], + } + await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") expect(lines).toEqual(["a-start", "a-end", "b"]) } finally { @@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() - const api = () => + const mkApi = () => createTuiPluginApi({ theme: { has(name) { @@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => { }, }) + const mkConfig = (): TuiConfig.Info => ({ + plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], + plugin_origins: [ + { + spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) + try { - await TuiPluginRuntime.init(api()) + await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) await TuiPluginRuntime.dispose() await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111") @@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => { await fs.utimes(tmp.extra.pluginPath, stamp, stamp) await fs.utimes(tmp.extra.themePath, stamp, stamp) - await TuiPluginRuntime.init(api()) + await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) const text = await fs.readFile(tmp.extra.dest, "utf8") expect(text).toContain("#222222") expect(text).not.toContain("#111111") diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 3f04e3c6fa..11fdf5ce46 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({ @@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } @@ -117,7 +116,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const config: TuiConfig.Info = { plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { source: path.join(tmp.path, "tui.json"), }, ], - }) + } const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) try { - await TuiPluginRuntime.init(api) + await TuiPluginRuntime.init({ api, config }) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on") expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({ @@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() wait.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE } diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 7b781c49e8..40f4021a23 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -8,13 +8,11 @@ import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/config" -import { Instance } from "../../../src/project/instance" +import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const stop = new Error("stop") const seen = { tui: [] as string[], - inst: [] as string[], } function setup() { @@ -42,11 +40,6 @@ function setup() { }) spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) - spyOn(TuiConfig, "get").mockResolvedValue({}) - spyOn(Instance, "provide").mockImplementation(async (input) => { - seen.inst.push(input.directory) - return input.fn() - }) } describe("tui thread", () => { @@ -86,7 +79,6 @@ describe("tui thread", () => { const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" seen.tui.length = 0 - seen.inst.length = 0 await fs.symlink(tmp.path, link, type) Object.defineProperty(process.stdin, "isTTY", { @@ -105,7 +97,6 @@ describe("tui thread", () => { process.chdir(tmp.path) process.env.PWD = link await expect(call(project)).rejects.toBe(stop) - expect(seen.inst[0]).toBe(tmp.path) expect(seen.tui[0]).toBe(tmp.path) } finally { process.chdir(cwd) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e309416f1d..92c919dc26 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" import * as Network from "../../src/util/network" import { Npm } from "../../src/npm" +import { ConfigPlugin } from "@/config/plugin" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), @@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { const cfg = await load() const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] - const names = plugins.map((item) => Config.pluginSpecifier(item)) + const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) expect(names).toContain("shared-plugin@2.0.0") expect(names).not.toContain("shared-plugin@1.0.0") @@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { expect(names).toContain("local-only@1.0.0") expect(origins.map((item) => item.spec)).toEqual(plugins) - const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") + const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") expect(hit?.scope).toBe("local") }, }) @@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() const file = path.join(tmp.path, "opencode.json") - expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3") - expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg") + expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3") + expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg") }) test("resolves windows-style relative plugin directory specs", async () => { @@ -1925,8 +1926,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec(".\\plugin", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) + const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) }) test("resolves relative file plugin paths to file urls", async () => { @@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec("./plugin.ts", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href) + const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href) }) test("resolves plugin directory paths to directory urls", async () => { @@ -1956,8 +1957,8 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec("./plugin", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) + const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) }) test("resolves plugin directories without package.json to index.ts", async () => { diff --git a/packages/opencode/test/config/plugin.test.ts b/packages/opencode/test/config/plugin.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 62587d2704..c7b6d4a504 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -3,13 +3,15 @@ import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "../../src/config" -import { TuiConfig } from "../../src/config" import { Global } from "../../src/global" import { Filesystem } from "../../src/util" import { AppRuntime } from "../../src/effect/app-runtime" +import { Effect, Layer } from "effect" +import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" +import { ConfigPlugin } from "@/config/plugin" -const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) @@ -18,6 +20,13 @@ beforeEach(async () => { await clear(true) }) +const getTuiConfig = async (directory: string) => + Effect.runPromise( + TuiConfig.Service.use((svc) => svc.get()).pipe( + Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + ), + ) + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG @@ -25,7 +34,6 @@ afterEach(async () => { await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) - await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await clear(true) }) @@ -83,9 +91,9 @@ test("keeps server and tui plugin merge semantics aligned", async () => { directory: tmp.path, fn: async () => { const server = await load() - const tui = await TuiConfig.get() - const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) - const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) + const tui = await getTuiConfig(tmp.path) + const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) + const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) expect(serverPlugins).toEqual(tuiPlugins) expect(serverPlugins).toContain("shared-plugin@2.0.0") @@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => { const serverOrigins = server.plugin_origins ?? [] const tuiOrigins = tui.plugin_origins ?? [] - expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins) - expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins) + expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins) + expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins) expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope)) }, }) @@ -113,14 +121,9 @@ test("loads tui config with the same precedence order as server config paths", a }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("local") - expect(config.diff_style).toBe("stacked") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("local") + expect(config.diff_style).toBe("stacked") }) test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { @@ -141,26 +144,21 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(5) - expect(config.keybinds?.app_exit).toBe("ctrl+q") - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - expect(JSON.parse(text)).toMatchObject({ - theme: "migrated-theme", - scroll_speed: 5, - }) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.keybinds).toBeUndefined() - expect(server.tui).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - }, + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(5) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + expect(JSON.parse(text)).toMatchObject({ + theme: "migrated-theme", + scroll_speed: 5, }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.keybinds).toBeUndefined() + expect(server.tui).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) }) test("migrates project legacy tui keys even when global tui.json already exists", async () => { @@ -181,19 +179,14 @@ test("migrates project legacy tui keys even when global tui.json already exists" }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("project-migrated") - expect(config.scroll_speed).toBe(2) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("project-migrated") + expect(config.scroll_speed).toBe(2) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.tui).toBeUndefined() - }, - }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.tui).toBeUndefined() }) test("drops unknown legacy tui keys during migration", async () => { @@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(2) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(2) - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - const migrated = JSON.parse(text) - expect(migrated.scroll_speed).toBe(2) - expect(migrated.foo).toBeUndefined() - }, - }) + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + const migrated = JSON.parse(text) + expect(migrated.scroll_speed).toBe(2) + expect(migrated.foo).toBeUndefined() }) test("skips migration when opencode.jsonc is syntactically invalid", async () => { @@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () => }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBeUndefined() - expect(config.scroll_speed).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) - const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) - expect(source).toContain('"theme": "broken-theme"') - expect(source).toContain('"tui": { "scroll_speed": 2 }') - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBeUndefined() + expect(config.scroll_speed).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) + const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(source).toContain('"theme": "broken-theme"') + expect(source).toContain('"tui": { "scroll_speed": 2 }') }) test("skips migration when tui.json already exists", async () => { @@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("stacked") - expect(config.theme).toBeUndefined() + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("stacked") + expect(config.theme).toBeUndefined() - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBe("legacy") - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) - }, - }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBe("legacy") + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) }) test("continues loading tui config when legacy source cannot be stripped", async () => { @@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async await fs.chmod(source, 0o444) try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("readonly-theme") - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("readonly-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - const server = JSON.parse(await Filesystem.readText(source)) - expect(server.theme).toBe("readonly-theme") - }, - }) + const server = JSON.parse(await Filesystem.readText(source)) + expect(server.theme).toBe("readonly-theme") } finally { await fs.chmod(source, 0o644) } @@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await TuiConfig.get() - const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) - expect(backup).toContain("// top-level comment") - expect(backup).toContain("// nested comment") - expect(backup).toContain('"theme": "jsonc-theme"') - expect(backup).toContain('"scroll_speed": 1.5') - }, - }) + await getTuiConfig(tmp.path) + const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) + expect(backup).toContain("// top-level comment") + expect(backup).toContain("// nested comment") + expect(backup).toContain('"theme": "jsonc-theme"') + expect(backup).toContain('"scroll_speed": 1.5') }) test("migrates legacy tui keys across multiple opencode.json levels", async () => { @@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () = await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2)) }, }) - - await Instance.provide({ - directory: path.join(tmp.path, "apps", "client"), - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("nested-theme") - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) - }, - }) + const config = await getTuiConfig(path.join(tmp.path, "apps", "client")) + expect(config.theme).toBe("nested-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) }) test("flattens nested tui key inside tui.json", async () => { @@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.scroll_speed).toBe(3) - expect(config.diff_style).toBe("stacked") - // top-level keys take precedence over nested tui keys - expect(config.theme).toBe("outer") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.scroll_speed).toBe(3) + expect(config.diff_style).toBe("stacked") + // top-level keys take precedence over nested tui keys + expect(config.theme).toBe("outer") }) test("top-level keys in tui.json take precedence over nested tui key", async () => { @@ -395,14 +352,9 @@ test("top-level keys in tui.json take precedence over nested tui key", async () }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("auto") - expect(config.scroll_speed).toBe(2) - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("auto") + expect(config.scroll_speed).toBe(2) }) test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => { @@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - // project tui.json overrides the custom path, same as server config precedence - expect(config.theme).toBe("project") - // project also set diff_style, so that wins - expect(config.diff_style).toBe("auto") - }, - }) + const config = await getTuiConfig(tmp.path) + // project tui.json overrides the custom path, same as server config precedence + expect(config.theme).toBe("project") + // project also set diff_style, so that wins + expect(config.diff_style).toBe("auto") }) test("merges keybind overrides across precedence layers", async () => { @@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } })) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.app_exit).toBe("ctrl+q") - expect(config.keybinds?.theme_list).toBe("ctrl+k") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds?.theme_list).toBe("ctrl+k") }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { @@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } })) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+y") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+y") }) wintest("ignores terminal suspend bindings on Windows", async () => { @@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { @@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn process.env.OPENCODE_TUI_CONFIG = custom }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("from-env") - expect(config.diff_style).toBe("stacked") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("from-env") + expect(config.diff_style).toBe("stacked") }) test("does not derive tui path from OPENCODE_CONFIG", async () => { @@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => { process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json") }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBeUndefined() - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBeUndefined() }) test("applies env and file substitutions in tui.json", async () => { @@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => { ) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("env-theme") - expect(config.keybinds?.app_exit).toBe("ctrl+q") - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("env-theme") + expect(config.keybinds?.app_exit).toBe("ctrl+q") } finally { if (original === undefined) delete process.env.TUI_THEME_TEST else process.env.TUI_THEME_TEST = original @@ -575,46 +481,8 @@ test("applies file substitutions when first identical token is in a commented li ) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("resolved-theme") - }, - }) -}) - -test("loads managed tui config and gives it highest precedence", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2), - ) - await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write( - path.join(managedConfigDir, "tui.json"), - JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("managed-theme") - expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) - expect(config.plugin_origins).toEqual([ - { - spec: "shared-plugin@2.0.0", - scope: "global", - source: path.join(managedConfigDir, "tui.json"), - }, - ]) - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.theme).toBe("resolved-theme") }) test("loads .opencode/tui.json", async () => { @@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => { await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) }, }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.diff_style).toBe("stacked") - }, - }) -}) - -test("gracefully falls back when tui.json has invalid JSON", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), "{ invalid json }") - await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2)) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.theme).toBe("managed-fallback") - expect(config.keybinds).toBeDefined() - }, - }) + const config = await getTuiConfig(tmp.path) + expect(config.diff_style).toBe("stacked") }) test("supports tuple plugin specs with options in tui.json", async () => { @@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), }, - }) + ]) }) test("deduplicates tuple plugin specs by name with higher precedence winning", async () => { @@ -702,28 +540,23 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual([ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@2.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - { - spec: ["second-plugin@3.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual([ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@2.0.0", { source: "project" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), }, - }) + { + spec: ["second-plugin@3.0.0", { source: "project" }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ]) }) test("tracks global and local plugin metadata in merged tui config", async () => { @@ -744,25 +577,20 @@ test("tracks global and local plugin metadata in merged tui config", async () => }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) - expect(config.plugin_origins).toEqual([ - { - spec: "global-plugin@1.0.0", - scope: "global", - source: path.join(Global.Path.config, "tui.json"), - }, - { - spec: "local-plugin@2.0.0", - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) + const config = await getTuiConfig(tmp.path) + expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) + expect(config.plugin_origins).toEqual([ + { + spec: "global-plugin@1.0.0", + scope: "global", + source: path.join(Global.Path.config, "tui.json"), }, - }) + { + spec: "local-plugin@2.0.0", + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ]) }) test("merges plugin_enabled flags across config layers", async () => { @@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await TuiConfig.get() - expect(config.plugin_enabled).toEqual({ - "internal:sidebar-context": false, - "demo.plugin": false, - "local.plugin": true, - }) - }, + const config = await getTuiConfig(tmp.path) + expect(config.plugin_enabled).toEqual({ + "internal:sidebar-context": false, + "demo.plugin": false, + "local.plugin": true, }) }) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 28fd2c8384..21dbc75b95 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Filesystem.mimeType(filepath)).toContain("application/json") + expect(await Filesystem.mimeType(filepath)).toContain("application/json") const result = await read("test.json") expect(result.type).toBe("text") @@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Filesystem.mimeType(filepath)).toContain(mime) + expect(await Filesystem.mimeType(filepath)).toContain(mime) }, }) } diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 493b23f7e8..ba8099fcdd 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,27 +1,31 @@ import { spyOn } from "bun:test" import path from "path" -import { TuiConfig } from "../../src/config" +import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" type PluginSpec = string | [string, Record] -export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { +export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record }) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") const plugin_origins = plugin.map((spec) => ({ spec, scope: "local" as const, source: path.join(dir, "tui.json"), })) - const get = spyOn(TuiConfig, "get").mockResolvedValue({ - plugin, - plugin_origins, - }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) - return () => { - cwd.mockRestore() - get.mockRestore() - wait.mockRestore() - delete process.env.OPENCODE_PLUGIN_META_FILE + const config: TuiConfig.Info = { + plugin, + plugin_origins, + ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), + } + + return { + config, + restore: () => { + cwd.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + }, } } diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index 7edc862c4c..6beb95ac5f 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Global } from "../../src/global" -import { Installation } from "../../src/installation" +import { InstallationChannel } from "../../src/installation/version" import { Database } from "../../src/storage" describe("Database.Path", () => { test("returns database path for the current channel", () => { - const expected = ["latest", "beta"].includes(Installation.CHANNEL) + const expected = ["latest", "beta"].includes(InstallationChannel) ? path.join(Global.Path.data, "opencode.db") - : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) + : path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) expect(Database.getChannelPath()).toBe(expected) }) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 8e1724b474..3b32c72e05 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -16,6 +16,7 @@ import { Tool } from "../../src/tool" import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Npm } from "@opencode-ai/shared/npm" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 1f3a66b950..d5f8a529bd 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -347,31 +347,31 @@ describe("filesystem", () => { }) describe("mimeType()", () => { - test("returns correct MIME type for JSON", () => { - expect(Filesystem.mimeType("test.json")).toContain("application/json") + test("returns correct MIME type for JSON", async () => { + expect(await Filesystem.mimeType("test.json")).toContain("application/json") }) - test("returns correct MIME type for JavaScript", () => { - expect(Filesystem.mimeType("test.js")).toContain("javascript") + test("returns correct MIME type for JavaScript", async () => { + expect(await Filesystem.mimeType("test.js")).toContain("javascript") }) - test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => { - const mime = Filesystem.mimeType("test.ts") + test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => { + const mime = await Filesystem.mimeType("test.ts") // .ts is ambiguous: TypeScript vs MPEG-2 TS video expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true) }) - test("returns correct MIME type for images", () => { - expect(Filesystem.mimeType("test.png")).toContain("image/png") - expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg") + test("returns correct MIME type for images", async () => { + expect(await Filesystem.mimeType("test.png")).toContain("image/png") + expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg") }) - test("returns default for unknown extension", () => { - expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream") + test("returns default for unknown extension", async () => { + expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream") }) - test("handles files without extension", () => { - expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream") + test("handles files without extension", async () => { + expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream") }) }) diff --git a/packages/opencode/time.ts b/packages/opencode/time.ts new file mode 100755 index 0000000000..c00936db26 --- /dev/null +++ b/packages/opencode/time.ts @@ -0,0 +1,4 @@ +import path from "path" +const toDynamicallyImport = path.join(process.cwd(), process.argv[2]) +await import(toDynamicallyImport) +console.log(performance.now()) diff --git a/packages/opencode/trace-imports.ts b/packages/opencode/trace-imports.ts new file mode 100755 index 0000000000..3aad338515 --- /dev/null +++ b/packages/opencode/trace-imports.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env bun +import * as path from "path" +import * as ts from "typescript" + +const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode" + +// Get entry file from command line arg or use default +const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts" + +const visited = new Set() + +function resolveImport(importPath: string, fromFile: string): string | null { + if (importPath.startsWith("@/")) { + return path.join(BASE_DIR, "src", importPath.slice(2)) + } + + if (importPath.startsWith("./") || importPath.startsWith("../")) { + const dir = path.dirname(fromFile) + return path.resolve(dir, importPath) + } + + return null +} + +function isInternalImport(importPath: string): boolean { + return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../") +} + +async function tryExtensions(filePath: string): Promise { + const extensions = [".ts", ".tsx", ".js", ".jsx"] + + try { + const file = Bun.file(filePath) + const stat = await file.stat() + + if (stat?.isDirectory()) { + for (const ext of extensions) { + const indexPath = path.join(filePath, "index" + ext) + const indexFile = Bun.file(indexPath) + if (await indexFile.exists()) return indexPath + } + return null + } + + // It's a file + return filePath + } catch { + // Path doesn't exist, try adding extensions + for (const ext of extensions) { + const withExt = filePath + ext + const extFile = Bun.file(withExt) + if (await extFile.exists()) return withExt + } + return null + } +} + +function extractImports(sourceFile: ts.SourceFile): string[] { + const imports: string[] = [] + + function visit(node: ts.Node) { + // import x from "path" or import { x } from "path" + if (ts.isImportDeclaration(node)) { + // Skip type-only imports + if (node.importClause?.isTypeOnly) return + + const moduleSpec = node.moduleSpecifier + if (ts.isStringLiteral(moduleSpec)) { + imports.push(moduleSpec.text) + } + } + + // export { x } from "path" + if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (ts.isStringLiteral(node.moduleSpecifier)) { + imports.push(node.moduleSpecifier.text) + } + } + + // Dynamic import: import("path") + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) { + imports.push(arg.text) + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return imports +} + +async function traceFile(filePath: string, depth = 0): Promise { + const normalizedPath = path.relative(BASE_DIR, filePath) + + if (visited.has(filePath)) { + return + } + + // Only trace TypeScript/JavaScript files + if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) { + return + } + + visited.add(filePath) + console.log("\t".repeat(depth) + normalizedPath) + + let content: string + try { + content = await Bun.file(filePath).text() + } catch { + return + } + + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true) + + const imports = extractImports(sourceFile) + const internalImports = imports.filter(isInternalImport) + const externalImports = imports.filter((imp) => !isInternalImport(imp)) + + // Print external imports + for (const imp of externalImports) { + console.log("\t".repeat(depth + 1) + `[ext] ${imp}`) + } + + for (const imp of internalImports) { + const resolved = resolveImport(imp, filePath) + if (!resolved) continue + + const actualPath = await tryExtensions(resolved) + if (!actualPath) continue + + await traceFile(actualPath, depth + 1) + } +} + +async function main() { + const entryPath = path.join(BASE_DIR, ENTRY_FILE) + + // Check if file exists + const file = Bun.file(entryPath) + if (!(await file.exists())) { + console.error(`File not found: ${ENTRY_FILE}`) + console.error(`Resolved to: ${entryPath}`) + process.exit(1) + } + + await traceFile(entryPath) +} + +main().catch(console.error) diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index ff9886313a..5cb51012ae 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -10,7 +10,8 @@ "customConditions": ["browser"], "paths": { "@/*": ["./src/*"], - "@tui/*": ["./src/cli/cmd/tui/*"] + "@tui/*": ["./src/cli/cmd/tui/*"], + "@test/*": ["./test/*"] }, "plugins": [ { diff --git a/packages/shared/package.json b/packages/shared/package.json index 252b381d48..ac2d8f2097 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,7 +6,8 @@ "license": "MIT", "private": true, "scripts": { - "test": "bun test" + "test": "bun test", + "typecheck": "tsgo --noEmit" }, "bin": { "opencode": "./bin/opencode" @@ -17,7 +18,9 @@ "imports": {}, "devDependencies": { "@types/semver": "catalog:", - "@types/bun": "catalog:" + "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", + "@tsconfig/bun": "catalog:" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts index 8bd0cc468b..955cafa190 100644 --- a/packages/shared/src/npm.ts +++ b/packages/shared/src/npm.ts @@ -1,6 +1,5 @@ import path from "path" import semver from "semver" -import { Arborist } from "@npmcli/arborist" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "./filesystem" @@ -19,8 +18,8 @@ export namespace Npm { } export interface Interface { - readonly add: (pkg: string) => Effect.Effect - readonly install: (dir: string) => Effect.Effect + readonly add: (pkg: string) => Effect.Effect + readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string) => Effect.Effect> } @@ -92,6 +91,7 @@ export namespace Npm { }) const add = Effect.fn("Npm.add")(function* (pkg: string) { + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const dir = directory(pkg) yield* flock.acquire(`npm-install:${dir}`) @@ -133,10 +133,17 @@ export namespace Npm { return resolveEntryPoint(first.name, first.path) }, Effect.scoped) - const install = Effect.fn("Npm.install")(function* (dir: string) { + const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + const canWrite = yield* afs.access(dir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + if (!canWrite) return + yield* flock.acquire(`npm-install:${dir}`) const reify = Effect.fnUntraced(function* () { + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const arb = new Arborist({ path: dir, binLinks: true, @@ -145,7 +152,14 @@ export namespace Npm { ignoreScripts: true, }) yield* Effect.tryPromise({ - try: () => arb.reify().catch(() => {}), + try: () => + arb + .reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }) + .catch(() => {}), catch: () => {}, }).pipe(Effect.orElseSucceed(() => {})) }) @@ -167,6 +181,7 @@ export namespace Npm { ...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.optionalDependencies || {}), + ...(input?.add || []), ]) const root = lockAny?.packages?.[""] || {} diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts index 12c27a0a77..9d3b7c661a 100644 --- a/packages/shared/src/util/error.ts +++ b/packages/shared/src/util/error.ts @@ -4,6 +4,12 @@ export abstract class NamedError extends Error { abstract schema(): z.core.$ZodType abstract toObject(): { name: string; data: any } + static hasName(error: unknown, name: string): boolean { + return ( + typeof error === "object" && error !== null && "name" in error && (error as Record).name === name + ) + } + static create(name: Name, data: Data) { const schema = z .object({ diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts index 4a1df1dee7..958bd9fd1d 100644 --- a/packages/shared/src/util/flock.ts +++ b/packages/shared/src/util/flock.ts @@ -345,10 +345,14 @@ export namespace Flock { return await fn() } - export const effect = Effect.fn("Flock.effect")(function* (key: string) { + export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) { return yield* Effect.acquireRelease( - Effect.promise((signal) => Flock.acquire(key, { signal })), - (foo) => Effect.promise(() => foo.release()), + Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe( + Effect.withSpan("Flock.acquire", { + attributes: { key }, + }), + ), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")), ).pipe(Effect.asVoid) }) } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index ff9886313a..d7745d7554 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -2,16 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "@opentui/solid", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": [], "noUncheckedIndexedAccess": false, - "customConditions": ["browser"], - "paths": { - "@/*": ["./src/*"], - "@tui/*": ["./src/cli/cmd/tui/*"] - }, "plugins": [ { "name": "@effect/language-service", From f418fd56323c42f7bf1a0009db966632ca9bbab2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:03:41 -0500 Subject: [PATCH 68/75] beta badge for desktop app (#14471) Co-authored-by: Brendan Allan --- packages/app/src/components/titlebar.tsx | 73 ++++++++++--------- packages/app/src/env.d.ts | 1 + .../desktop-electron/electron.vite.config.ts | 3 + 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index a90178abdd..b7edb85ede 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -252,41 +252,48 @@ export function Titlebar() {

- -
- -
-
+
+ +
+ +
+
+
+ {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( +
+ {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} +
+ )} +
-
diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 89721f34f2..22e52f9919 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -3,6 +3,7 @@ import "solid-js" interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string + readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod" } interface ImportMeta { diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index e2b296a3e2..d0e6c42b6c 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -60,6 +60,9 @@ export default defineConfig({ plugins: [appPlugin], publicDir: "../../../app/public", root: "src/renderer", + define: { + "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), + }, build: { rollupOptions: { input: { From e2c08039624ac7c799ea5ba6ce94e3c671a8ed7c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 14:10:03 +0800 Subject: [PATCH 69/75] Fix desktop download asset names for beta channel (#22766) --- .../app/src/routes/download/[channel]/[platform].ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index 82d2f1d01c..082dfe7073 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -1,7 +1,7 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" -const assetNames: Record = { +const prodAssetNames: Record = { "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", "windows-x64-nsis": "opencode-desktop-windows-x64.exe", @@ -10,6 +10,15 @@ const assetNames: Record = { "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record +const betaAssetNames: Record = { + "darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-electron-mac-x64.dmg", + "windows-x64-nsis": "opencode-electron-win-x64.exe", + "linux-x64-deb": "opencode-electron-linux-amd64.deb", + "linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage", + "linux-x64-rpm": "opencode-electron-linux-x86_64.rpm", +} satisfies Record + // Doing this on the server lets us preserve the original name for platforms we don't care to rename for const downloadNames: Record = { "darwin-aarch64-dmg": "OpenCode Desktop.dmg", @@ -18,7 +27,7 @@ const downloadNames: Record = { } satisfies { [K in DownloadPlatform]?: string } export async function GET({ params: { platform, channel } }: APIEvent) { - const assetName = assetNames[platform] + const assetName = channel === "stable" ? prodAssetNames[platform] : betaAssetNames[platform] if (!assetName) return new Response(null, { status: 404 }) const resp = await fetch( From 97918500d4020a7b44f3636f23daabc8c477008b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Apr 2026 14:10:23 +0800 Subject: [PATCH 70/75] app: start migrating bootstrap data fetching to TanStack Query (#22756) --- bun.lock | 4 + packages/app/src/app.tsx | 23 +- packages/app/src/components/prompt-input.tsx | 203 +++++++------ packages/app/src/context/global-sync.tsx | 91 +++--- .../app/src/context/global-sync/bootstrap.ts | 269 ++++++++++-------- .../src/context/global-sync/child-store.ts | 1 + packages/app/src/context/global-sync/types.ts | 1 + packages/app/src/pages/layout.tsx | 10 +- .../src/pages/layout/sidebar-workspace.tsx | 8 +- packages/app/src/pages/session.tsx | 16 +- 10 files changed, 355 insertions(+), 271 deletions(-) diff --git a/bun.lock b/bun.lock index 644de37f2e..c156533799 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -597,6 +598,7 @@ "solid-js": "catalog:", "solid-list": "catalog:", "strip-ansi": "7.1.2", + "tw-animate-css": "1.4.0", "virtua": "catalog:", }, "devDependencies": { @@ -4859,6 +4861,8 @@ "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a2a746c05b..dbe1074484 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - }> - {props.appChildren} - {props.children} - + {/*}>*/} + {props.appChildren} + {props.children} + {/**/} ) } @@ -184,14 +184,22 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { ) return ( -
} > + {/* + + + } + >*/} + {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} ) { > {props.children} - + {/**/} + ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 534215022a..156b0b3a4a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -54,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useQuery } from "@tanstack/solid-query" +import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap" interface PromptInputProps { class?: string @@ -100,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/ export const PromptInput: Component = (props) => { const sdk = useSDK() + const sync = useSync() const local = useLocal() const files = useFile() @@ -1249,6 +1252,14 @@ export const PromptInput: Component = (props) => { } } + const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory)) + const agentsLoading = () => agentsQuery.isLoading + + const globalProvidersQuery = useQuery(() => loadProvidersQuery(null)) + const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory)) + + const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + return (
= (props) => { {language.t("prompt.mode.shell")}
-
-
- - { + local.agent.set(value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-agent" }} + variant="ghost" + /> + +
+ + + +
+ 0} + fallback={ + + + + } + > - + - } - > + +
+
- (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + local.model.variant.set(value === "default" ? undefined : value) + restoreFocus() }} - onClose={restoreFocus} - > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> - -
-
- -