From b1f076558cf75c9ae5322f6195e68c3c380e3f9c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 14:33:02 -0400 Subject: [PATCH 01/30] test: align plugin loader npm mocks - switch plugin loader tests to the effect npm module - return Option.none() for mocked npm entrypoints - keep test fixtures aligned with the current Npm.add contract --- .../cli/tui/plugin-loader-entrypoint.test.ts | 15 ++++++------ .../test/plugin/loader-shared.test.ts | 24 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) 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 395e8ce429..c6c25fcc11 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -1,3 +1,4 @@ +import { Option } from "effect" import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -5,7 +6,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" -import { Npm } from "../../../src/npm" +import { Npm } from "../../../src/npm/effect" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -56,7 +57,7 @@ test("loads npm tui plugin from package ./tui export", async () => { } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -117,7 +118,7 @@ test("does not use npm package exports dot for tui entry", async () => { } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -179,7 +180,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -241,7 +242,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -299,7 +300,7 @@ test("does not use npm package main for tui entry", async () => { } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) const warn = spyOn(console, "warn").mockImplementation(() => {}) const error = spyOn(console, "error").mockImplementation(() => {}) @@ -468,7 +469,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { } 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 }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 5072c1e748..8e3ad5ea0b 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Option } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" @@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") -const { Npm } = await import("../../src/npm") +const { Npm } = await import("../../src/npm/effect") afterAll(() => { if (disableDefault === undefined) { @@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => { }) const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme } - return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope } + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() } + return { directory: tmp.extra.scope, entrypoint: Option.none() } }) try { @@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { await load(tmp.path) @@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() }) try { await load(tmp.path) @@ -927,7 +927,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) const missing: string[] = [] try { @@ -996,7 +996,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) try { const loaded = await PluginLoader.loadExternal({ From bbb422d1250da1400c9c228d363bebb336e238ca Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:22 -0500 Subject: [PATCH 02/30] chore: bump ai to 6.0.168 and @ai-sdk/gateway to 3.0.104 (#23145) --- bun.lock | 12 +++++------- package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index c78c9ae48d..8d83d8ab47 100644 --- a/bun.lock +++ b/bun.lock @@ -328,7 +328,7 @@ "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.102", + "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", @@ -692,7 +692,7 @@ "@types/node": "22.13.9", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.2", "dompurify": "3.3.1", @@ -760,7 +760,7 @@ "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], @@ -2456,7 +2456,7 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -2516,7 +2516,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], + "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -5702,8 +5702,6 @@ "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], diff --git a/package.json b/package.json index f1ad03afc8..ddd711adaf 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.48", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cadb1fa38d..2f56b74775 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,7 +85,7 @@ "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.102", + "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", From 467be08e679d82c20164870f067eb759abe5f6ec Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 17 Apr 2026 14:58:37 -0400 Subject: [PATCH 03/30] refactor: consolidate npm exports and trace flock acquisition (#23151) --- .../opencode/src/cli/cmd/tui/config/tui.ts | 2 +- packages/opencode/src/cli/cmd/tui/layer.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/npm/effect.ts | 258 ----------- packages/opencode/src/npm/index.ts | 405 +++++++++++------- packages/opencode/src/plugin/shared.ts | 2 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 15 +- packages/opencode/test/config/config.test.ts | 2 +- .../test/plugin/loader-shared.test.ts | 24 +- packages/shared/src/util/effect-flock.ts | 75 ++-- 11 files changed, 304 insertions(+), 485 deletions(-) delete mode 100644 packages/opencode/src/npm/effect.ts diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index abcf11fcef..179046e026 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version" import { makeRuntime } from "@/effect/runtime" import { Filesystem, Log } from "@/util" import { ConfigVariable } from "@/config/variable" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts index 66497f8b1a..64cba08e82 100644 --- a/packages/opencode/src/cli/cmd/tui/layer.ts +++ b/packages/opencode/src/cli/cmd/tui/layer.ts @@ -1,6 +1,6 @@ import { Layer } from "effect" import { TuiConfig } from "./config/tui" -import { Npm } from "@/npm/effect" +import { Npm } from "@/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/config/config.ts b/packages/opencode/src/config/config.ts index 8980765b79..459f76961a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths" import { ConfigFormatter } from "./formatter" import { ConfigLSP } from "./lsp" import { ConfigVariable } from "./variable" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const log = Log.create({ service: "config" }) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 262d85e7ea..d68e00a323 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,7 +46,7 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share" import { SessionShare } from "@/share" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" import { memoMap } from "./memo-map" export const AppLayer = Layer.mergeAll( diff --git a/packages/opencode/src/npm/effect.ts b/packages/opencode/src/npm/effect.ts deleted file mode 100644 index 5968f14519..0000000000 --- a/packages/opencode/src/npm/effect.ts +++ /dev/null @@ -1,258 +0,0 @@ -export * as Npm from "./effect" - -import path from "path" -import semver from "semver" -import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Global } from "@opencode-ai/shared/global" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" - -import { makeRuntime } from "../effect/runtime" - -export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { - add: Schema.Array(Schema.String).pipe(Schema.optional), - dir: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export interface EntryPoint { - readonly directory: string - readonly entrypoint: Option.Option -} - -export interface Interface { - 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> -} - -export class Service extends Context.Service()("@opencode/Npm") {} - -const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined - -export function sanitize(pkg: string) { - if (!illegal) return pkg - return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") -} - -const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: Option.Option - try { - const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - entrypoint = Option.some(resolved) - } catch { - entrypoint = Option.none() - } - return { - directory: dir, - entrypoint, - } -} - -interface ArboristNode { - name: string - path: string -} - -interface ArboristTree { - edgesOut: Map -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - 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 reify = (input: { dir: string; add?: string[] }) => - Effect.gen(function* () { - yield* flock.acquire(`npm-install:${input.dir}`) - const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) - const arborist = new Arborist({ - path: input.dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, - }) - return yield* Effect.tryPromise({ - try: () => - arborist.reify({ - add: input?.add || [], - save: true, - saveType: "prod", - }), - catch: (cause) => - new InstallFailedError({ - cause, - add: input?.add, - dir: input.dir, - }), - }) as Effect.Effect - }).pipe( - Effect.withSpan("Npm.reify", { - attributes: input, - }), - ) - - const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { - const response = yield* Effect.tryPromise({ - try: () => fetch(`https://registry.npmjs.org/${pkg}`), - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - if (!response || !response.ok) { - return false - } - - const data = yield* Effect.tryPromise({ - try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - const latestVersion = data?.["dist-tags"]?.latest - if (!latestVersion) { - return false - } - - const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion, cachedVersion) - - return semver.lt(cachedVersion, latestVersion) - }) - - const add = Effect.fn("Npm.add")(function* (pkg: string) { - const dir = directory(pkg) - - const tree = yield* reify({ dir, add: [pkg] }) - const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) - return resolveEntryPoint(first.name, first.path) - }, Effect.scoped) - - 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* Effect.gen(function* () { - const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) - if (!nodeModulesExists) { - yield* reify({ add: input?.add, dir }) - return - } - }).pipe(Effect.withSpan("Npm.checkNodeModules")) - - yield* Effect.gen(function* () { - const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({}))) - const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({}))) - - const pkgAny = pkg as any - const lockAny = lock as any - const declared = new Set([ - ...Object.keys(pkgAny?.dependencies || {}), - ...Object.keys(pkgAny?.devDependencies || {}), - ...Object.keys(pkgAny?.peerDependencies || {}), - ...Object.keys(pkgAny?.optionalDependencies || {}), - ...(input?.add || []), - ]) - - const root = lockAny?.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)) { - yield* reify({ dir, add: input?.add }) - return - } - } - }).pipe(Effect.withSpan("Npm.checkDirty")) - - return - }, Effect.scoped) - - const which = Effect.fn("Npm.which")(function* (pkg: string) { - const dir = directory(pkg) - const binDir = path.join(dir, "node_modules", ".bin") - - const pick = Effect.fnUntraced(function* () { - const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - - if (files.length === 0) return Option.none() - if (files.length === 1) return Option.some(files[0]) - - const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) - - if (Option.isSome(pkgJson)) { - const parsed = pkgJson.value as { bin?: string | Record } - if (parsed?.bin) { - const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = parsed.bin - if (typeof bin === "string") return Option.some(unscoped) - const keys = Object.keys(bin) - if (keys.length === 1) return Option.some(keys[0]) - return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) - } - } - - return Option.some(files[0]) - }) - - return yield* Effect.gen(function* () { - const bin = yield* pick() - if (Option.isSome(bin)) { - return Option.some(path.join(binDir, bin.value)) - } - - yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) - - yield* add(pkg) - - const resolved = yield* pick() - if (Option.isNone(resolved)) return Option.none() - return Option.some(path.join(binDir, resolved.value)) - }).pipe( - Effect.scoped, - Effect.orElseSucceed(() => Option.none()), - ) - }) - - return Service.of({ - add, - install, - outdated, - which, - }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(EffectFlock.layer), - Layer.provide(AppFileSystem.layer), - Layer.provide(Global.layer), - Layer.provide(NodeFileSystem.layer), -) - -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function install(...args: Parameters) { - return runPromise((svc) => svc.install(...args)) -} - -export async function add(...args: Parameters) { - return runPromise((svc) => svc.add(...args)) -} diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 425b27f420..f242598192 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,198 +1,271 @@ -import semver from "semver" -import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Global } from "../global" -import { Log } from "../util" +export * as Npm from "." + import path from "path" -import { readdir, rm } from "fs/promises" -import { Filesystem } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import semver from "semver" +import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Global } from "@opencode-ai/shared/global" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" + +import { makeRuntime } from "../effect/runtime" + +export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { + add: Schema.Array(Schema.String).pipe(Schema.optional), + dir: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export interface EntryPoint { + readonly directory: string + readonly entrypoint: Option.Option +} + +export interface Interface { + 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> +} + +export class Service extends Context.Service()("@opencode/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 +const resolveEntryPoint = (name: string, dir: string): EntryPoint => { + let entrypoint: Option.Option try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - } catch {} - const result = { + const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) + entrypoint = Option.some(resolved) + } catch { + entrypoint = Option.none() + } + return { 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) +interface ArboristNode { + name: string + path: string } -export async function add(pkg: string) { - const { Arborist } = await import("@npmcli/arborist") - const dir = directory(pkg) - await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`) - log.info("installing package", { - pkg, - }) +interface ArboristTree { + edgesOut: Map +} - 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) - } - } +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + 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 reify = (input: { dir: string; add?: string[] }) => + Effect.gen(function* () { + yield* flock.acquire(`npm-install:${input.dir}`) + const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const arborist = new Arborist({ + path: input.dir, + binLinks: true, + progress: false, + savePrefix: "", + ignoreScripts: true, + }) + return yield* Effect.tryPromise({ + try: () => + arborist.reify({ + add: input?.add || [], + save: true, + saveType: "prod", + }), + catch: (cause) => + new InstallFailedError({ + cause, + add: input?.add, + dir: input.dir, + }), + }) as Effect.Effect + }).pipe( + Effect.withSpan("Npm.reify", { + attributes: input, + }), + ) - const result = await arborist - .reify({ - add: [pkg], - save: true, - saveType: "prod", + const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { + const response = yield* Effect.tryPromise({ + try: () => fetch(`https://registry.npmjs.org/${pkg}`), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + + if (!response || !response.ok) { + return false + } + + const data = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + + const latestVersion = data?.["dist-tags"]?.latest + if (!latestVersion) { + return false + } + + const range = /[\s^~*xX<>|=]/.test(cachedVersion) + if (range) return !semver.satisfies(latestVersion, cachedVersion) + + return semver.lt(cachedVersion, latestVersion) }) - .catch((cause) => { - throw new InstallFailedError( - { pkg }, - { - cause, - }, + + const add = Effect.fn("Npm.add")(function* (pkg: string) { + const dir = directory(pkg) + + const tree = yield* reify({ dir, add: [pkg] }) + const first = tree.edgesOut.values().next().value?.to + if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + return resolveEntryPoint(first.name, first.path) + }, Effect.scoped) + + 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* Effect.gen(function* () { + const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules")) + if (!nodeModulesExists) { + yield* reify({ add: input?.add, dir }) + return + } + }).pipe(Effect.withSpan("Npm.checkNodeModules")) + + yield* Effect.gen(function* () { + const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({}))) + const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({}))) + + const pkgAny = pkg as any + const lockAny = lock as any + const declared = new Set([ + ...Object.keys(pkgAny?.dependencies || {}), + ...Object.keys(pkgAny?.devDependencies || {}), + ...Object.keys(pkgAny?.peerDependencies || {}), + ...Object.keys(pkgAny?.optionalDependencies || {}), + ...(input?.add || []), + ]) + + const root = lockAny?.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)) { + yield* reify({ dir, add: input?.add }) + return + } + } + }).pipe(Effect.withSpan("Npm.checkDirty")) + + return + }, Effect.scoped) + + const which = Effect.fn("Npm.which")(function* (pkg: string) { + const dir = directory(pkg) + const binDir = path.join(dir, "node_modules", ".bin") + + const pick = Effect.fnUntraced(function* () { + const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + if (files.length === 0) return Option.none() + if (files.length === 1) return Option.some(files[0]) + + const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) + + if (Option.isSome(pkgJson)) { + const parsed = pkgJson.value as { bin?: string | Record } + if (parsed?.bin) { + const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg + const bin = parsed.bin + if (typeof bin === "string") return Option.some(unscoped) + const keys = Object.keys(bin) + if (keys.length === 1) return Option.some(keys[0]) + return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) + } + } + + return Option.some(files[0]) + }) + + return yield* Effect.gen(function* () { + const bin = yield* pick() + if (Option.isSome(bin)) { + return Option.some(path.join(binDir, bin.value)) + } + + yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) + + yield* add(pkg) + + const resolved = yield* pick() + if (Option.isNone(resolved)) return Option.none() + return Option.some(path.join(binDir, resolved.value)) + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => Option.none()), ) }) - 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 { Arborist } = await import("@npmcli/arborist") - const arb = new Arborist({ - path: dir, - binLinks: true, - progress: false, - savePrefix: "", - ignoreScripts: true, + return Service.of({ + add, + install, + outdated, + which, }) - await arb.reify().catch(() => {}) - } + }), +) - if (!(await Filesystem.exists(path.join(dir, "node_modules")))) { - log.info("node_modules missing, reifying") - await reify() - return - } +export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), +) - type PackageDeps = Record - type PackageJson = { - dependencies?: PackageDeps - devDependencies?: PackageDeps - peerDependencies?: PackageDeps - optionalDependencies?: PackageDeps - } - const pkg: PackageJson = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) - const lock: { packages?: Record } = await Filesystem.readJson<{ - packages?: Record - }>(path.join(dir, "package-lock.json")).catch(() => ({})) +const { runPromise } = makeRuntime(Service, defaultLayer) - 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 install(...args: Parameters) { + return runPromise((svc) => svc.install(...args)) } -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] +export async function add(...args: Parameters) { + const entry = await runPromise((svc) => svc.add(...args)) + return { + directory: entry.directory, + entrypoint: Option.getOrUndefined(entry.entrypoint), } - - 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 "." +export async function outdated(...args: Parameters) { + return runPromise((svc) => svc.outdated(...args)) +} + +export async function which(...args: Parameters) { + const resolved = await runPromise((svc) => svc.which(...args)) + return Option.getOrUndefined(resolved) +} diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index f431204fc4..ca821216d4 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -4,7 +4,7 @@ import npa from "npm-package-arg" import semver from "semver" import { Filesystem } from "@/util" import { isRecord } from "@/util/record" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" // Old npm package names for plugins that are now built-in export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] 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 c6c25fcc11..74236afae8 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -1,4 +1,3 @@ -import { Option } from "effect" import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -6,7 +5,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" -import { Npm } from "../../../src/npm/effect" +import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -57,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => { } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -118,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => { } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -180,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -242,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) @@ -300,7 +299,7 @@ test("does not use npm package main for tui entry", async () => { } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const warn = spyOn(console, "warn").mockImplementation(() => {}) const error = spyOn(console, "error").mockImplementation(() => {}) @@ -469,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { } 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: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 7b01ee626a..9f2bf9db9a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -27,7 +27,7 @@ import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" import { ConfigPlugin } from "@/config/plugin" -import { Npm } from "@/npm/effect" +import { Npm } from "@/npm" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 8e3ad5ea0b..83e9d71b4f 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect, Option } from "effect" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" @@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") -const { Npm } = await import("../../src/npm/effect") +const { Npm } = await import("../../src/npm") afterAll(() => { if (disableDefault === undefined) { @@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => { }) const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { - if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() } - return { directory: tmp.extra.scope, entrypoint: Option.none() } + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } + return { directory: tmp.extra.scope, entrypoint: undefined } }) try { @@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { await load(tmp.path) @@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) try { await load(tmp.path) @@ -927,7 +927,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const missing: string[] = [] try { @@ -996,7 +996,7 @@ export default { }, }) - const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() }) + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) try { const loaded = await PluginLoader.loadExternal({ diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts index 3e00afc9e4..16bcf091b4 100644 --- a/packages/shared/src/util/effect-flock.ts +++ b/packages/shared/src/util/effect-flock.ts @@ -165,55 +165,60 @@ export namespace EffectFlock { type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) { - const token = randomUUID() - const metaPath = path.join(lockDir, "meta.json") - const heartbeatPath = path.join(lockDir, "heartbeat") + const tryAcquireLockDir = (lockDir: string, key: string) => + Effect.gen(function* () { + const token = randomUUID() + const metaPath = path.join(lockDir, "meta.json") + const heartbeatPath = path.join(lockDir, "heartbeat") - // Atomic mkdir — the POSIX lock primitive - const created = yield* atomicMkdir(lockDir) + // Atomic mkdir — the POSIX lock primitive + const created = yield* atomicMkdir(lockDir) - if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + if (!created) { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() - // Stale — race for breaker ownership - const breakerPath = lockDir + ".breaker" + // Stale — race for breaker ownership + const breakerPath = lockDir + ".breaker" - const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( - Effect.as(true), - Effect.catchIf( - (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), - ), - Effect.catchIf(isPathGone, () => Effect.succeed(false)), - Effect.orDie, - ) + const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => cleanStaleBreaker(breakerPath), + ), + Effect.catchIf(isPathGone, () => Effect.succeed(false)), + Effect.orDie, + ) - if (!claimed) return yield* new NotAcquired() + if (!claimed) return yield* new NotAcquired() - // We own the breaker — double-check staleness, nuke, recreate - const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false - yield* forceRemove(lockDir) - return yield* atomicMkdir(lockDir) - }).pipe(Effect.ensuring(forceRemove(breakerPath))) + // We own the breaker — double-check staleness, nuke, recreate + const recreated = yield* Effect.gen(function* () { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + yield* forceRemove(lockDir) + return yield* atomicMkdir(lockDir) + }).pipe(Effect.ensuring(forceRemove(breakerPath))) - if (!recreated) return yield* new NotAcquired() - } + if (!recreated) return yield* new NotAcquired() + } - // We own the lock dir — write heartbeat + meta with exclusive create - yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") + // We own the lock dir — write heartbeat + meta with exclusive create + yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") - const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) - yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") + const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) + yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") - return { token, metaPath, heartbeatPath, lockDir } satisfies Handle - }) + return { token, metaPath, heartbeatPath, lockDir } satisfies Handle + }).pipe( + Effect.withSpan("EffectFlock.tryAcquire", { + attributes: { key }, + }), + ) // -- retry wrapper (preserves Handle type) -- const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile).pipe( + tryAcquireLockDir(lockfile, key).pipe( Effect.retry({ while: (err) => err._tag === "NotAcquired", schedule: retrySchedule, From b275b8580d7bd3b884cf535e28c1b826759eb14b Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:14:05 -0400 Subject: [PATCH 04/30] feat(tui): minor UX improvements for workspaces (#23146) --- .../cmd/tui/component/dialog-session-list.tsx | 9 +- .../tui/component/dialog-workspace-create.tsx | 11 ++- .../dialog-workspace-unavailable.tsx | 83 +++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 53 ++++++++++-- .../cli/cmd/tui/routes/session/sidebar.tsx | 20 +++++ 5 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx 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 60ef6087ba..32342e7724 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 @@ -139,15 +139,10 @@ export function DialogSessionList() { {desc}{" "} - ■ + ● ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 7ea513edee..6dcdabe0b9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -139,7 +139,16 @@ export async function restoreWorkspaceSession(input: { total: result.data.total, }) - await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => { + input.project.workspace.set(input.workspaceID) + + try { + await input.sync.bootstrap({ fatal: false }) + } catch (e) {} + + await Promise.all([ + input.project.workspace.sync(), + input.sync.session.sync(input.sessionID), + ]).catch((err) => { log.error("session restore refresh failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx new file mode 100644 index 0000000000..0c2dd3e2f3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -0,0 +1,83 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createStore } from "solid-js/store" +import { For } from "solid-js" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" + +export function DialogWorkspaceUnavailable(props: { + onRestore?: () => boolean | void | Promise +}) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + active: "restore" as "cancel" | "restore", + }) + + const options = ["cancel", "restore"] as const + + async function confirm() { + if (store.active === "cancel") { + dialog.clear() + return + } + const result = await props.onRestore?.() + if (result === false) return + } + + useKeyboard((evt) => { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + void confirm() + return + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + setStore("active", "cancel") + return + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + setStore("active", "restore") + } + }) + + return ( + + + + Workspace Unavailable + + dialog.clear()}> + esc + + + + This session is attached to a workspace that is no longer available. + + + Would you like to restore this session into a new workspace? + + + + {(item) => ( + { + setStore("active", item) + void confirm() + }} + > + {item} + + )} + + + + ) +} 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 cf26ec1950..2e08e66a4a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" @@ -38,6 +39,8 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" export type PromptProps = { @@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) { const args = useArgs() const sdk = useSDK() const route = useRoute() + const project = useProject() const sync = useSync() const dialog = useDialog() const toast = useToast() @@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) { keybind: "input_submit", category: "Prompt", hidden: true, - onSelect: (dialog) => { + onSelect: async (dialog) => { if (!input.focused) return - void submit() + const handled = await submit() + if (!handled) return + dialog.clear() }, }, @@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } - if (props.disabled) return - if (autocomplete?.visible) return - if (!store.prompt.input) return + if (props.disabled) return false + if (autocomplete?.visible) return false + if (!store.prompt.input) return false const agent = local.agent.current() - if (!agent) return + if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() - return + return true } const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() - return + return false + } + + const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined + const workspaceID = workspaceSession?.workspaceID + const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined + if (props.sessionID && workspaceID && workspaceStatus !== "connected") { + dialog.replace(() => ( + { + dialog.replace(() => ( + + restoreWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: nextWorkspaceID, + sessionID: props.sessionID!, + }) + } + /> + )) + }} + /> + )) + return false } let sessionID = props.sessionID @@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) { variant: "error", }) - return + return true } sessionID = res.data.id @@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) { }) }, 50) input.clear() + return true } const exit = useExit() 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 06bc270644..4a7b711a03 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,3 +1,4 @@ +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" @@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + const project = useProject() const sync = useSync() const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) + const workspaceStatus = () => { + const workspaceID = session()?.workspaceID + if (!workspaceID) return "error" + return project.workspace.status(workspaceID) ?? "error" + } + const workspaceLabel = () => { + const workspaceID = session()?.workspaceID + if (!workspaceID) return "unknown" + const info = project.workspace.get(workspaceID) + if (!info) return "unknown" + return `${info.type}: ${info.name}` + } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) return ( @@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} + + + {" "} + {workspaceLabel()} + + {session()!.share!.url} From d6e1362fee569e9d9fa9bc0c6c982f64b2027b30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:15:07 +0000 Subject: [PATCH 05/30] chore: generate --- .../src/cli/cmd/tui/component/dialog-workspace-create.tsx | 5 +---- .../cli/cmd/tui/component/dialog-workspace-unavailable.tsx | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 6dcdabe0b9..a16c98a9f4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -145,10 +145,7 @@ export async function restoreWorkspaceSession(input: { await input.sync.bootstrap({ fatal: false }) } catch (e) {} - await Promise.all([ - input.project.workspace.sync(), - input.sync.session.sync(input.sessionID), - ]).catch((err) => { + await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => { log.error("session restore refresh failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 0c2dd3e2f3..7a21798534 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -5,9 +5,7 @@ import { For } from "solid-js" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -export function DialogWorkspaceUnavailable(props: { - onRestore?: () => boolean | void | Promise -}) { +export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { const dialog = useDialog() const { theme } = useTheme() const [store, setStore] = createStore({ From 88582566bf2bfd2d26000f0c25735bf48ddeca00 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:18:55 +0000 Subject: [PATCH 06/30] 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 5fe1189579..01366c82dc 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=", - "aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=", - "aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=", - "x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc=" + "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=", + "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=", + "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=", + "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA=" } } From 5621373bc2d5ca106e9fbb2d2fbc3bc547dd744c Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:20:11 -0400 Subject: [PATCH 07/30] fix(core): move instance middleware after control plane routes (#23150) --- .../src/server/routes/instance/index.ts | 6 ++- .../src/server/routes/instance/middleware.ts | 35 +++++++++++++++ packages/opencode/src/server/server.ts | 44 +++---------------- 3 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/middleware.ts diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 017541b8fc..c0339fded7 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -15,6 +15,7 @@ import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" +import { WorkspaceID } from "@/control-plane/schema" import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -27,9 +28,10 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceMiddleware } from "./middleware" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { - const app = new Hono() +export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: WorkspaceID): Hono => { + const app = new Hono().use(InstanceMiddleware(workspaceID)) if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts new file mode 100644 index 0000000000..b963268d64 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -0,0 +1,35 @@ +import type { MiddlewareHandler } from "hono" +import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" +import { AppRuntime } from "@/effect/app-runtime" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { WorkspaceID } from "@/control-plane/schema" + +export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { + return async (c, next) => { + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = AppFileSystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + async fn() { + return next() + }, + }) + }, + }) + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2201c75b4c..f608c2b732 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,16 +1,10 @@ import { generateSpecs } from "hono-openapi" import { Hono } from "hono" -import type { MiddlewareHandler } from "hono" import { adapter } from "#hono" import { lazy } from "@/util/lazy" import { Log } from "@/util" import { Flag } from "@/flag/flag" -import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -48,47 +42,23 @@ function create(opts: { cors?: string[] }) { const runtime = adapter.create(app) - function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { - return async (c, next) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = AppFileSystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return Instance.provide({ - directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - async fn() { - return next() - }, - }) - }, - }) - } - } - if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app - .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route( + "/", + InstanceRoutes( + runtime.upgradeWebSocket, + Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined, + ), + ), runtime, } } return { app: app - .use(InstanceMiddleware()) .route("/", ControlPlaneRoutes()) .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) From 68834cfcc37ce51c1b0b4895b9563a86f1611c9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 15:22:08 -0400 Subject: [PATCH 08/30] fix(opencode): normalize provider metadata and tag otel runs (#23140) --- packages/opencode/src/cli/cmd/tui/thread.ts | 9 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++ packages/opencode/src/effect/observability.ts | 50 +++++++++++-------- packages/opencode/src/file/ripgrep.ts | 5 +- packages/opencode/src/file/ripgrep.worker.ts | 5 +- packages/opencode/src/index.ts | 5 ++ packages/opencode/src/provider/provider.ts | 14 +++--- .../opencode/src/util/opencode-process.ts | 24 +++++++++ .../opencode/test/provider/provider.test.ts | 34 ++++++++++++- 9 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/util/opencode-process.ts diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 96ceb905c5..e3e9eb8117 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { writeHeapSnapshot } from "v8" import { TuiConfig } from "./config/tui" +import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" declare global { const OPENCODE_WORKER_PATH: string @@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({ return } const cwd = Filesystem.resolve(process.cwd()) + const env = sanitizedProcessEnv({ + [OPENCODE_PROCESS_ROLE]: "worker", + [OPENCODE_RUN_ID]: ensureRunID(), + }) const worker = new Worker(file, { - env: Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ), + env, }) worker.onerror = (e) => { Log.Default.error("thread error", { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 393a407eb0..8cec99c615 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" +import { ensureProcessMetadata } from "@/util/opencode-process" + +ensureProcessMetadata("worker") await Log.init({ print: process.argv.includes("--print-logs"), diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index efd16ffc09..1c385d60ae 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" import * as EffectLogger from "./logger" import { Flag } from "@/flag/flag" import { InstallationChannel, InstallationVersion } from "@/installation/version" +import { ensureProcessMetadata } from "@/util/opencode-process" const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT export const enabled = !!base +const processID = crypto.randomUUID() const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( @@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS ) : undefined -const resource = { - serviceName: "opencode", - serviceVersion: InstallationVersion, - attributes: { - "deployment.environment.name": InstallationChannel, - "opencode.client": Flag.OPENCODE_CLIENT, - }, +function resource() { + const processMetadata = ensureProcessMetadata("main") + return { + serviceName: "opencode", + serviceVersion: InstallationVersion, + attributes: { + "deployment.environment.name": InstallationChannel, + "opencode.client": Flag.OPENCODE_CLIENT, + "opencode.process_role": processMetadata.processRole, + "opencode.run_id": processMetadata.runID, + "service.instance.id": processID, + }, + } } -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)) +function logs() { + return Logger.layer( + [ + EffectLogger.logger, + OtlpLogger.make({ + url: `${base}/v1/logs`, + resource: resource(), + headers, + }), + ], + { mergeWithExisting: false }, + ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) +} const traces = async () => { const NodeSdk = await import("@effect/opentelemetry/NodeSdk") @@ -58,7 +68,7 @@ const traces = async () => { context.setGlobalContextManager(mgr) return NodeSdk.layer(() => ({ - resource, + resource: resource(), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ url: `${base}/v1/traces`, @@ -73,7 +83,7 @@ export const layer = !base : Layer.unwrap( Effect.gen(function* () { const trace = yield* Effect.promise(traces) - return Layer.mergeAll(trace, logs) + return Layer.mergeAll(trace, logs()) }), ) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ac450108e1..3f16f6c501 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep" import { Filesystem } from "@/util" import { Log } from "@/util" +import { sanitizedProcessEnv } from "@/util/opencode-process" const log = Log.create({ service: "ripgrep" }) @@ -157,9 +158,7 @@ type WorkerError = { } function env() { - const env = Object.fromEntries( - Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), - ) + const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts index 62094c7acc..21a3aef5cc 100644 --- a/packages/opencode/src/file/ripgrep.worker.ts +++ b/packages/opencode/src/file/ripgrep.worker.ts @@ -1,9 +1,8 @@ import { ripgrep } from "ripgrep" +import { sanitizedProcessEnv } from "@/util/opencode-process" function env() { - const env = Object.fromEntries( - Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), - ) + const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 67de87c2aa..0a3a927b46 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -38,6 +38,9 @@ import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" +import { ensureProcessMetadata } from "./util/opencode-process" + +const processMetadata = ensureProcessMetadata("main") process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -108,6 +111,8 @@ const cli = yargs(args) Log.Default.info("opencode", { version: InstallationVersion, args: process.argv.slice(2), + process_role: processMetadata.processRole, + run_id: processMetadata.runID, }) const marker = path.join(Global.Path.data, "opencode.db") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 558bcb75a5..51aca21c77 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model family: model.family, api: { id: model.id, - url: model.provider?.api ?? provider.api!, + url: model.provider?.api ?? provider.api ?? "", npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", }, status: model.status ?? "active", @@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model output: model.limit.output, }, capabilities: { - temperature: model.temperature, - reasoning: model.reasoning, - attachment: model.attachment, - toolcall: model.tool_call, + temperature: model.temperature ?? false, + reasoning: model.reasoning ?? false, + attachment: model.attachment ?? false, + toolcall: model.tool_call ?? true, input: { text: model.modalities?.input?.includes("text") ?? false, audio: model.modalities?.input?.includes("audio") ?? false, @@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model }, interleaved: model.interleaved ?? false, }, - release_date: model.release_date, + release_date: model.release_date ?? "", variants: {}, } @@ -1143,7 +1143,7 @@ const layer: Layer.Layer< existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", }, status: model.status ?? existingModel?.status ?? "active", name, diff --git a/packages/opencode/src/util/opencode-process.ts b/packages/opencode/src/util/opencode-process.ts new file mode 100644 index 0000000000..f59270ad2d --- /dev/null +++ b/packages/opencode/src/util/opencode-process.ts @@ -0,0 +1,24 @@ +export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID" +export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE" + +export function ensureRunID() { + return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID()) +} + +export function ensureProcessRole(fallback: "main" | "worker") { + return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback) +} + +export function ensureProcessMetadata(fallback: "main" | "worker") { + return { + runID: ensureRunID(), + processRole: ensureProcessRole(fallback), + } +} + +export function sanitizedProcessEnv(overrides?: Record) { + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + return overrides ? Object.assign(env, overrides) : env +} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index df8fc4e966..8993020820 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => { }, }, }, - } as ModelsDev.Provider + } as unknown as ModelsDev.Provider const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] expect(model.cost.input).toEqual(5) @@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => { }) }) +test("models.dev normalization fills required response fields", () => { + const provider = { + id: "gateway", + name: "Gateway", + env: [], + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + cost: { + input: 2.5, + output: 15, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + }, + }, + } as unknown as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] + expect(model.api.url).toBe("") + expect(model.capabilities.temperature).toBe(false) + expect(model.capabilities.reasoning).toBe(false) + expect(model.capabilities.attachment).toBe(false) + expect(model.capabilities.toolcall).toBe(true) + expect(model.release_date).toBe("") +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { From aa05b9abe5ff3a4b29b72fb878be969567eda5bb Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 15:25:58 -0400 Subject: [PATCH 09/30] fix(core): pass OTEL config to workspace env (#23154) --- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/control-plane/workspace.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 3961cd0e2a..07acd5ce58 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -28,7 +28,7 @@ export type WorkspaceAdaptor = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise - create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise remove(info: WorkspaceInfo): Promise target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index d678ad7526..e94d6c2c93 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => { OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), OPENCODE_WORKSPACE_ID: config.id, OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, } await adaptor.create(config, env) From f83cecaaf6ee29da70109575595901884cdbc312 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 15:29:32 -0400 Subject: [PATCH 10/30] fix(opencode): untrace streaming event hot paths (#23156) --- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 820c61aa91..900579f036 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -213,7 +213,7 @@ export const layer: Layer.Layer< return true }) - const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": yield* status.set(ctx.sessionID, { type: "busy" }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 077cc43097..1941a6704e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -649,10 +649,10 @@ export const layer: Layer.Layer = return input.partID }) - const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID field: string delta: string }) { From 9b0659d4f9b64cb2416bb340c503037d0778349e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:30:28 +0000 Subject: [PATCH 11/30] chore: generate --- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 900579f036..9ab74ca341 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -213,7 +213,7 @@ export const layer: Layer.Layer< return true }) - const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": yield* status.set(ctx.sessionID, { type: "busy" }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 1941a6704e..5168b80b56 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -649,10 +649,10 @@ export const layer: Layer.Layer = return input.partID }) - const updatePartDelta = Effect.fnUntraced(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID field: string delta: string }) { From 9b6c3971712baaecacb1d09eb21c4d6f77955622 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 17 Apr 2026 20:13:25 +0000 Subject: [PATCH 12/30] release: v1.4.10 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 8d83d8ab47..6e8845c4f2 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -268,7 +268,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -297,7 +297,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -313,7 +313,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.9", + "version": "1.4.10", "bin": { "opencode": "./bin/opencode", }, @@ -458,7 +458,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -493,7 +493,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -508,7 +508,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "1.4.9", + "version": "1.4.10", "bin": { "opencode": "./bin/opencode", }, @@ -532,7 +532,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -567,7 +567,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -616,7 +616,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 0f4ae4228b..7206bd5394 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.9", + "version": "1.4.10", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 63b6a5c414..2dbd630163 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e5351c1a87..323ee013df 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.9", + "version": "1.4.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index ef84eae470..bef6f53e08 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.9", + "version": "1.4.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 439beb15c8..5b56f5678d 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index b698a08896..dbf63e8500 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d9d24bd32..97c8713a2b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 02f1ad83ca..914978d7da 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.9", + "version": "1.4.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ffcc975d1b..55b3673739 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.9" +version = "1.4.10" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 2ac49a9228..1dc1f32bb1 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.9", + "version": "1.4.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2f56b74775..7d24d7ca31 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.9", + "version": "1.4.10", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a1aa6470dc..cc1f24b68e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 27d9188151..3a6d13680e 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 383b26d6ed..615cd42c00 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.9", + "version": "1.4.10", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 75974ed39e..eb23c2036f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 525cf20935..e756c33fca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.9", + "version": "1.4.10", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index d3a2a25e4c..0c4a0c6480 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.9", + "version": "1.4.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index a3ed76c883..83d00c987a 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.9", + "version": "1.4.10", "publisher": "sst-dev", "repository": { "type": "git", From b708e8431ec3fcda2a53a3c3e3b8608e5edfb16d Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 17 Apr 2026 15:56:52 -0400 Subject: [PATCH 13/30] docs(opencode): annotate plugin loader flow (#23160) --- packages/opencode/src/plugin/loader.ts | 46 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 0245d311e0..f17673f36e 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin" import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { + // A normalized plugin declaration derived from config before any filesystem or npm work happens. export type Plan = { spec: string options: ConfigPlugin.Options | undefined deprecated: boolean } + + // A plugin that has been resolved to a concrete target and entrypoint on disk. export type Resolved = Plan & { source: PluginSource target: string entry: string pkg?: PluginPackage } + + // A plugin target we could inspect, but which does not expose the requested kind of entrypoint. export type Missing = Plan & { source: PluginSource target: string pkg?: PluginPackage message: string } + + // A resolved plugin whose module has been imported successfully. export type Loaded = Resolved & { mod: Record } type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Report = { + // Called before each attempt so callers can log initial load attempts and retries uniformly. start?: (candidate: Candidate, retry: boolean) => void + // Called when the package exists but does not provide the requested entrypoint. missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + // Called for operational failures such as install, compatibility, or dynamic import errors. error?: ( candidate: Candidate, retry: boolean, @@ -46,19 +56,25 @@ export namespace PluginLoader { ) => void } + // Normalize a config item into the loader's internal representation. function plan(item: ConfigPlugin.Spec): Plan { const spec = ConfigPlugin.pluginSpecifier(item) return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } } + // Resolve a configured plugin into a concrete entrypoint that can later be imported. + // + // The stages here intentionally separate install/target resolution, entrypoint detection, + // and compatibility checks so callers can report the exact reason a plugin was skipped. export async function resolve( plan: Plan, kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } > { + // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" try { target = await resolvePluginTarget(plan.spec) @@ -67,6 +83,7 @@ export namespace PluginLoader { } if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } + // Then inspect the target for the requested server/tui entrypoint. let base try { base = await createPluginEntry(plan.spec, target, kind) @@ -86,6 +103,8 @@ export namespace PluginLoader { }, } + // npm plugins can declare which opencode versions they support; file plugins are treated + // as local development code and skip this compatibility gate. if (base.source === "npm") { try { await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) @@ -96,6 +115,7 @@ export namespace PluginLoader { return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } } + // Import the resolved module only after all earlier validation has succeeded. export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { let mod try { @@ -107,6 +127,8 @@ export namespace PluginLoader { return { ok: true, value: { ...row, mod } } } + // Run one candidate through the full pipeline: resolve, optionally surface a missing entry, + // import the module, and finally let the caller transform the loaded plugin into any result type. async function attempt( candidate: Candidate, kind: PluginKind, @@ -116,11 +138,17 @@ export namespace PluginLoader { report: Report | undefined, ): Promise { const plan = candidate.plan + + // Deprecated plugin packages are silently ignored because they are now built in. if (plan.deprecated) return + report?.start?.(candidate, retry) + const resolved = await resolve(plan, kind) if (!resolved.ok) { if (resolved.stage === "missing") { + // Missing entrypoints are handled separately so callers can still inspect package metadata, + // for example to load theme files from a tui plugin package that has no code entrypoint. if (missing) { const value = await missing(resolved.value, candidate.origin, retry) if (value !== undefined) return value @@ -131,11 +159,15 @@ export namespace PluginLoader { report?.error?.(candidate, retry, resolved.stage, resolved.error) return } + const loaded = await load(resolved.value) if (!loaded.ok) { report?.error?.(candidate, retry, "load", loaded.error, resolved.value) return } + + // The default behavior is to return the successfully loaded plugin as-is, but callers can + // provide a finisher to adapt the result into a more specific runtime shape. if (!finish) return loaded.value as R return finish(loaded.value, candidate.origin, retry) } @@ -149,6 +181,11 @@ export namespace PluginLoader { report?: Report } + // Resolve and load all configured plugins in parallel. + // + // If `wait` is provided, file-based plugins that initially failed are retried once after the + // caller finishes preparing dependencies. This supports local plugins that depend on an install + // step happening elsewhere before their entrypoint becomes loadable. export async function loadExternal(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const list: Array> = [] @@ -160,6 +197,9 @@ export namespace PluginLoader { let deps: Promise | undefined for (let i = 0; i < candidates.length; i++) { if (out[i] !== undefined) continue + + // Only local file plugins are retried. npm plugins already attempted installation during + // the first pass, while file plugins may need the caller's dependency preparation to finish. const candidate = candidates[i] if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue deps ??= input.wait() @@ -167,6 +207,8 @@ export namespace PluginLoader { out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) } } + + // Drop skipped/failed entries while preserving the successful result order. const ready: R[] = [] for (const item of out) if (item !== undefined) ready.push(item) return ready From c2061c6bbfa83147792ab9aec251cf193ccb6b0d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 19:58:18 +0000 Subject: [PATCH 14/30] chore: generate --- packages/opencode/src/plugin/loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index f17673f36e..e61612561b 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -71,8 +71,8 @@ export namespace PluginLoader { kind: PluginKind, ): Promise< | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } > { // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" From 984f5ed6eb0cbf3048d8c1dbd8f643f5a01d2501 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:15:24 -0400 Subject: [PATCH 15/30] fix(opencode): skip share sync for unshared sessions (#23159) --- packages/opencode/src/share/share-next.ts | 41 ++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 3484d5da76..2622f4f7f0 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({ export type Share = typeof ShareSchema.Type type State = { - queue: Map }> + queue: Map> scope: Scope.Closeable + shared: Map } type Data = @@ -118,17 +119,20 @@ export const layer = Layer.effect( function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { if (disabled) return + const share = yield* getCached(sessionID) + if (!share) 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) + existing.set(key(item), item) } return } const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, { data: next }) + s.queue.set(sessionID, next) yield* flush(sessionID).pipe( Effect.delay(1000), Effect.catchCause((cause) => @@ -143,13 +147,14 @@ export const layer = Layer.effect( const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make() } + const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } yield* Effect.addFinalizer(() => Scope.close(cache.scope, Exit.void).pipe( Effect.andThen( Effect.sync(() => { cache.queue.clear() + cache.shared.clear() }), ), ), @@ -227,6 +232,18 @@ export const layer = Layer.effect( return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) + const getCached = Effect.fnUntraced(function* (sessionID: SessionID) { + const s = yield* InstanceState.get(state) + if (s.shared.has(sessionID)) { + const cached = s.shared.get(sessionID) + return cached === null ? undefined : cached + } + + const share = yield* get(sessionID) + s.shared.set(sessionID, share ?? null) + return share + }) + const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { if (disabled) return const s = yield* InstanceState.get(state) @@ -235,13 +252,13 @@ export const layer = Layer.effect( s.queue.delete(sessionID) - const share = yield* get(sessionID) + const share = yield* getCached(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()) }), + HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }), Effect.flatMap((r) => http.execute(r)), ) @@ -307,6 +324,7 @@ export const layer = Layer.effect( .run(), ) const s = yield* InstanceState.get(state) + s.shared.set(sessionID, result) yield* full(sessionID).pipe( Effect.catchCause((cause) => Effect.sync(() => { @@ -321,8 +339,13 @@ export const layer = Layer.effect( 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 s = yield* InstanceState.get(state) + const share = yield* getCached(sessionID) + if (!share) { + s.shared.delete(sessionID) + s.queue.delete(sessionID) + return + } const req = yield* request() yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( @@ -332,6 +355,8 @@ export const layer = Layer.effect( ) yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + s.shared.delete(sessionID) + s.queue.delete(sessionID) }) return Service.of({ init, url, request, create, remove }) From 6af8ab0df201e245fac503aa8822750662ebf6b9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:20:57 -0400 Subject: [PATCH 16/30] docs(http-api): refresh bridge inventory and clarify Schema.Class vs Struct (#23164) --- packages/opencode/specs/effect/http-api.md | 77 ++++++++++++++++------ 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 71b50250ed..09cb86a000 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -189,10 +189,46 @@ Ordering for a route-group migration: 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 +- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below) +- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging + +### Schema.Class vs Schema.Struct + +The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**. + +**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`: + +```ts +export class Info extends Schema.Class("FooConfig")({ ... }) { + static readonly zod = zod(this) +} +``` + +**Schema.Struct** stays anonymous and is inlined everywhere it is referenced: + +```ts +export const Info = Schema.Struct({ ... }).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export type Info = Schema.Schema.Type +``` + +When to use each: + +- Use **Schema.Class** when: + - the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte) + - the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name) +- Use **Schema.Struct** when: + - the type is only used as a nested field inside another named schema + - the original Zod was anonymous and promoting it would bloat SDK types with no import value + +Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape. + +Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior: + +```ts +export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) +``` Temporary exception: @@ -365,17 +401,16 @@ Current instance route inventory: 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` +- `provider` - `bridged` + endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback` +- `config` - `bridged` (partial) + bridged endpoint: `GET /config/providers` later endpoint: `GET /config` defer `PATCH /config` for now -- `project` - `later` - best small reads: `GET /project`, `GET /project/current` +- `project` - `bridged` (partial) + bridged endpoints: `GET /project`, `GET /project/current` defer git-init mutation first -- `workspace` - `later` +- `workspace` - `next` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` defer create/remove mutations first - `file` - `later` @@ -393,12 +428,12 @@ Current instance route inventory: - `tui` - `defer` queue-style UI bridge, weak early `HttpApi` fit -Recommended near-term sequence after the first spike: +Recommended near-term sequence: -1. `provider` auth read endpoint -2. `config` providers read endpoint -3. `project` read endpoints -4. `workspace` read endpoints +1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`) +2. `config` full read endpoint (`GET /config`) +3. `file` JSON read endpoints +4. `mcp` JSON read endpoints ## Checklist @@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike: - [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 +- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations) +- [x] port `config` providers read endpoint +- [x] port `project` read endpoints (`GET /project`, `GET /project/current`) +- [ ] port `workspace` read endpoints +- [ ] port `GET /config` full read endpoint +- [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default ## Rule of thumb From 11fa257549a1094b43d25f014e54557ed6aab660 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:25:49 -0400 Subject: [PATCH 17/30] refactor(config): migrate mcp schemas to Effect Schema.Class (#23163) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/mcp.ts | 118 +++++++++--------- .../src/server/routes/instance/mcp.ts | 2 +- 3 files changed, 58 insertions(+), 64 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 459f76961a..258500d7bd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -178,7 +178,7 @@ export const Info = z .record( z.string(), z.union([ - ConfigMCP.Info, + ConfigMCP.Info.zod, z .object({ enabled: z.boolean(), diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 5036cd6e4f..8b77bc4c28 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,68 +1,62 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Local = 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", - }) +export class Local extends Schema.Class("McpLocalConfig")({ + type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), + command: Schema.mutable(Schema.Array(Schema.String)).annotate({ + description: "Command and arguments to run the MCP server", + }), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: "Environment variables to set when running the MCP server", + }), + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable or disable the MCP server on startup", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + }), +}) { + static readonly zod = zod(this) +} -export const OAuth = 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 OAuth = z.infer +export class OAuth extends Schema.Class("McpOAuthConfig")({ + clientId: Schema.optional(Schema.String).annotate({ + description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", + }), + clientSecret: Schema.optional(Schema.String).annotate({ + description: "OAuth client secret (if required by the authorization server)", + }), + scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }), + redirectUri: Schema.optional(Schema.String).annotate({ + description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + }), +}) { + static readonly zod = zod(this) +} -export const Remote = 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([OAuth, 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 class Remote extends Schema.Class("McpRemoteConfig")({ + type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), + url: Schema.String.annotate({ description: "URL of the remote MCP server" }), + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable or disable the MCP server on startup", + }), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: "Headers to send with the request", + }), + oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ + description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + }), +}) { + static readonly zod = zod(this) +} -export const Info = z.discriminatedUnion("type", [Local, Remote]) -export type Info = z.infer +export const Info = Schema.Union([Local, Remote]) + .annotate({ discriminator: "type" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index 197185bde0..b42cfb5314 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -54,7 +54,7 @@ export const McpRoutes = lazy(() => "json", z.object({ name: z.string(), - config: ConfigMCP.Info, + config: ConfigMCP.Info.zod, }), ), async (c) => { From 54435325b6adf292d720885622f1282eaed98c92 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:26:43 +0000 Subject: [PATCH 18/30] chore: generate --- packages/sdk/openapi.json | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3b811f2fa9..71e78307cd 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11428,13 +11428,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "command"], - "additionalProperties": false + "required": ["type", "command"] }, "McpOAuthConfig": { "type": "object", @@ -11455,8 +11452,7 @@ "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - }, - "additionalProperties": false + } }, "McpRemoteConfig": { "type": "object", @@ -11498,13 +11494,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "url"], - "additionalProperties": false + "required": ["type", "url"] }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", From 650a13a6908e23c30e07329f9f3ec816d7a6f2a7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:34:47 -0400 Subject: [PATCH 19/30] refactor(config): migrate lsp schemas to Effect Schema (#23167) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/lsp.ts | 58 ++++++++++++++------------ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 258500d7bd..039b0598da 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -189,7 +189,7 @@ export const Info = z .optional() .describe("MCP (Model Context Protocol) server configurations"), formatter: ConfigFormatter.Info.optional(), - lsp: ConfigLSP.Info.optional(), + lsp: ConfigLSP.Info.zod.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: ConfigPermission.Info.optional(), diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 5530a5be56..94a6d4bc56 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,37 +1,43 @@ export * as ConfigLSP from "./lsp" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import * as LSPServer from "../lsp/server" -export const Disabled = z.object({ - disabled: z.literal(true), -}) +export const Disabled = Schema.Struct({ + disabled: Schema.Literal(true), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Entry = z.union([ +export const Entry = Schema.Union([ Disabled, - 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(), + Schema.Struct({ + command: Schema.mutable(Schema.Array(Schema.String)), + extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + disabled: Schema.optional(Schema.Boolean), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), -]) +]).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine( - (data) => { - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( + withStatics((s) => ({ + zod: zod(s).refine( + (data) => { + if (typeof data === "boolean") return true + const serverIds = new Set(Object.values(LSPServer).map((server) => server.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.", - }, + 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.", + }, + ), + })), ) -export type Info = z.infer +export type Info = Schema.Schema.Type From d11268ece76d132108feda4bb91f0caacac51719 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:35:42 -0400 Subject: [PATCH 20/30] refactor(config): migrate permission Action/Object/Rule leaves to Effect Schema (#23168) --- packages/opencode/src/config/permission.ts | 63 +++++++++++----------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index af01f6f2a3..7cfbaec01f 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,5 +1,8 @@ export * as ConfigPermission from "./permission" +import { Schema } from "effect" import z from "zod" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const permissionPreprocess = (val: unknown) => { if (typeof val === "object" && val !== null && !Array.isArray(val)) { @@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => { return val } -export const Action = z.enum(["ask", "allow", "deny"]).meta({ - ref: "PermissionActionConfig", -}) -export type Action = z.infer +export const Action = Schema.Literals(["ask", "allow", "deny"]) + .annotate({ identifier: "PermissionActionConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Action = Schema.Schema.Type -export const Object = z.record(z.string(), Action).meta({ - ref: "PermissionObjectConfig", -}) -export type Object = z.infer +export const Object = Schema.Record(Schema.String, Action) + .annotate({ identifier: "PermissionObjectConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Object = Schema.Schema.Type -export const Rule = z.union([Action, Object]).meta({ - ref: "PermissionRuleConfig", -}) -export type Rule = z.infer +export const Rule = Schema.Union([Action, Object]) + .annotate({ identifier: "PermissionRuleConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Rule = Schema.Schema.Type const transform = (x: unknown): Record => { if (typeof x === "string") return { "*": x as Action } @@ -41,25 +44,25 @@ export const Info = z z .object({ __originalKeys: z.string().array().optional(), - read: Rule.optional(), - edit: Rule.optional(), - glob: Rule.optional(), - grep: Rule.optional(), - list: Rule.optional(), - bash: Rule.optional(), - task: Rule.optional(), - external_directory: Rule.optional(), - todowrite: Action.optional(), - question: Action.optional(), - webfetch: Action.optional(), - websearch: Action.optional(), - codesearch: Action.optional(), - lsp: Rule.optional(), - doom_loop: Action.optional(), - skill: Rule.optional(), + read: Rule.zod.optional(), + edit: Rule.zod.optional(), + glob: Rule.zod.optional(), + grep: Rule.zod.optional(), + list: Rule.zod.optional(), + bash: Rule.zod.optional(), + task: Rule.zod.optional(), + external_directory: Rule.zod.optional(), + todowrite: Action.zod.optional(), + question: Action.zod.optional(), + webfetch: Action.zod.optional(), + websearch: Action.zod.optional(), + codesearch: Action.zod.optional(), + lsp: Rule.zod.optional(), + doom_loop: Action.zod.optional(), + skill: Rule.zod.optional(), }) - .catchall(Rule) - .or(Action), + .catchall(Rule.zod) + .or(Action.zod), ) .transform(transform) .meta({ From 47f553f9ba9d98056fa5a20fd1a4e6d4d63df016 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 16:39:34 -0400 Subject: [PATCH 21/30] fix(core): more explicit routing to fix workspace instance issue (#23171) --- .../src/server/routes/control/index.ts | 2 -- .../src/server/routes/instance/index.ts | 5 ++--- packages/opencode/src/server/server.ts | 19 +++++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts index 3fd60636ff..60883274a5 100644 --- a/packages/opencode/src/server/routes/control/index.ts +++ b/packages/opencode/src/server/routes/control/index.ts @@ -7,7 +7,6 @@ import { Hono } from "hono" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import z from "zod" import { errors } from "../../error" -import { WorkspaceRoutes } from "./workspace" export function ControlPlaneRoutes(): Hono { const app = new Hono() @@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono { return c.json(true) }, ) - .route("/experimental/workspace", WorkspaceRoutes()) } diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index c0339fded7..ddb7e335ad 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -15,7 +15,6 @@ import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" -import { WorkspaceID } from "@/control-plane/schema" import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -30,8 +29,8 @@ import { SyncRoutes } from "./sync" import { AppRuntime } from "@/effect/app-runtime" import { InstanceMiddleware } from "./middleware" -export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: WorkspaceID): Hono => { - const app = new Hono().use(InstanceMiddleware(workspaceID)) +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { + const app = new Hono() if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f608c2b732..8b1f1aee10 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control" import { UIRoutes } from "./routes/ui" import { GlobalRoutes } from "./routes/global" import { WorkspaceRouterMiddleware } from "./workspace" +import { InstanceMiddleware } from "./routes/instance/middleware" +import { WorkspaceRoutes } from "./routes/control/workspace" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -45,14 +47,9 @@ function create(opts: { cors?: string[] }) { if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app + .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route( - "/", - InstanceRoutes( - runtime.upgradeWebSocket, - Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined, - ), - ), + .route("/", InstanceRoutes(runtime.upgradeWebSocket)), runtime, } } @@ -60,7 +57,13 @@ function create(opts: { cors?: string[] }) { return { app: app .route("/", ControlPlaneRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + .route( + "/", + new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)), + ) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, From e7686dbd643241f7e4ebf4a5e37e5ef33ad7797f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:46:05 -0400 Subject: [PATCH 22/30] feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173) --- packages/opencode/src/config/lsp.ts | 38 ++++---- packages/opencode/src/util/effect-zod.ts | 23 ++++- packages/opencode/test/config/lsp.test.ts | 87 +++++++++++++++++++ .../opencode/test/util/effect-zod.test.ts | 61 +++++++++++++ 4 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/test/config/lsp.test.ts diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 94a6d4bc56..6d61c6b8e3 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -20,24 +20,26 @@ export const Entry = Schema.Union([ }), ]).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( - withStatics((s) => ({ - zod: zod(s).refine( - (data) => { - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((server) => server.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.", - }, - ), - })), +/** + * For custom (non-builtin) LSP server entries, `extensions` is required so the + * client knows which files the server should attach to. Builtin server IDs and + * explicitly disabled entries are exempt. + */ +export const requiresExtensionsForCustomServers = Schema.makeFilter>>( + (data) => { + if (typeof data === "boolean") return undefined + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const ok = Object.entries(data).every(([id, config]) => { + if ("disabled" in config && config.disabled) return true + if (serverIds.has(id)) return true + return "extensions" in config && Boolean(config.extensions) + }) + return ok ? undefined : "For custom LSP servers, 'extensions' array is required." + }, ) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) + .check(requiresExtensionsForCustomServers) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 6e99fd4688..c3240deaac 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -16,13 +16,34 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined if (override) return override - const out = body(ast) + let out = body(ast) + for (const check of ast.checks ?? []) { + out = applyCheck(out, check, ast) + } const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) const next = desc ? out.describe(desc) : out return ref ? next.meta({ ref }) : next } +function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check, ast: SchemaAST.AST): z.ZodTypeAny { + if (check._tag === "FilterGroup") { + return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out) + } + return out.superRefine((value, ctx) => { + const issue = check.run(value, ast, {} as any) + if (!issue) return + const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed" + ctx.addIssue({ code: "custom", message }) + }) +} + +function issueMessage(issue: any): string | undefined { + if (typeof issue?.annotations?.message === "string") return issue.annotations.message + if (typeof issue?.message === "string") return issue.message + return undefined +} + function body(ast: SchemaAST.AST): z.ZodTypeAny { if (SchemaAST.isOptional(ast)) return opt(ast) diff --git a/packages/opencode/test/config/lsp.test.ts b/packages/opencode/test/config/lsp.test.ts new file mode 100644 index 0000000000..1d24fe124d --- /dev/null +++ b/packages/opencode/test/config/lsp.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigLSP } from "../../src/config/lsp" + +// The LSP config refinement enforces: any custom (non-builtin) LSP server +// entry must declare an `extensions` array so the client knows which files +// the server should attach to. Builtin server IDs and explicitly disabled +// entries are exempt. +// +// Both validation paths must honor this rule: +// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer) +// - `ConfigLSP.Info.zod.parse(...)` (derived Zod) +// +// `typescript` is a builtin server id (see src/lsp/server.ts). +describe("ConfigLSP.Info refinement", () => { + const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info) + + describe("accepted inputs", () => { + test("true and false pass (top-level toggle)", () => { + expect(decodeEffect(true)).toBe(true) + expect(decodeEffect(false)).toBe(false) + expect(ConfigLSP.Info.zod.parse(true)).toBe(true) + expect(ConfigLSP.Info.zod.parse(false)).toBe(false) + }) + + test("builtin server with no extensions passes", () => { + const input = { typescript: { command: ["typescript-language-server", "--stdio"] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server WITH extensions passes", () => { + const input = { + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("disabled custom server passes (no extensions needed)", () => { + const input = { "my-lsp": { disabled: true as const } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("mix of builtin and custom with extensions passes", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + }) + + describe("rejected inputs", () => { + const expectedMessage = "For custom LSP servers, 'extensions' array is required." + + test("custom server WITHOUT extensions fails via Effect decode", () => { + expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage) + }) + + test("custom server WITHOUT extensions fails via derived Zod", () => { + const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } }) + expect(result.success).toBe(false) + expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true) + }) + + test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => { + // Boolean(['']) is true, so a non-empty array of strings is fine. + // Boolean([]) is also true in JS, so empty arrays are accepted by the + // refinement. This test documents current behavior. + const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server without extensions mixed with a valid builtin still fails", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"] }, + } + expect(() => decodeEffect(input)).toThrow(expectedMessage) + expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 7f7249514d..2a5cc45f8d 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -186,4 +186,65 @@ describe("util.effect-zod", () => { const schema = json(zod(Parent)) as any expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) }) + + describe("Schema.check translation", () => { + test("filter returning string triggers refinement with that message", () => { + const isEven = Schema.makeFilter((n: number) => + n % 2 === 0 ? undefined : "expected an even number", + ) + const schema = zod(Schema.Number.check(isEven)) + + expect(schema.parse(4)).toBe(4) + const result = schema.safeParse(3) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("expected an even number") + }) + + test("filter returning false triggers refinement with fallback message", () => { + const nonEmpty = Schema.makeFilter((s: string) => s.length > 0) + const schema = zod(Schema.String.check(nonEmpty)) + + expect(schema.parse("hi")).toBe("hi") + const result = schema.safeParse("") + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toMatch(/./) + }) + + test("filter returning undefined passes validation", () => { + const alwaysOk = Schema.makeFilter(() => undefined) + const schema = zod(Schema.Number.check(alwaysOk)) + + expect(schema.parse(42)).toBe(42) + }) + + test("annotations.message on the filter is used when filter returns false", () => { + const positive = Schema.makeFilter( + (n: number) => n > 0, + { message: "must be positive" }, + ) + const schema = zod(Schema.Number.check(positive)) + + const result = schema.safeParse(-1) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("must be positive") + }) + + test("cross-field check on a record flags missing key", () => { + const hasKey = Schema.makeFilter( + (data: Record) => + "required" in data ? undefined : "missing 'required' key", + ) + const schema = zod( + Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey), + ) + + expect(schema.parse({ required: { enabled: true } })).toEqual({ + required: { enabled: true }, + }) + + const result = schema.safeParse({ other: { enabled: true } }) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("missing 'required' key") + }) + }) }) From dc16013b4f0aa5e4d3eb433046965e1274da3990 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:47:05 +0000 Subject: [PATCH 23/30] chore: generate --- packages/opencode/src/config/lsp.ts | 24 +++++++++---------- .../opencode/test/util/effect-zod.test.ts | 18 ++++---------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 6d61c6b8e3..1cf93177e4 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -25,18 +25,18 @@ export const Entry = Schema.Union([ * client knows which files the server should attach to. Builtin server IDs and * explicitly disabled entries are exempt. */ -export const requiresExtensionsForCustomServers = Schema.makeFilter>>( - (data) => { - if (typeof data === "boolean") return undefined - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) - const ok = Object.entries(data).every(([id, config]) => { - if ("disabled" in config && config.disabled) return true - if (serverIds.has(id)) return true - return "extensions" in config && Boolean(config.extensions) - }) - return ok ? undefined : "For custom LSP servers, 'extensions' array is required." - }, -) +export const requiresExtensionsForCustomServers = Schema.makeFilter< + boolean | Record> +>((data) => { + if (typeof data === "boolean") return undefined + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const ok = Object.entries(data).every(([id, config]) => { + if ("disabled" in config && config.disabled) return true + if (serverIds.has(id)) return true + return "extensions" in config && Boolean(config.extensions) + }) + return ok ? undefined : "For custom LSP servers, 'extensions' array is required." +}) export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) .check(requiresExtensionsForCustomServers) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 2a5cc45f8d..1c84c96f3a 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -189,9 +189,7 @@ describe("util.effect-zod", () => { describe("Schema.check translation", () => { test("filter returning string triggers refinement with that message", () => { - const isEven = Schema.makeFilter((n: number) => - n % 2 === 0 ? undefined : "expected an even number", - ) + const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number")) const schema = zod(Schema.Number.check(isEven)) expect(schema.parse(4)).toBe(4) @@ -218,10 +216,7 @@ describe("util.effect-zod", () => { }) test("annotations.message on the filter is used when filter returns false", () => { - const positive = Schema.makeFilter( - (n: number) => n > 0, - { message: "must be positive" }, - ) + const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" }) const schema = zod(Schema.Number.check(positive)) const result = schema.safeParse(-1) @@ -230,13 +225,10 @@ describe("util.effect-zod", () => { }) test("cross-field check on a record flags missing key", () => { - const hasKey = Schema.makeFilter( - (data: Record) => - "required" in data ? undefined : "missing 'required' key", - ) - const schema = zod( - Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey), + const hasKey = Schema.makeFilter((data: Record) => + "required" in data ? undefined : "missing 'required' key", ) + const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey)) expect(schema.parse({ required: { enabled: true } })).toEqual({ required: { enabled: true }, From b1307d5c2a41c12d6696b31fe46bea7882993753 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:49:36 -0400 Subject: [PATCH 24/30] refactor(config): migrate skills, formatter, console-state to Effect Schema (#23162) --- packages/opencode/src/config/config.ts | 4 ++-- packages/opencode/src/config/console-state.ts | 21 +++++++++--------- packages/opencode/src/config/formatter.ts | 22 +++++++++++-------- packages/opencode/src/config/skills.ts | 21 ++++++++++-------- .../server/routes/instance/experimental.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +++++----- 6 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 039b0598da..261c5acab4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -100,7 +100,7 @@ export const Info = z .record(z.string(), ConfigCommand.Info) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"), + skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), watcher: z .object({ ignore: z.array(z.string()).optional(), @@ -188,7 +188,7 @@ export const Info = z ) .optional() .describe("MCP (Model Context Protocol) server configurations"), - formatter: ConfigFormatter.Info.optional(), + formatter: ConfigFormatter.Info.zod.optional(), lsp: ConfigLSP.Info.zod.optional(), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index cf96a4e305..08668afe4e 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,15 +1,16 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" -export const ConsoleState = z.object({ - consoleManagedProviders: z.array(z.string()), - activeOrgName: z.string().optional(), - switchableOrgCount: z.number().int().nonnegative(), -}) +export class ConsoleState extends Schema.Class("ConsoleState")({ + consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), + activeOrgName: Schema.optional(Schema.String), + switchableOrgCount: Schema.Number, +}) { + static readonly zod = zod(this) +} -export type ConsoleState = z.infer - -export const emptyConsoleState: ConsoleState = { +export const emptyConsoleState: ConsoleState = ConsoleState.make({ consoleManagedProviders: [], activeOrgName: undefined, switchableOrgCount: 0, -} +}) diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 93b87f0281..8c1f09a247 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,13 +1,17 @@ export * as ConfigFormatter from "./formatter" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Entry = 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(), -}) +export const Entry = Schema.Struct({ + disabled: Schema.optional(Schema.Boolean), + command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), + extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]) -export type Info = z.infer +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index 38cbf99e7d..f29d854f50 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,13 +1,16 @@ -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Info = 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 const Info = Schema.Struct({ + paths: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Additional paths to skill folders", + }), + urls: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", + }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = z.infer +export type Info = Schema.Schema.Type export * as ConfigSkills from "./skills" diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index f7ecc8255b..d5659346ef 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -49,7 +49,7 @@ export const ExperimentalRoutes = lazy(() => description: "Active Console provider metadata", content: { "application/json": { - schema: resolver(ConsoleState), + schema: resolver(ConsoleState.zod), }, }, }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5698cba54f..72a383a608 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1807,6 +1807,12 @@ export type Provider = { } } +export type ConsoleState = { + consoleManagedProviders: Array + activeOrgName?: string + switchableOrgCount: number +} + export type ToolIds = Array export type ToolListItem = { @@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata */ - 200: { - consoleManagedProviders: Array - activeOrgName?: string - switchableOrgCount: number - } + 200: ConsoleState } export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] From ed0f0225025336ad84c04210850fcd7bfb38f221 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 20:50:37 +0000 Subject: [PATCH 25/30] chore: generate --- packages/sdk/openapi.json | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71e78307cd..c3fd00356c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1607,24 +1607,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "consoleManagedProviders": { - "type": "array", - "items": { - "type": "string" - } - }, - "activeOrgName": { - "type": "string" - }, - "switchableOrgCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["consoleManagedProviders", "switchableOrgCount"] + "$ref": "#/components/schemas/ConsoleState" } } } @@ -12359,6 +12342,24 @@ }, "required": ["id", "name", "source", "env", "options", "models"] }, + "ConsoleState": { + "type": "object", + "properties": { + "consoleManagedProviders": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeOrgName": { + "type": "string" + }, + "switchableOrgCount": { + "type": "number" + } + }, + "required": ["consoleManagedProviders", "switchableOrgCount"] + }, "ToolIDs": { "type": "array", "items": { From 999d8651aab7f9531539f686f685375fcbb19437 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:52:40 -0400 Subject: [PATCH 26/30] feat(server): wrap remaining route handlers in request spans (#23169) --- .../src/server/routes/instance/config.ts | 13 +- .../server/routes/instance/experimental.ts | 170 ++++---- .../src/server/routes/instance/file.ts | 88 ++-- .../src/server/routes/instance/index.ts | 87 ++-- .../src/server/routes/instance/mcp.ts | 80 ++-- .../src/server/routes/instance/permission.ts | 37 +- .../src/server/routes/instance/project.ts | 18 +- .../src/server/routes/instance/provider.ts | 117 +++-- .../src/server/routes/instance/pty.ts | 69 ++- .../src/server/routes/instance/question.ts | 47 +- .../src/server/routes/instance/session.ts | 411 +++++++++--------- .../src/server/routes/instance/tui.ts | 8 +- packages/opencode/src/server/workspace.ts | 5 +- 13 files changed, 550 insertions(+), 600 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 235f5682e2..7f368cd31c 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -5,7 +5,6 @@ import { Config } from "@/config" import { Provider } from "@/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest } from "./trace" export const ConfigRoutes = lazy(() => @@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) - return c.json(config) - }, + async (c) => + jsonRequest("ConfigRoutes.update", c, function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return config + }), ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index d5659346ef..9c86494987 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -12,11 +12,11 @@ import { Config } from "@/config" import { ConsoleState } from "@/config/console-state" import { Account } from "@/account/account" import { AccountID, OrgID } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect, Option } from "effect" import { Agent } from "@/agent/agent" +import { jsonRequest, runRequest } from "./trace" const ConsoleOrgOption = z.object({ accountID: z.string(), @@ -55,22 +55,18 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const config = yield* Config.Service - const account = yield* Account.Service - const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { - concurrency: "unbounded", - }) - return { - ...state, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }), - ) - return c.json(result) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.get", c, function* () { + const config = yield* Config.Service + const account = yield* Account.Service + const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { + concurrency: "unbounded", + }) + return { + ...state, + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }), ) .get( "/console/orgs", @@ -89,28 +85,25 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const orgs = await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { - concurrency: "unbounded", - }) - const info = Option.getOrUndefined(active) - return groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ) - }), - ) - return c.json({ orgs }) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () { + const account = yield* Account.Service + const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { + concurrency: "unbounded", + }) + const info = Option.getOrUndefined(active) + const orgs = groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ) + return { orgs } + }), ) .post( "/console/switch", @@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", ConsoleSwitchBody), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () { + const body = c.req.valid("json") + const account = yield* Account.Service + yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) + return true + }), ) .get( "/tool/ids", @@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() => ...errors(400), }, }), - async (c) => { - const ids = await AppRuntime.runPromise( - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - return yield* registry.ids() - }), - ) - return c.json(ids) - }, + async (c) => + jsonRequest("ExperimentalRoutes.tool.ids", c, function* () { + const registry = yield* ToolRegistry.Service + return yield* registry.ids() + }), ) .get( "/tool", @@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() => ), async (c) => { const { provider, model } = c.req.valid("query") - const tools = await AppRuntime.runPromise( + const tools = await runRequest( + "ExperimentalRoutes.tool.list", + c, Effect.gen(function* () { const agents = yield* Agent.Service const registry = yield* ToolRegistry.Service @@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.CreateInput.optional()), - async (c) => { - const body = c.req.valid("json") - const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body))) - return c.json(worktree) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.create", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + return yield* svc.create(body) + }), ) .get( "/worktree", @@ -272,10 +261,11 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id))) - return c.json(sandboxes) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.list", c, function* () { + const svc = yield* Project.Service + return yield* svc.sandboxes(Instance.project.id) + }), ) .delete( "/worktree", @@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.RemoveInput), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) - await AppRuntime.runPromise( - Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () { + const body = c.req.valid("json") + const worktree = yield* Worktree.Service + const project = yield* Project.Service + yield* worktree.remove(body) + yield* project.removeSandbox(Instance.project.id, body.directory) + return true + }), ) .post( "/worktree/reset", @@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() => }, }), validator("json", Worktree.ResetInput), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body))) - return c.json(true) - }, + async (c) => + jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () { + const body = c.req.valid("json") + const svc = yield* Worktree.Service + yield* svc.reset(body) + return true + }), ) .get( "/session", @@ -406,15 +399,10 @@ export const ExperimentalRoutes = lazy(() => }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - return yield* mcp.resources() - }), - ), - ) - }, + async (c) => + jsonRequest("ExperimentalRoutes.resource.list", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index a82e5687d8..bbef679a85 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -1,13 +1,12 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import { Effect } from "effect" import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" import { LSP } from "@/lsp" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" export const FileRoutes = lazy(() => new Hono() @@ -34,13 +33,13 @@ export const FileRoutes = lazy(() => pattern: z.string(), }), ), - async (c) => { - const pattern = c.req.valid("query").pattern - const result = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), - ) - return c.json(result.items) - }, + async (c) => + jsonRequest("FileRoutes.findText", c, function* () { + const pattern = c.req.valid("query").pattern + const svc = yield* Ripgrep.Service + const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 }) + return result.items + }), ) .get( "/find/file", @@ -68,25 +67,17 @@ export const FileRoutes = lazy(() => limit: z.coerce.number().int().min(1).max(200).optional(), }), ), - async (c) => { - const query = c.req.valid("query").query - const dirs = c.req.valid("query").dirs - const type = c.req.valid("query").type - const limit = c.req.valid("query").limit - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => - svc.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }), - ) - }), - ) - return c.json(results) - }, + async (c) => + jsonRequest("FileRoutes.findFile", c, function* () { + const query = c.req.valid("query") + const svc = yield* File.Service + return yield* svc.search({ + query: query.query, + limit: query.limit ?? 10, + dirs: query.dirs !== "false", + type: query.type, + }) + }), ) .get( "/find/symbol", @@ -138,15 +129,11 @@ export const FileRoutes = lazy(() => path: z.string(), }), ), - async (c) => { - const path = c.req.valid("query").path - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.list(path)) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.list", c, function* () { + const svc = yield* File.Service + return yield* svc.list(c.req.valid("query").path) + }), ) .get( "/file/content", @@ -171,15 +158,11 @@ export const FileRoutes = lazy(() => path: z.string(), }), ), - async (c) => { - const path = c.req.valid("query").path - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.read(path)) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.read", c, function* () { + const svc = yield* File.Service + return yield* svc.read(c.req.valid("query").path) + }), ) .get( "/file/status", @@ -198,13 +181,10 @@ export const FileRoutes = lazy(() => }, }, }), - async (c) => { - const content = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.status()) - }), - ) - return c.json(content) - }, + async (c) => + jsonRequest("FileRoutes.status", c, function* () { + const svc = yield* File.Service + return yield* svc.status() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index ddb7e335ad..5cc51d27ab 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -26,8 +26,8 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" -import { AppRuntime } from "@/effect/app-runtime" import { InstanceMiddleware } from "./middleware" +import { jsonRequest } from "./trace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() @@ -141,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { - concurrency: 2, - }) - return { branch, default_branch } - }), - ), - ) - }, + async (c) => + jsonRequest("InstanceRoutes.vcs.get", c, function* () { + const vcs = yield* Vcs.Service + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { + concurrency: 2, + }) + return { branch, default_branch } + }), ) .get( "/vcs/diff", @@ -178,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { mode: Vcs.Mode, }), ), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff(c.req.valid("query").mode) - }), - ), - ) - }, + async (c) => + jsonRequest("InstanceRoutes.vcs.diff", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff(c.req.valid("query").mode) + }), ) .get( "/command", @@ -206,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) - return c.json(commands) - }, + async (c) => + jsonRequest("InstanceRoutes.command.list", c, function* () { + const svc = yield* Command.Service + return yield* svc.list() + }), ) .get( "/agent", @@ -228,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - return c.json(modes) - }, + async (c) => + jsonRequest("InstanceRoutes.agent.list", c, function* () { + const svc = yield* Agent.Service + return yield* svc.list() + }), ) .get( "/skill", @@ -250,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - return c.json(skills) - }, + async (c) => + jsonRequest("InstanceRoutes.skill.list", c, function* () { + const skill = yield* Skill.Service + return yield* skill.all() + }), ) .get( "/lsp", @@ -277,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) - return c.json(items) - }, + async (c) => + jsonRequest("InstanceRoutes.lsp.status", c, function* () { + const lsp = yield* LSP.Service + return yield* lsp.status() + }), ) .get( "/formatter", @@ -299,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) - }, + async (c) => + jsonRequest("InstanceRoutes.formatter.status", c, function* () { + const svc = yield* Format.Service + return yield* svc.status() + }), ) } diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index b42cfb5314..ce4722933b 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -2,12 +2,11 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "@/mcp" -import { Config } from "@/config" import { ConfigMCP } from "@/config/mcp" -import { AppRuntime } from "@/effect/app-runtime" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect } from "effect" +import { jsonRequest, runRequest } from "./trace" export const McpRoutes = lazy(() => new Hono() @@ -28,9 +27,11 @@ export const McpRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) - }, + async (c) => + jsonRequest("McpRoutes.status", c, function* () { + const mcp = yield* MCP.Service + return yield* mcp.status() + }), ) .post( "/", @@ -57,11 +58,13 @@ export const McpRoutes = lazy(() => config: ConfigMCP.Info.zod, }), ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) - return c.json(result.status) - }, + async (c) => + jsonRequest("McpRoutes.add", c, function* () { + const { name, config } = c.req.valid("json") + const mcp = yield* MCP.Service + const result = yield* mcp.add(name, config) + return result.status + }), ) .post( "/:name/auth", @@ -87,7 +90,9 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const result = await AppRuntime.runPromise( + const result = await runRequest( + "McpRoutes.auth.start", + c, Effect.gen(function* () { const mcp = yield* MCP.Service const supports = yield* mcp.supportsOAuth(name) @@ -129,12 +134,13 @@ export const McpRoutes = lazy(() => code: z.string().describe("Authorization code from OAuth callback"), }), ), - async (c) => { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) - return c.json(status) - }, + async (c) => + jsonRequest("McpRoutes.auth.callback", c, function* () { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const mcp = yield* MCP.Service + return yield* mcp.finishAuth(name, code) + }), ) .post( "/:name/auth/authenticate", @@ -156,7 +162,9 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const result = await AppRuntime.runPromise( + const result = await runRequest( + "McpRoutes.auth.authenticate", + c, Effect.gen(function* () { const mcp = yield* MCP.Service const supports = yield* mcp.supportsOAuth(name) @@ -191,11 +199,13 @@ export const McpRoutes = lazy(() => ...errors(404), }, }), - async (c) => { - const name = c.req.param("name") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) - return c.json({ success: true as const }) - }, + async (c) => + jsonRequest("McpRoutes.auth.remove", c, function* () { + const name = c.req.param("name") + const mcp = yield* MCP.Service + yield* mcp.removeAuth(name) + return { success: true as const } + }), ) .post( "/:name/connect", @@ -214,11 +224,13 @@ export const McpRoutes = lazy(() => }, }), validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) - return c.json(true) - }, + async (c) => + jsonRequest("McpRoutes.connect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.connect(name) + return true + }), ) .post( "/:name/disconnect", @@ -237,10 +249,12 @@ export const McpRoutes = lazy(() => }, }), validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) - return c.json(true) - }, + async (c) => + jsonRequest("McpRoutes.disconnect", c, function* () { + const { name } = c.req.valid("param") + const mcp = yield* MCP.Service + yield* mcp.disconnect(name) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts index c3f9c82011..c18f4734b4 100644 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ b/packages/opencode/src/server/routes/instance/permission.ts @@ -1,11 +1,11 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" export const PermissionRoutes = lazy(() => new Hono() @@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() => }), ), validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("PermissionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return true + }), ) .get( "/", @@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() => }, }, }), - async (c) => { - const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) - return c.json(permissions) - }, + async (c) => + jsonRequest("PermissionRoutes.list", c, function* () { + const svc = yield* Permission.Service + return yield* svc.list() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 060542c4b4..5acef6d788 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -9,6 +9,7 @@ import { errors } from "../../error" import { lazy } from "@/util/lazy" import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" +import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => new Hono() @@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() => async (c) => { const dir = Instance.directory const prev = Instance.project - const next = await AppRuntime.runPromise( + const next = await runRequest( + "ProjectRoutes.initGit", + c, Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) @@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() => }), validator("param", z.object({ projectID: ProjectID.zod })), validator("json", Project.UpdateInput.omit({ projectID: true })), - async (c) => { - const projectID = c.req.valid("param").projectID - const body = c.req.valid("json") - const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID }))) - return c.json(project) - }, + async (c) => + jsonRequest("ProjectRoutes.update", c, function* () { + const projectID = c.req.valid("param").projectID + const body = c.req.valid("json") + const svc = yield* Project.Service + return yield* svc.update({ ...body, projectID }) + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index 57aa895e3d..617980e39c 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -6,11 +6,11 @@ import { Provider } from "@/provider" import { ModelsDev } from "@/provider" import { ProviderAuth } from "@/provider" import { ProviderID } from "@/provider/schema" -import { AppRuntime } from "@/effect/app-runtime" import { mapValues } from "remeda" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect } from "effect" +import { jsonRequest } from "./trace" export const ProviderRoutes = lazy(() => new Hono() @@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() => }, }, }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const cfg = yield* Config.Service - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } + async (c) => + jsonRequest("ProviderRoutes.list", c, function* () { + const svc = yield* Provider.Service + const cfg = yield* Config.Service + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value } - const connected = yield* svc.list() - const providers = Object.assign( - mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }), - ) - return c.json({ - all: result.all, - default: result.default, - connected: result.connected, - }) - }, + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }), ) .get( "/auth", @@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) - }, + async (c) => + jsonRequest("ProviderRoutes.auth", c, function* () { + const svc = yield* ProviderAuth.Service + return yield* svc.methods() + }), ) .post( "/:providerID/oauth/authorize", @@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() => }), ), validator("json", ProviderAuth.AuthorizeInput.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, inputs } = c.req.valid("json") - const result = await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.authorize({ - providerID, - method, - inputs, - }), - ), - ) - return c.json(result) - }, + async (c) => + jsonRequest("ProviderRoutes.oauth.authorize", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, inputs } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + return yield* svc.authorize({ + providerID, + method, + inputs, + }) + }), ) .post( "/:providerID/oauth/callback", @@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() => }), ), validator("json", ProviderAuth.CallbackInput.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.callback({ - providerID, - method, - code, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("ProviderRoutes.oauth.callback", c, function* () { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + const svc = yield* ProviderAuth.Service + yield* svc.callback({ + providerID, + method, + code, + }) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index b3f71c235c..a25b66e9ff 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -8,6 +8,7 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { NotFoundError } from "@/storage" import { errors } from "../../error" +import { jsonRequest, runRequest } from "./trace" export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return new Hono() @@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }), - async (c) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.list() - }), - ), - ) - }, + async (c) => + jsonRequest("PtyRoutes.list", c, function* () { + const pty = yield* Pty.Service + return yield* pty.list() + }), ) .post( "/", @@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), validator("json", Pty.CreateInput), - async (c) => { - const info = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.create(c.req.valid("json")) - }), - ) - return c.json(info) - }, + async (c) => + jsonRequest("PtyRoutes.create", c, function* () { + const pty = yield* Pty.Service + return yield* pty.create(c.req.valid("json")) + }), ) .get( "/:ptyID", @@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), async (c) => { - const info = await AppRuntime.runPromise( + const info = await runRequest( + "PtyRoutes.get", + c, Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.get(c.req.valid("param").ptyID) @@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), validator("json", Pty.UpdateInput), - async (c) => { - const info = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) - }), - ) - return c.json(info) - }, + async (c) => + jsonRequest("PtyRoutes.update", c, function* () { + const pty = yield* Pty.Service + return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + }), ) .delete( "/:ptyID", @@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - yield* pty.remove(c.req.valid("param").ptyID) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("PtyRoutes.remove", c, function* () { + const pty = yield* Pty.Service + yield* pty.remove(c.req.valid("param").ptyID) + return true + }), ) .get( "/:ptyID/connect", @@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { })() let handler: Handler | undefined if ( - !(await AppRuntime.runPromise( + !(await runRequest( + "PtyRoutes.connect", + c, Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.get(id) @@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { Effect.gen(function* () { const pty = yield* Pty.Service return yield* pty.connect(id, socket, cursor) - }), + }).pipe(Effect.withSpan("PtyRoutes.connect.open")), ) ready = true for (const msg of pending) handler?.onMessage(msg) diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts index 9b8f461e39..51ecb48ccd 100644 --- a/packages/opencode/src/server/routes/instance/question.ts +++ b/packages/opencode/src/server/routes/instance/question.ts @@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { QuestionID } from "@/question/schema" import { Question } from "@/question" -import { AppRuntime } from "@/effect/app-runtime" import z from "zod" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { jsonRequest } from "./trace" const Reply = z.object({ answers: Question.Answer.zod @@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() => }, }, }), - async (c) => { - const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) - return c.json(questions) - }, + async (c) => + jsonRequest("QuestionRoutes.list", c, function* () { + const svc = yield* Question.Service + return yield* svc.list() + }), ) .post( "/:requestID/reply", @@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() => }), ), validator("json", Reply), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await AppRuntime.runPromise( - Question.Service.use((svc) => - svc.reply({ - requestID: params.requestID, - answers: json.answers, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("QuestionRoutes.reply", c, function* () { + const params = c.req.valid("param") + const json = c.req.valid("json") + const svc = yield* Question.Service + yield* svc.reply({ + requestID: params.requestID, + answers: json.answers, + }) + return true + }), ) .post( "/:requestID/reject", @@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() => requestID: QuestionID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID))) - return c.json(true) - }, + async (c) => + jsonRequest("QuestionRoutes.reject", c, function* () { + const params = c.req.valid("param") + const svc = yield* Question.Service + yield* svc.reject(params.requestID) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index ae6185abb8..bf713935b0 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Agent } from "@/agent/agent" import { Snapshot } from "@/snapshot" import { Command } from "@/command" @@ -26,7 +25,7 @@ import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Bus } from "@/bus" import { NamedError } from "@opencode-ai/shared/util/error" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" const log = Log.create({ service: "server" }) @@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() => }, }), validator("json", Session.CreateInput), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body))) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.create", c, function* () { + const body = c.req.valid("json") ?? {} + const svc = yield* SessionShare.Service + return yield* svc.create(body) + }), ) .delete( "/:sessionID", @@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() => sessionID: Session.RemoveInput, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.delete", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* Session.Service + yield* svc.remove(sessionID) + return true + }), ) .patch( "/:sessionID", @@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() => .optional(), }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const current = yield* session.get(sessionID) + async (c) => + jsonRequest("SessionRoutes.update", c, function* () { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + const session = yield* Session.Service + const current = yield* session.get(sessionID) - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } + if (updates.title !== undefined) { + yield* session.setTitle({ sessionID, title: updates.title }) + } + if (updates.permission !== undefined) { + yield* session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } + if (updates.time?.archived !== undefined) { + yield* session.setArchived({ sessionID, time: updates.time.archived }) + } - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + return yield* session.get(sessionID) + }), ) // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( @@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() => messageID: MessageID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.init", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + yield* svc.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) + return true + }), ) .post( "/:sessionID/fork", @@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", Session.ForkInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID }))) - return c.json(result) - }, + async (c) => + jsonRequest("SessionRoutes.fork", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* Session.Service + return yield* svc.fork({ ...body, sessionID }) + }), ) .post( "/:sessionID/abort", @@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID))) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.abort", c, function* () { + const svc = yield* SessionPrompt.Service + yield* svc.cancel(c.req.valid("param").sessionID) + return true + }), ) .post( "/:sessionID/share", @@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.share", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.share(sessionID) + return yield* session.get(sessionID) + }), ) .get( "/:sessionID/diff", @@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() => messageID: SessionSummary.DiffInput.shape.messageID, }), ), - async (c) => { - const query = c.req.valid("query") - const params = c.req.valid("param") - const result = await AppRuntime.runPromise( - SessionSummary.Service.use((summary) => - summary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }), - ), - ) - return c.json(result) - }, + async (c) => + jsonRequest("SessionRoutes.diff", c, function* () { + const query = c.req.valid("query") + const params = c.req.valid("param") + const summary = yield* SessionSummary.Service + return yield* summary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + }), ) .delete( "/:sessionID/share", @@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* session.get(sessionID) - }), - ) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.unshare", c, function* () { + const sessionID = c.req.valid("param").sessionID + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.unshare(sessionID) + return yield* session.get(sessionID) + }), ) .post( "/:sessionID/summarize", @@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() => auto: z.boolean().optional().default(false), }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service + async (c) => + jsonRequest("SessionRoutes.summarize", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } + yield* revert.cleanup(yield* session.get(sessionID)) + const msgs = yield* session.messages({ sessionID }) + const defaultAgent = yield* agent.defaultAgent() + let currentAgent = defaultAgent + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || defaultAgent + break } + } - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.loop({ sessionID }) - }), - ) - return c.json(true) - }, + yield* compact.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + yield* prompt.loop({ sessionID }) + return true + }), ) .get( "/:sessionID/message", @@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() => const query = c.req.valid("query") const sessionID = c.req.valid("param").sessionID if (query.limit === undefined || query.limit === 0) { - const messages = await AppRuntime.runPromise( + const messages = await runRequest( + "SessionRoutes.messages", + c, Effect.gen(function* () { const session = yield* Session.Service yield* session.get(sessionID) @@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() => messageID: MessageID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - }), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.deleteMessage", c, function* () { + const params = c.req.valid("param") + const state = yield* SessionRunState.Service + const session = yield* Session.Service + yield* state.assertNotBusy(params.sessionID) + yield* session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return true + }), ) .delete( "/:sessionID/message/:messageID/part/:partID", @@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() => partID: PartID.zod, }), ), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.deletePart", c, function* () { + const params = c.req.valid("param") + const svc = yield* Session.Service + yield* svc.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return true + }), ) .patch( "/:sessionID/message/:messageID/part/:partID", @@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() => `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, ) } - const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body))) - return c.json(part) + return jsonRequest("SessionRoutes.updatePart", c, function* () { + const svc = yield* Session.Service + return yield* svc.updatePart(body) + }) }, ) .post( @@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() => return stream(c, async (stream) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const msg = await AppRuntime.runPromise( + const msg = await runRequest( + "SessionRoutes.prompt", + c, SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), ) void stream.write(JSON.stringify(msg)) @@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - 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(), - }) - }, - ) + void runRequest( + "SessionRoutes.prompt_async", + c, + 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) }, @@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID }))) - return c.json(msg) - }, + async (c) => + jsonRequest("SessionRoutes.command", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + return yield* svc.command({ ...body, sessionID }) + }), ) .post( "/:sessionID/shell", @@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() => }), ), validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID }))) - return c.json(msg) - }, + async (c) => + jsonRequest("SessionRoutes.shell", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const svc = yield* SessionPrompt.Service + return yield* svc.shell({ ...body, sessionID }) + }), ) .post( "/:sessionID/revert", @@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID log.info("revert", c.req.valid("json")) - const session = await AppRuntime.runPromise( - SessionRevert.Service.use((svc) => - svc.revert({ - sessionID, - ...c.req.valid("json"), - }), - ), - ) - return c.json(session) + return jsonRequest("SessionRoutes.revert", c, function* () { + const svc = yield* SessionRevert.Service + return yield* svc.revert({ + sessionID, + ...c.req.valid("json"), + }) + }) }, ) .post( @@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID }))) - return c.json(session) - }, + async (c) => + jsonRequest("SessionRoutes.unrevert", c, function* () { + const sessionID = c.req.valid("param").sessionID + const svc = yield* SessionRevert.Service + return yield* svc.unrevert({ sessionID }) + }), ) .post( "/:sessionID/permissions/:permissionID", @@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() => }), ), validator("json", z.object({ response: Permission.Reply.zod })), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }), - ), - ) - return c.json(true) - }, + async (c) => + jsonRequest("SessionRoutes.permissionRespond", c, function* () { + const params = c.req.valid("param") + const svc = yield* Permission.Service + yield* svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return true + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 2f856c3488..d6add67b97 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -4,10 +4,10 @@ import z from "zod" import { Bus } from "@/bus" import { Session } from "@/session" import { TuiEvent } from "@/cli/cmd/tui/event" -import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { runRequest } from "./trace" const TuiRequest = z.object({ path: z.string(), @@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() => validator("json", TuiEvent.SessionSelect.properties), async (c) => { const { sessionID } = c.req.valid("json") - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) + await runRequest( + "TuiRoutes.sessionSelect", + c, + Session.Service.use((svc) => svc.get(sessionID)), + ) await Bus.publish(TuiEvent.SessionSelect, { sessionID }) return c.json(true) }, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index c141d10956..d30a117d6a 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -10,6 +10,7 @@ import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Log } from "@/util" import { ServerProxy } from "./proxy" @@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) { const id = getSessionID(url) if (!id) return null - const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) + const session = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), + ).catch(() => undefined) return session?.workspaceID } From ce69bd97b90200a4d9794bd0409d3b4bb7996e54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 16:59:24 -0400 Subject: [PATCH 27/30] refactor(config): migrate model-id and command to Effect Schema (#23175) --- packages/opencode/src/config/agent.ts | 2 +- packages/opencode/src/config/command.ts | 22 ++++++++++++---------- packages/opencode/src/config/config.ts | 8 +++++--- packages/opencode/src/config/model-id.ts | 13 ++++++++++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index f754f009d4..9053b19fc1 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -15,7 +15,7 @@ const log = Log.create({ service: "config" }) export const Info = z .object({ - model: ConfigModelID.optional(), + model: ConfigModelID.zod.optional(), variant: z .string() .optional() diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 9799250567..3e0adccc30 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -1,10 +1,12 @@ export * as ConfigCommand from "./command" import { Log } from "../util" -import z from "zod" +import { Schema } from "effect" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" import { Bus } from "@/bus" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" @@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id" const log = Log.create({ service: "config" }) -export const Info = z.object({ - template: z.string(), - description: z.string().optional(), - agent: z.string().optional(), - model: ConfigModelID.optional(), - subtask: z.boolean().optional(), -}) +export const Info = Schema.Struct({ + template: Schema.String, + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(ConfigModelID), + subtask: Schema.optional(Schema.Boolean), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = z.infer +export type Info = Schema.Schema.Type export async function load(dir: string) { const result: Record = {} @@ -49,7 +51,7 @@ export async function load(dir: string) { ...md.data, template: md.content.trim(), } - const parsed = Info.safeParse(config) + const parsed = Info.zod.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 261c5acab4..684fdf63ab 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -97,7 +97,7 @@ export const Info = z logLevel: Log.Level.optional().describe("Log level"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z - .record(z.string(), ConfigCommand.Info) + .record(z.string(), ConfigCommand.Info.zod) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), @@ -135,8 +135,10 @@ export const Info = z .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), - small_model: ConfigModelID.describe( + model: ConfigModelID.zod + .describe("Model to use in the format of provider/model, eg anthropic/claude-2") + .optional(), + small_model: ConfigModelID.zod.describe( "Small model to use for tasks like title generation in the format of provider/model", ).optional(), default_agent: z diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 909e9aa929..3ad9e035ce 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,3 +1,14 @@ +import { Schema } from "effect" import z from "zod" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) +// The original Zod schema carried an external $ref pointing at the models.dev +// JSON schema. That external reference is not a named SDK component — it is a +// literal pointer to an outside schema — so the walker cannot re-derive it +// from AST metadata. Preserve the exact original Zod via ZodOverride. +export const ConfigModelID = Schema.String.annotate({ + [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) + +export type ConfigModelID = Schema.Schema.Type From 89029a20ef1548f6637c15f63f39f281e4a6dae7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 21:00:20 +0000 Subject: [PATCH 28/30] chore: generate --- packages/opencode/src/config/config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 684fdf63ab..f228878d04 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -135,12 +135,10 @@ export const Info = z .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ConfigModelID.zod - .describe("Model to use in the format of provider/model, eg anthropic/claude-2") + model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + small_model: ConfigModelID.zod + .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), - small_model: ConfigModelID.zod.describe( - "Small model to use for tasks like title generation in the format of provider/model", - ).optional(), default_agent: z .string() .optional() From 5980b0a5eeb8f7a8dc31433f86e458dbe3358269 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 17:06:55 -0400 Subject: [PATCH 29/30] feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) --- .../src/cli/cmd/tui/config/tui-schema.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/plugin.ts | 15 ++++++---- packages/opencode/src/util/effect-zod.ts | 13 +++++++-- .../opencode/test/util/effect-zod.test.ts | 28 +++++++++++++++++-- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 66569efea5..ed79e8e524 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -31,7 +31,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f228878d04..0f6d71f447 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -113,7 +113,7 @@ export const Info = z "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.", ), // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 7d335bcc53..ebc3e2230d 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -1,16 +1,21 @@ import { Glob } from "@opencode-ai/shared/util/glob" -import z from "zod" +import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import path from "path" -const Options = z.record(z.string(), z.unknown()) -export type Options = z.infer +export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) -export type Spec = z.infer +export const Spec = Schema.Union([ + Schema.String, + Schema.mutable(Schema.Tuple([Schema.String, Options])), +]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Spec = Schema.Schema.Type export type Scope = "global" | "local" diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index c3240deaac..771795ba68 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -119,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny { } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - if (ast.elements.length > 0) return fail(ast) - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) + // Pure variadic arrays: { elements: [], rest: [item] } + if (ast.elements.length === 0) { + if (ast.rest.length !== 1) return fail(ast) + return z.array(walk(ast.rest[0])) + } + // Fixed-length tuples: { elements: [a, b, ...], rest: [] } + // Tuples with a variadic tail (...rest) are not yet supported. + if (ast.rest.length > 0) return fail(ast) + const items = ast.elements.map(walk) + return z.tuple(items as [z.ZodTypeAny, ...Array]) } function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 1c84c96f3a..ba67a60e6d 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -61,8 +61,32 @@ describe("util.effect-zod", () => { }) }) - test("throws for unsupported tuple schemas", () => { - expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") + describe("Tuples", () => { + test("fixed-length tuple parses matching array", () => { + const out = zod(Schema.Tuple([Schema.String, Schema.Number])) + expect(out.parse(["a", 1])).toEqual(["a", 1]) + expect(out.safeParse(["a"]).success).toBe(false) + expect(out.safeParse(["a", "b"]).success).toBe(false) + }) + + test("single-element tuple parses a one-element array", () => { + const out = zod(Schema.Tuple([Schema.Boolean])) + expect(out.parse([true])).toEqual([true]) + expect(out.safeParse([true, false]).success).toBe(false) + }) + + test("tuple inside a union picks the right branch", () => { + const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])])) + expect(out.parse("hello")).toBe("hello") + expect(out.parse(["foo", 42])).toEqual(["foo", 42]) + expect(out.safeParse(["foo"]).success).toBe(false) + }) + + test("plain arrays still work (no element positions)", () => { + const out = zod(Schema.Array(Schema.String)) + expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]) + expect(out.parse([])).toEqual([]) + }) }) test("string literal unions produce z.enum with enum in JSON Schema", () => { From 89e8994fd15c4ac1235930a07e0b6e37254df22b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 21:08:00 +0000 Subject: [PATCH 30/30] chore: generate --- packages/opencode/src/config/plugin.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index ebc3e2230d..4277c1cd6d 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -11,10 +11,9 @@ export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = Schema.Union([ - Schema.String, - Schema.mutable(Schema.Tuple([Schema.String, Options])), -]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe( + withStatics((s) => ({ zod: zod(s) })), +) export type Spec = Schema.Schema.Type export type Scope = "global" | "local"