From e1e356cab4a9425fa287e4b01efd15187b8a9450 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 30 Jan 2026 06:48:49 +0000 Subject: [PATCH 01/35] release: v1.1.45 --- bun.lock | 36 ++++++++++++-------------- 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/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/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 38 insertions(+), 42 deletions(-) diff --git a/bun.lock b/bun.lock index 99d35168f30c..538712601d5c 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,12 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.17.23", - "stackback": "0.0.2", "turbo": "2.5.6", - "why-is-node-running": "2.2.2", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -75,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -109,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -136,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -160,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -184,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.43", + "version": "1.1.45", "bin": { "opencode": "./bin/opencode", }, @@ -364,7 +362,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -384,7 +382,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.43", + "version": "1.1.45", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -395,7 +393,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -408,7 +406,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -450,7 +448,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "zod": "catalog:", }, @@ -461,7 +459,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -3901,7 +3899,7 @@ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], - "why-is-node-running": ["why-is-node-running@2.2.2", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA=="], + "why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="], "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], @@ -4389,8 +4387,6 @@ "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], - "opencode/why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="], - "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], diff --git a/packages/app/package.json b/packages/app/package.json index 87ca7931c550..8459172e7cfc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.43", + "version": "1.1.45", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b10593a8fa5f..84d45d3a7b1d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 111390efbe76..38811d4de296 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.1.43", + "version": "1.1.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index c26b4584dedc..50acf1567c62 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.43", + "version": "1.1.45", "$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 564a7140cc1a..902dd447bed6 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.43", + "version": "1.1.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c1f330ce9302..1b01f8a6f2b9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 03016c1c58f7..2091e6857497 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.43", + "version": "1.1.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index eaa882b65bd1..283036bf5b18 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.1.43" +version = "1.1.45" 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.1.43/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.43/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.43/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/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.1.43/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/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.1.43/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 4a9e82def5ec..9746d11dccfd 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.43", + "version": "1.1.45", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bd77afa2e8c4..ab28687d3588 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.43", + "version": "1.1.45", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 19077d786d61..9e9232a4da7c 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.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e929d628943a..6b550512dea3 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.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index decfd834a74b..fbcccac399a3 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 2e7a2cfefdf1..5fde0960aca0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.43", + "version": "1.1.45", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index f76106701b27..9a3eb8f7d330 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.43", + "version": "1.1.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 0f601ba248f2..06ab5ef4e952 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.1.43", + "version": "1.1.45", "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 1ffe553c2cd0..7c160a454fbd 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.1.43", + "version": "1.1.45", "publisher": "sst-dev", "repository": { "type": "git", From 00637c0269312455e55e4977a7a8f55c728e93bd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:56:47 -0600 Subject: [PATCH 02/35] fix: rm ai sdk middleware that was preventing blocks from being sent back as assistant message content (#11270) Co-authored-by: opencode-agent[bot] --- packages/opencode/src/session/llm.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4c4f4114a281..0c765210452a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,4 +1,3 @@ -import os from "os" import { Installation } from "@/installation" import { Provider } from "@/provider/provider" import { Log } from "@/util/log" @@ -9,7 +8,6 @@ import { type StreamTextResult, type Tool, type ToolSet, - extractReasoningMiddleware, tool, jsonSchema, } from "ai" @@ -261,7 +259,6 @@ export namespace LLM { return args.params }, }, - extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }), ], }), experimental_telemetry: { From 33439be800ae9cb4e0e9a5bdb488a2f0d304a80e Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 29 Jan 2026 18:33:12 -0600 Subject: [PATCH 03/35] feat: add inherit field to provider config for multi-account support Allows users to create derived providers that inherit models from a base provider with different credentials. This enables per-project account selection for providers like Anthropic. Example config: provider: anthropic-work: inherit: anthropic name: Anthropic (Work) options: apiKey: work-key Changes: - Add inherit field to ProviderConfig schema - Process inherit directives in provider state initialization - Clone base provider models with new providerID - Regenerate SDK types - Add comprehensive test coverage (12 tests) --- bun.lock | 1 - packages/opencode/src/config/config.ts | 6 + packages/opencode/src/provider/provider.ts | 24 ++ .../opencode/test/provider/inherit.test.ts | 407 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 + 5 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/provider/inherit.test.ts diff --git a/bun.lock b/bun.lock index 538712601d5c..859b7d463571 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2f1cba8a0548..84cd84f1a170 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -862,6 +862,12 @@ export namespace Config { export const Provider = ModelsDev.Provider.partial() .extend({ + inherit: z + .string() + .optional() + .describe( + "Base provider ID to inherit models from. The inherited provider's models will be cloned under this provider's ID with custom credentials.", + ), whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 27ff5475db09..d3f9bc1df375 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -712,6 +712,30 @@ export namespace Provider { } } + // Process inherit directives from config - clone base providers with new IDs + for (const [providerID, providerConfig] of configProviders) { + if (!providerConfig.inherit) continue + if (!isProviderAllowed(providerID)) continue + + const base = database[providerConfig.inherit] + if (!base) { + log.warn(`inherit: base provider '${providerConfig.inherit}' not found for '${providerID}'`) + continue + } + + database[providerID] = { + ...base, + id: providerID, + name: providerConfig.name ?? base.name, + source: "config", + models: mapValues(base.models, (model) => ({ + ...model, + providerID, + api: { ...model.api }, + })), + } + } + function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/test/provider/inherit.test.ts b/packages/opencode/test/provider/inherit.test.ts new file mode 100644 index 000000000000..864afad16240 --- /dev/null +++ b/packages/opencode/test/provider/inherit.test.ts @@ -0,0 +1,407 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// Mock BunProc and default plugins to prevent actual installations during tests +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { Env } from "../../src/env" + +test("inherit creates derived provider with base provider models", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].source).toBe("config") + expect(Object.keys(providers["anthropic-work"].models).length).toBeGreaterThan(0) + expect(providers["anthropic-work"].models["claude-sonnet-4-20250514"]).toBeDefined() + }, + }) +}) + +test("inherit uses custom name when provided", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + }, + }) +}) + +test("inherit falls back to base provider name when name not provided", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic") + }, + }) +}) + +test("inherited provider models have correct providerID", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-personal": { + inherit: "anthropic", + name: "Anthropic (Personal)", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const model = providers["anthropic-personal"].models["claude-sonnet-4-20250514"] + expect(model).toBeDefined() + expect(model.providerID).toBe("anthropic-personal") + }, + }) +}) + +test("getModel works with inherited provider", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = await Provider.getModel("anthropic-work", "claude-sonnet-4-20250514") + expect(model).toBeDefined() + expect(model.providerID).toBe("anthropic-work") + expect(model.id).toBe("claude-sonnet-4-20250514") + }, + }) +}) + +test("inherit skips non-existent base provider with warning", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "nonexistent-derived": { + inherit: "nonexistent-provider", + options: { + apiKey: "test-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["nonexistent-derived"]).toBeUndefined() + }, + }) +}) + +test("inherited provider options merge with config options", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + timeout: 60000, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"].options["timeout"]).toBe(60000) + }, + }) +}) + +test("multiple inherited providers from same base", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + "anthropic-personal": { + inherit: "anthropic", + name: "Anthropic (Personal)", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-personal"]).toBeDefined() + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + expect(providers["anthropic-personal"].name).toBe("Anthropic (Personal)") + expect(providers["anthropic-work"].models["claude-sonnet-4-20250514"].providerID).toBe("anthropic-work") + expect(providers["anthropic-personal"].models["claude-sonnet-4-20250514"].providerID).toBe("anthropic-personal") + }, + }) +}) + +test("inherited provider respects disabled_providers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + disabled_providers: ["anthropic-work"], + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeUndefined() + }, + }) +}) + +test("inherited provider respects enabled_providers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic-work"], + provider: { + "anthropic-work": { + inherit: "anthropic", + options: { + apiKey: "work-api-key", + }, + }, + "anthropic-personal": { + inherit: "anthropic", + options: { + apiKey: "personal-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic-personal"]).toBeUndefined() + }, + }) +}) + +test("project config can use inherited provider model", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "anthropic-work/claude-sonnet-4-20250514", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const defaultModel = await Provider.defaultModel() + expect(defaultModel.providerID).toBe("anthropic-work") + expect(defaultModel.modelID).toBe("claude-sonnet-4-20250514") + }, + }) +}) + +test("inherited provider coexists with base provider when base has env key", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "anthropic-work": { + inherit: "anthropic", + name: "Anthropic (Work)", + options: { + apiKey: "work-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("ANTHROPIC_API_KEY", "default-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic"]).toBeDefined() + expect(providers["anthropic-work"]).toBeDefined() + expect(providers["anthropic"].name).toBe("Anthropic") + expect(providers["anthropic-work"].name).toBe("Anthropic (Work)") + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8555e84384ff..e1549870fd81 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1493,6 +1493,10 @@ export type ProviderConfig = { } } } + /** + * Base provider ID to inherit models from. The inherited provider's models will be cloned under this provider's ID with custom credentials. + */ + inherit?: string whitelist?: Array blacklist?: Array options?: { From 84540bf2143477961e6307255210c395331b5f22 Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 29 Jan 2026 18:23:43 -0600 Subject: [PATCH 04/35] feat(plugin): add tools and mcp bridges for plugin tool calling Add ToolsBridge and McpBridge to PluginInput, enabling plugins to: - Call built-in tools (read, edit, bash, glob, grep, etc.) - Call MCP server tools programmatically - Manage MCP servers dynamically (add, remove, connect, disconnect) - Use helpers like mcpTool() and mcpServer for common patterns Includes cycle detection, permission checking, and typed errors. --- examples/plugin-mcp-demo/plugin.ts | 93 +++++++++ packages/opencode/src/plugin/index.ts | 4 + packages/opencode/src/plugin/mcp-bridge.ts | 117 ++++++++++++ packages/opencode/src/plugin/tools-bridge.ts | 191 +++++++++++++++++++ packages/plugin/src/errors.ts | 53 +++++ packages/plugin/src/helpers.ts | 40 ++++ packages/plugin/src/index.ts | 8 + packages/plugin/src/mcp.ts | 57 ++++++ packages/plugin/src/tools.ts | 31 +++ 9 files changed, 594 insertions(+) create mode 100644 examples/plugin-mcp-demo/plugin.ts create mode 100644 packages/opencode/src/plugin/mcp-bridge.ts create mode 100644 packages/opencode/src/plugin/tools-bridge.ts create mode 100644 packages/plugin/src/errors.ts create mode 100644 packages/plugin/src/helpers.ts create mode 100644 packages/plugin/src/mcp.ts create mode 100644 packages/plugin/src/tools.ts diff --git a/examples/plugin-mcp-demo/plugin.ts b/examples/plugin-mcp-demo/plugin.ts new file mode 100644 index 000000000000..03bcde54c978 --- /dev/null +++ b/examples/plugin-mcp-demo/plugin.ts @@ -0,0 +1,93 @@ +import { type Plugin, mcpTool, mcpServer } from "@opencode-ai/plugin" +import { z } from "zod" + +/** + * Example plugin demonstrating the tools and mcp bridges. + * + * This plugin shows how to: + * 1. Call built-in tools from a plugin + * 2. Add and use MCP servers + * 3. Create wrapped MCP tools + */ +export const DemoPlugin: Plugin = async (ctx) => { + // Example: Add a local MCP server (uncomment when you have one to test) + // await ctx.mcp.addServer("my-tools", mcpServer.local({ + // command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + // })) + + return { + tool: { + // Example: Tool that uses built-in tools + countFiles: { + description: "Count files matching a pattern", + args: { + pattern: z.string().default("**/*.ts"), + }, + async execute(args) { + const result = await ctx.tools.call("glob", { pattern: args.pattern }) + const files = result.output as string[] + return `Found ${files.length} files matching ${args.pattern}` + }, + }, + + // Example: Tool that reads a file using built-in tool + peekFile: { + description: "Read first 10 lines of a file", + args: { + filePath: z.string(), + }, + async execute(args) { + const result = await ctx.tools.call("read", { + filePath: args.filePath, + limit: 10, + }) + return result.output as string + }, + }, + + // Example: List all available tools + listTools: { + description: "List all available tools", + args: {}, + async execute() { + const tools = await ctx.tools.list() + const lines = tools.map((t) => `- ${t.id} (${t.source}): ${t.description.slice(0, 50)}...`) + return `Available tools:\n${lines.join("\n")}` + }, + }, + + // Example: Check MCP server status + mcpStatus: { + description: "Show MCP server connection status", + args: {}, + async execute() { + const status = await ctx.mcp.status() + const entries = Object.entries(status) + if (entries.length === 0) { + return "No MCP servers configured" + } + const lines = entries.map(([name, s]) => `- ${name}: ${s.status}`) + return `MCP Servers:\n${lines.join("\n")}` + }, + }, + + // Example: Wrapped MCP tool (uncomment when server is added) + // everything: mcpTool(ctx, { + // server: "my-tools", + // tool: "echo", + // description: "Echo via MCP", + // }), + }, + + // Example: Using tools from event hook + event: async ({ event }) => { + if (event.type === "session.created") { + console.log("[demo-plugin] Session created, checking tools...") + const hasRead = await ctx.tools.has("read") + console.log(`[demo-plugin] Read tool available: ${hasRead}`) + } + }, + } +} + +export default DemoPlugin diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6032935f8480..8f207803a937 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,8 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { createToolsBridge } from "./tools-bridge" +import { createMcpBridge } from "./mcp-bridge" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -35,6 +37,8 @@ export namespace Plugin { directory: Instance.directory, serverUrl: Server.url(), $: Bun.$, + tools: createToolsBridge({ pluginName: "internal" }), + mcp: createMcpBridge(), } for (const plugin of INTERNAL_PLUGINS) { diff --git a/packages/opencode/src/plugin/mcp-bridge.ts b/packages/opencode/src/plugin/mcp-bridge.ts new file mode 100644 index 000000000000..8c7dd1c9a5bb --- /dev/null +++ b/packages/opencode/src/plugin/mcp-bridge.ts @@ -0,0 +1,117 @@ +import type { + McpBridge, + McpServerConfig, + McpStatus, + ResourceContent, + PromptResult, + ToolInfo, +} from "@opencode-ai/plugin" +import { McpNotConnectedError, McpAuthError } from "@opencode-ai/plugin" +import { MCP } from "../mcp" +import type { Config } from "../config/config" + +export function createMcpBridge(): McpBridge { + return { + async addServer(name: string, config: McpServerConfig): Promise { + const mcpConfig: Config.Mcp = + config.type === "local" + ? { + type: "local", + command: config.command, + environment: config.environment, + timeout: config.timeout, + } + : { + type: "remote", + url: config.url, + headers: config.headers, + oauth: config.oauth, + } + + await MCP.add(name, mcpConfig) + }, + + async removeServer(name: string): Promise { + await MCP.disconnect(name) + }, + + async status(): Promise> { + const result = await MCP.status() + return result as Record + }, + + async connect(name: string): Promise { + await MCP.connect(name) + }, + + async disconnect(name: string): Promise { + await MCP.disconnect(name) + }, + + async tools(server: string): Promise { + const status = await MCP.status() + const serverStatus = status[server] + + if (!serverStatus) { + throw new McpNotConnectedError(server) + } + if (serverStatus.status === "needs_auth") { + throw new McpAuthError(server) + } + if (serverStatus.status !== "connected") { + throw new McpNotConnectedError(server) + } + + const allTools = await MCP.tools() + const result: ToolInfo[] = [] + const prefix = `${server}_` + + for (const [id, tool] of Object.entries(allTools)) { + if (id.startsWith(prefix)) { + result.push({ + id, + description: tool.description ?? "", + parameters: {}, + source: "mcp", + server, + }) + } + } + + return result + }, + + async readResource(server: string, uri: string): Promise { + const result = await MCP.readResource(server, uri) + if (!result) { + throw new McpNotConnectedError(server) + } + // Transform MCP SDK result to our interface + const contents = result.contents?.[0] as + | { uri?: string; mimeType?: string; text?: string; blob?: string } + | undefined + return { + uri: contents?.uri ?? uri, + mimeType: contents?.mimeType, + text: contents?.text, + blob: contents?.blob, + } + }, + + async getPrompt(server: string, name: string, args?: Record): Promise { + const result = await MCP.getPrompt(server, name, args) + if (!result) { + throw new McpNotConnectedError(server) + } + // Transform MCP SDK result to our interface + return { + description: result.description, + messages: + result.messages?.map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content as any, + })) ?? [], + } + }, + } +} diff --git a/packages/opencode/src/plugin/tools-bridge.ts b/packages/opencode/src/plugin/tools-bridge.ts new file mode 100644 index 000000000000..1c46c6665bc3 --- /dev/null +++ b/packages/opencode/src/plugin/tools-bridge.ts @@ -0,0 +1,191 @@ +import type { ToolsBridge, ToolCallOptions, ToolResult, ToolInfo } from "@opencode-ai/plugin" +import { ToolNotFoundError, ToolPermissionError, ToolCycleError, DEFAULT_TOOL_TIMEOUT } from "@opencode-ai/plugin" +import { ToolRegistry } from "../tool/registry" +import { MCP } from "../mcp" +import type { Tool } from "../tool/tool" + +export interface ToolsBridgeConfig { + pluginName: string + permissions?: { + tools?: { + allow?: string[] + deny?: string[] + } + } + sessionID?: string + messageID?: string + agent?: string + abort?: AbortSignal +} + +const callStacks = new WeakMap>() + +export function createToolsBridge(config: ToolsBridgeConfig): ToolsBridge { + const bridge: ToolsBridge = { + async call( + id: string, + args: Record, + options?: ToolCallOptions, + ): Promise> { + // Check permissions + if (!options?.skipPermissions) { + if (!checkPermission(id, config.permissions)) { + throw new ToolPermissionError(id, config.pluginName) + } + } + + // Cycle detection + let stack = callStacks.get(bridge) + if (!stack) { + stack = new Set() + callStacks.set(bridge, stack) + } + if (stack.has(id)) { + throw new ToolCycleError([...stack, id]) + } + stack.add(id) + + try { + const timeout = options?.timeout ?? DEFAULT_TOOL_TIMEOUT + const context = createContext(config, options) + + // Try built-in tools first + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + const builtinTool = registryTools.find((t) => t.id === id) + + if (builtinTool) { + const result = await Promise.race([ + builtinTool.execute(args, context), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool ${id} timed out after ${timeout}ms`)), timeout), + ), + ]) + + return { + output: result.output as T, + title: result.title, + metadata: result.metadata as Record, + } + } + + // Try MCP tools + const mcpTools = await MCP.tools() + const mcpTool = mcpTools[id] + + if (mcpTool && mcpTool.execute) { + const mcpResult = await Promise.race([ + mcpTool.execute(args, { toolCallId: crypto.randomUUID(), messages: [] }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool ${id} timed out after ${timeout}ms`)), timeout), + ), + ]) + + // MCP tools return { content: [...] } + if (typeof mcpResult === "object" && mcpResult !== null && "content" in mcpResult) { + const content = (mcpResult as { content: Array<{ type: string; text?: string }> }).content + const textParts = content.filter((c) => c.type === "text").map((c) => c.text ?? "") + return { + output: textParts.join("\n") as T, + } + } + + return { + output: mcpResult as T, + } + } + + // Tool not found + const availableBuiltin = registryTools.map((t) => t.id) + const availableMcp = Object.keys(mcpTools) + throw new ToolNotFoundError(id, [...availableBuiltin, ...availableMcp]) + } finally { + stack.delete(id) + } + }, + + async list(): Promise { + const result: ToolInfo[] = [] + + // Built-in tools + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + for (const tool of registryTools) { + result.push({ + id: tool.id, + description: tool.description, + parameters: tool.parameters as any, + source: "builtin", + }) + } + + // MCP tools + const mcpTools = await MCP.tools() + for (const [id, tool] of Object.entries(mcpTools)) { + result.push({ + id, + description: tool.description ?? "", + parameters: {}, + source: "mcp", + server: id.split("_")[0], + }) + } + + return result + }, + + async has(id: string): Promise { + const registryTools = await ToolRegistry.tools({ providerID: "opencode", modelID: "default" }) + if (registryTools.some((t) => t.id === id)) return true + + const mcpTools = await MCP.tools() + return id in mcpTools + }, + } + + return bridge +} + +function checkPermission(toolId: string, permissions?: { tools?: { allow?: string[]; deny?: string[] } }): boolean { + if (!permissions?.tools) return true + + // Deny list takes precedence + if (permissions.tools.deny?.includes(toolId)) { + return false + } + + // If allow list exists, it's a whitelist + if (permissions.tools.allow) { + return permissions.tools.allow.includes(toolId) + } + + return true +} + +function createContext(config: ToolsBridgeConfig, options?: ToolCallOptions): Tool.Context { + const callId = crypto.randomUUID() + + if (config.sessionID) { + // Use session context + return { + sessionID: config.sessionID, + messageID: config.messageID ?? `plugin-${callId}`, + agent: config.agent ?? "default", + abort: options?.signal ?? config.abort ?? new AbortController().signal, + callID: callId, + messages: [], + metadata: () => {}, + ask: async () => {}, + } + } + + // Synthetic context + return { + sessionID: `plugin-${config.pluginName}-${Date.now()}`, + messageID: `plugin-call-${callId}`, + agent: "default", + abort: options?.signal ?? new AbortController().signal, + callID: callId, + messages: [], + metadata: () => {}, + ask: async () => {}, + } +} diff --git a/packages/plugin/src/errors.ts b/packages/plugin/src/errors.ts new file mode 100644 index 000000000000..efe00e565342 --- /dev/null +++ b/packages/plugin/src/errors.ts @@ -0,0 +1,53 @@ +export class ToolNotFoundError extends Error { + name = "ToolNotFoundError" as const + constructor( + public toolId: string, + public availableTools: string[], + ) { + super( + `Tool "${toolId}" not found. Available: ${availableTools.slice(0, 5).join(", ")}${availableTools.length > 5 ? "..." : ""}`, + ) + } +} + +export class ToolPermissionError extends Error { + name = "ToolPermissionError" as const + constructor( + public toolId: string, + public pluginName: string, + ) { + super(`Plugin "${pluginName}" does not have permission to call "${toolId}"`) + } +} + +export class ToolCycleError extends Error { + name = "ToolCycleError" as const + constructor(public callStack: string[]) { + super(`Circular tool call detected: ${callStack.join(" -> ")}`) + } +} + +export class McpNotConnectedError extends Error { + name = "McpNotConnectedError" as const + constructor(public server: string) { + super(`MCP server "${server}" is not connected`) + } +} + +export class McpTimeoutError extends Error { + name = "McpTimeoutError" as const + constructor( + public server: string, + public tool: string, + public elapsed: number, + ) { + super(`MCP call to ${server}/${tool} timed out after ${elapsed}ms`) + } +} + +export class McpAuthError extends Error { + name = "McpAuthError" as const + constructor(public server: string) { + super(`MCP server "${server}" requires authentication. Run: opencode mcp ${server} auth`) + } +} diff --git a/packages/plugin/src/helpers.ts b/packages/plugin/src/helpers.ts new file mode 100644 index 000000000000..3dd281560f2e --- /dev/null +++ b/packages/plugin/src/helpers.ts @@ -0,0 +1,40 @@ +import type { ToolDefinition, ToolContext } from "./tool" +import type { PluginInput } from "./index" +import type { McpServerConfig, McpOAuth } from "./mcp" + +export function mcpTool( + ctx: PluginInput, + config: { + server: string + tool: string + description?: string + transformArgs?: (args: unknown) => unknown + transformResult?: (result: unknown) => unknown + }, +): ToolDefinition { + return { + description: config.description ?? `Call ${config.server}/${config.tool}`, + args: {}, + async execute(args: unknown, context: ToolContext) { + const transformedArgs = config.transformArgs ? config.transformArgs(args) : args + const result = await ctx.tools.call(`${config.server}_${config.tool}`, transformedArgs as Record) + return config.transformResult ? String(config.transformResult(result.output)) : String(result.output) + }, + } +} + +export const mcpServer = { + local(config: { command: string[]; environment?: Record; timeout?: number }): McpServerConfig { + return { + type: "local", + ...config, + } + }, + + remote(config: { url: string; headers?: Record; oauth?: McpOAuth | false }): McpServerConfig { + return { + type: "remote", + ...config, + } + }, +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 86e7ae93420f..cc104accddf8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -14,8 +14,14 @@ import type { import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" +import type { ToolsBridge } from "./tools" +import type { McpBridge } from "./mcp" export * from "./tool" +export * from "./errors" +export * from "./tools" +export * from "./mcp" +export * from "./helpers" export type ProviderContext = { source: "env" | "config" | "custom" | "api" @@ -30,6 +36,8 @@ export type PluginInput = { worktree: string serverUrl: URL $: BunShell + tools: ToolsBridge + mcp: McpBridge } export type Plugin = (input: PluginInput) => Promise diff --git a/packages/plugin/src/mcp.ts b/packages/plugin/src/mcp.ts new file mode 100644 index 000000000000..f60354c140fd --- /dev/null +++ b/packages/plugin/src/mcp.ts @@ -0,0 +1,57 @@ +import type { ToolInfo } from "./tools" + +export interface McpBridge { + addServer(name: string, config: McpServerConfig): Promise + removeServer(name: string): Promise + status(): Promise> + connect(name: string): Promise + disconnect(name: string): Promise + tools(server: string): Promise + readResource(server: string, uri: string): Promise + getPrompt(server: string, name: string, args?: Record): Promise +} + +export type McpServerConfig = + | { + type: "local" + command: string[] + environment?: Record + timeout?: number + } + | { + type: "remote" + url: string + headers?: Record + oauth?: McpOAuth | false + } + +export interface McpOAuth { + clientId?: string + clientSecret?: string + scope?: string +} + +export type McpStatus = + | { status: "connected" } + | { status: "disabled" } + | { status: "failed"; error: string } + | { status: "needs_auth" } + | { status: "needs_client_registration"; error: string } + +export interface ResourceContent { + uri: string + mimeType?: string + text?: string + blob?: string +} + +export interface PromptResult { + description?: string + messages: Array<{ + role: "user" | "assistant" + content: + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + | { type: "resource"; resource: ResourceContent } + }> +} diff --git a/packages/plugin/src/tools.ts b/packages/plugin/src/tools.ts new file mode 100644 index 000000000000..91f004bab103 --- /dev/null +++ b/packages/plugin/src/tools.ts @@ -0,0 +1,31 @@ +import type { JsonSchema7Type } from "zod-to-json-schema" + +export const DEFAULT_TOOL_TIMEOUT = 30000 + +export interface ToolsBridge { + call(id: string, args: Record, options?: ToolCallOptions): Promise> + + list(): Promise + + has(id: string): Promise +} + +export interface ToolCallOptions { + signal?: AbortSignal + timeout?: number + skipPermissions?: boolean +} + +export interface ToolResult { + output: T + title?: string + metadata?: Record +} + +export interface ToolInfo { + id: string + description: string + parameters: JsonSchema7Type + source: "builtin" | "mcp" | "plugin" + server?: string +} From f4b7085530dcdc8ef312a5f634adad6bfe32c8cf Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 29 Jan 2026 18:31:23 -0600 Subject: [PATCH 05/35] test(plugin): add unit tests for tools and mcp bridges --- .../opencode/test/plugin/mcp-bridge.test.ts | 73 ++++++++++ .../opencode/test/plugin/tools-bridge.test.ts | 128 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 packages/opencode/test/plugin/mcp-bridge.test.ts create mode 100644 packages/opencode/test/plugin/tools-bridge.test.ts diff --git a/packages/opencode/test/plugin/mcp-bridge.test.ts b/packages/opencode/test/plugin/mcp-bridge.test.ts new file mode 100644 index 000000000000..9ed830a867d6 --- /dev/null +++ b/packages/opencode/test/plugin/mcp-bridge.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { createMcpBridge } from "../../src/plugin/mcp-bridge" +import { McpNotConnectedError } from "@opencode-ai/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("McpBridge", () => { + describe("status", () => { + test("returns object of server statuses", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + const status = await bridge.status() + + expect(typeof status).toBe("object") + // May be empty if no MCP servers configured + }, + }) + }) + }) + + describe("tools", () => { + test("throws McpNotConnectedError for non-existent server", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + await expect(bridge.tools("nonexistent-server-12345")).rejects.toThrow(McpNotConnectedError) + }, + }) + }) + }) + + describe("server management", () => { + test("addServer does not throw for valid config", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + // This will attempt to connect but may fail - we just verify it completes + // In a real test environment, we'd mock the MCP module + const result = await bridge.addServer("test-server", { + type: "local", + command: ["echo", "test"], + }) + // Should resolve (may be undefined, that's ok) + expect(result).toBeUndefined() + }, + }) + }) + + test("disconnect does not throw for unknown server", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createMcpBridge() + + // Should not throw even for unknown server + const result = await bridge.disconnect("unknown-server") + // Should resolve (may be undefined, that's ok) + expect(result).toBeUndefined() + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/plugin/tools-bridge.test.ts b/packages/opencode/test/plugin/tools-bridge.test.ts new file mode 100644 index 000000000000..d59a6908988e --- /dev/null +++ b/packages/opencode/test/plugin/tools-bridge.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { createToolsBridge } from "../../src/plugin/tools-bridge" +import { ToolPermissionError } from "@opencode-ai/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("ToolsBridge", () => { + describe("permissions", () => { + test("denies tool in deny list", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash", "write"], + }, + }, + }) + + await expect(bridge.call("bash", { command: "ls" })).rejects.toThrow(ToolPermissionError) + }) + + test("allows tool not in deny list", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash"], + }, + }, + }) + + // This will throw ToolNotFoundError in test env (no actual tools), not PermissionError + const result = bridge.call("read", { filePath: "/tmp/test" }) + await expect(result).rejects.not.toThrow(ToolPermissionError) + }) + + test("allow list acts as whitelist", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + allow: ["read", "glob"], + }, + }, + }) + + // bash not in allow list - should be denied + await expect(bridge.call("bash", { command: "ls" })).rejects.toThrow(ToolPermissionError) + }) + + test("skipPermissions bypasses checks", async () => { + const bridge = createToolsBridge({ + pluginName: "test-plugin", + permissions: { + tools: { + deny: ["bash"], + }, + }, + }) + + // With skipPermissions, should not throw PermissionError (may throw ToolNotFoundError) + const result = bridge.call("bash", { command: "ls" }, { skipPermissions: true }) + await expect(result).rejects.not.toThrow(ToolPermissionError) + }) + }) + + describe("list and has", () => { + test("list returns array of tools", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + const tools = await bridge.list() + + expect(Array.isArray(tools)).toBe(true) + // Should have some built-in tools + expect(tools.length).toBeGreaterThan(0) + }, + }) + }) + + test("has returns true for existing tool", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + + // read is a built-in tool + const hasRead = await bridge.has("read") + expect(hasRead).toBe(true) + }, + }) + }) + + test("has returns false for non-existent tool", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + + const hasNonexistent = await bridge.has("this-tool-does-not-exist-12345") + expect(hasNonexistent).toBe(false) + }, + }) + }) + }) + + describe("tool info", () => { + test("list includes tool metadata", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bridge = createToolsBridge({ pluginName: "test-plugin" }) + const tools = await bridge.list() + + const readTool = tools.find((t) => t.id === "read") + expect(readTool).toBeDefined() + expect(readTool?.source).toBe("builtin") + expect(readTool?.description).toBeDefined() + }, + }) + }) + }) +}) From c28aa945cd6a6179d2a3f83e881fe1ee8458932b Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 29 Jan 2026 19:26:47 -0600 Subject: [PATCH 06/35] docs(plugin): add documentation for tools and mcp bridges Also fixes: add override modifier to Error.name properties --- packages/docs/docs.json | 4 + packages/docs/plugins.mdx | 324 ++++++++++++++++++++++++++++++++++ packages/plugin/README.md | 307 ++++++++++++++++++++++++++++++++ packages/plugin/src/errors.ts | 12 +- 4 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 packages/docs/plugins.mdx create mode 100644 packages/plugin/README.md diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 1bf8b3700b93..3a543a2beced 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,6 +17,10 @@ "group": "Getting started", "pages": ["index", "quickstart", "development"], "openapi": "https://opencode.ai/openapi.json" + }, + { + "group": "Plugins", + "pages": ["plugins"] } ] } diff --git a/packages/docs/plugins.mdx b/packages/docs/plugins.mdx new file mode 100644 index 000000000000..1810f8f99f92 --- /dev/null +++ b/packages/docs/plugins.mdx @@ -0,0 +1,324 @@ +--- +title: "Plugins" +description: "Extend OpenCode with custom tools and MCP integrations" +--- + +# Building Plugins + +Plugins extend OpenCode with custom tools, hooks, and integrations. The `@opencode-ai/plugin` package provides full access to OpenCode's tool system and MCP servers. + +## Installation + +```bash +bun add @opencode-ai/plugin +``` + +## Basic Plugin + +A plugin is an async function that receives context and returns hooks: + +```typescript +import { type Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + greet: { + description: "Say hello", + args: {}, + async execute() { + return "Hello from my plugin!" + }, + }, + }, + } +} + +export default MyPlugin +``` + +## Plugin Context + +Every plugin receives a context object: + +| Property | Description | +| ----------- | ------------------------ | +| `client` | SDK client for API calls | +| `project` | Current project info | +| `directory` | Project root directory | +| `tools` | Call any registered tool | +| `mcp` | Manage MCP servers | +| `$` | Shell for commands | + +## Calling Tools + +The `tools` bridge lets plugins call any tool in the system. + +### Basic Usage + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + countFiles: { + description: "Count TypeScript files", + args: {}, + async execute() { + const result = await ctx.tools.call("glob", { + pattern: "**/*.ts", + }) + const files = result.output as string[] + return `Found ${files.length} files` + }, + }, + }, + } +} +``` + +### Available Methods + +```typescript +// Call a tool +const result = await ctx.tools.call("read", { filePath: "/path/to/file" }) + +// Call with timeout +await ctx.tools.call("bash", { command: "npm test" }, { timeout: 60000 }) + +// List all tools +const tools = await ctx.tools.list() + +// Check if tool exists +if (await ctx.tools.has("my-mcp-server_search")) { + // Use the tool +} +``` + +### Tool Sources + +Tools come from three sources: + +- **builtin**: Core tools (read, write, bash, glob, grep, etc.) +- **mcp**: Tools from connected MCP servers +- **plugin**: Tools from other plugins + +## MCP Integration + +Plugins can add, manage, and use MCP servers programmatically. + +### Adding Servers + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + // Add a local MCP server + await ctx.mcp.addServer( + "my-tools", + mcpServer.local({ + command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + }), + ) + + // Add a remote MCP server + await ctx.mcp.addServer( + "remote", + mcpServer.remote({ + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }), + ) + + return { + /* hooks */ + } +} +``` + +### Server Management + +```typescript +// Check connection status +const status = await ctx.mcp.status() +// { "my-tools": { status: "connected" } } + +// Connect/disconnect +await ctx.mcp.connect("my-tools") +await ctx.mcp.disconnect("my-tools") + +// Remove server +await ctx.mcp.removeServer("my-tools") +``` + +### MCP Resources and Prompts + +```typescript +// Read a resource +const content = await ctx.mcp.readResource("server", "file:///path") + +// Get a prompt template +const prompt = await ctx.mcp.getPrompt("server", "review-code", { + language: "typescript", +}) +``` + +## Helper Functions + +### mcpTool + +Expose an MCP tool as a plugin tool: + +```typescript +import { mcpTool } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + search: mcpTool(ctx, { + server: "search-server", + tool: "search", + description: "Search the codebase", + transformArgs: (args) => ({ ...args, limit: 10 }), + }), + }, + } +} +``` + +### mcpServer + +Create server configurations: + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server (stdio) +mcpServer.local({ + command: ["python", "-m", "my_server"], + environment: { API_KEY: "xxx" }, + timeout: 60000, +}) + +// Remote server (SSE) with OAuth +mcpServer.remote({ + url: "https://api.example.com/mcp", + oauth: { clientId: "xxx", scope: "read" }, +}) +``` + +## Error Handling + +The SDK provides typed errors: + +```typescript +import { ToolNotFoundError, ToolPermissionError, McpNotConnectedError, McpTimeoutError } from "@opencode-ai/plugin" + +try { + await ctx.tools.call("unknown", {}) +} catch (e) { + if (e instanceof ToolNotFoundError) { + console.log(`Available tools: ${e.availableTools.join(", ")}`) + } +} +``` + +| Error | Cause | +| ---------------------- | ----------------------- | +| `ToolNotFoundError` | Tool doesn't exist | +| `ToolPermissionError` | Plugin lacks permission | +| `ToolCycleError` | Circular call detected | +| `McpNotConnectedError` | Server not connected | +| `McpTimeoutError` | Call timed out | +| `McpAuthError` | Auth required | + +## Permissions + +Restrict plugin tool access via configuration: + +```json +{ + "plugins": { + "my-plugin": { + "permissions": { + "tools": { + "allow": ["read", "glob"], + "deny": ["bash", "write"] + } + } + } + } +} +``` + +## Event Hooks + +React to OpenCode events: + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + event: async ({ event }) => { + if (event.type === "session.created") { + console.log("New session started") + } + }, + } +} +``` + +## Complete Example + +```typescript +import { type Plugin, mcpTool, mcpServer } from "@opencode-ai/plugin" +import { z } from "zod" + +export const CodeAnalyzer: Plugin = async (ctx) => { + // Add analysis server + await ctx.mcp.addServer( + "analyzer", + mcpServer.local({ + command: ["npx", "code-analyzer-mcp"], + }), + ) + + return { + tool: { + // Custom tool using built-in tools + fileStats: { + description: "Get file statistics", + args: { + pattern: z.string().default("**/*.ts"), + }, + async execute(args) { + const glob = await ctx.tools.call("glob", { pattern: args.pattern }) + const files = glob.output as string[] + + let totalLines = 0 + for (const file of files.slice(0, 10)) { + const content = await ctx.tools.call("read", { filePath: file }) + totalLines += (content.output as string).split("\n").length + } + + return `${files.length} files, ~${totalLines} lines (sampled)` + }, + }, + + // Wrapped MCP tool + analyze: mcpTool(ctx, { + server: "analyzer", + tool: "analyze", + description: "Run code analysis", + }), + }, + + event: async ({ event }) => { + if (event.type === "session.created") { + const status = await ctx.mcp.status() + console.log("Analyzer status:", status.analyzer?.status) + } + }, + } +} + +export default CodeAnalyzer +``` diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 000000000000..2d96ebd73b17 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,307 @@ +# @opencode-ai/plugin + +TypeScript SDK for building OpenCode plugins with full access to tools and MCP servers. + +## Installation + +```bash +bun add @opencode-ai/plugin +``` + +## Quick Start + +```typescript +import { type Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + myTool: { + description: "A custom tool", + args: {}, + async execute() { + // Call built-in tools + const result = await ctx.tools.call("read", { filePath: "/etc/hosts" }) + return result.output as string + }, + }, + }, + } +} + +export default MyPlugin +``` + +## Plugin Context + +Every plugin receives a context object with these properties: + +| Property | Type | Description | +| ----------- | ---------------- | ----------------------------- | +| `client` | `OpencodeClient` | SDK client for API calls | +| `project` | `Project` | Current project info | +| `directory` | `string` | Project root directory | +| `worktree` | `string` | Git worktree path | +| `serverUrl` | `URL` | OpenCode server URL | +| `$` | `BunShell` | Shell for running commands | +| `tools` | `ToolsBridge` | Call any registered tool | +| `mcp` | `McpBridge` | Manage MCP server connections | + +## Tools Bridge + +The `tools` bridge lets plugins call any tool: built-in, MCP, or from other plugins. + +### Calling Tools + +```typescript +// Call a built-in tool +const files = await ctx.tools.call("glob", { pattern: "**/*.ts" }) + +// Call with options +const result = await ctx.tools.call("bash", { command: "ls -la" }, { timeout: 5000, skipPermissions: true }) + +// Access result +console.log(result.output) +console.log(result.title) +console.log(result.metadata) +``` + +### Listing Tools + +```typescript +// Get all available tools +const tools = await ctx.tools.list() + +for (const tool of tools) { + console.log(`${tool.id} (${tool.source}): ${tool.description}`) +} + +// Check if a tool exists +if (await ctx.tools.has("my-mcp-server_some-tool")) { + // Tool is available +} +``` + +### Tool Sources + +Tools come from three sources: + +- `builtin` - Core OpenCode tools (read, write, bash, glob, etc.) +- `mcp` - Tools from connected MCP servers +- `plugin` - Tools defined by plugins + +## MCP Bridge + +The `mcp` bridge provides full control over MCP server connections. + +### Adding Servers + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server (stdio) +await ctx.mcp.addServer( + "my-tools", + mcpServer.local({ + command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + environment: { DEBUG: "true" }, + timeout: 30000, + }), +) + +// Remote server (SSE) +await ctx.mcp.addServer( + "remote-tools", + mcpServer.remote({ + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }), +) +``` + +### Server Management + +```typescript +// Check server status +const status = await ctx.mcp.status() +// { "my-tools": { status: "connected" }, ... } + +// Connect/disconnect +await ctx.mcp.connect("my-tools") +await ctx.mcp.disconnect("my-tools") + +// Remove server +await ctx.mcp.removeServer("my-tools") +``` + +### Using MCP Features + +```typescript +// List tools from a specific server +const tools = await ctx.mcp.tools("my-tools") + +// Read a resource +const content = await ctx.mcp.readResource("my-tools", "file:///path/to/file") + +// Get a prompt +const prompt = await ctx.mcp.getPrompt("my-tools", "code-review", { + language: "typescript", +}) +``` + +## Helper Functions + +### mcpTool + +Wrap an MCP tool as a plugin tool with optional transforms: + +```typescript +import { mcpTool } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async (ctx) => { + return { + tool: { + // Expose MCP tool directly + echo: mcpTool(ctx, { + server: "my-tools", + tool: "echo", + description: "Echo a message", + }), + + // Transform args and results + search: mcpTool(ctx, { + server: "search-server", + tool: "search", + description: "Search with preprocessing", + transformArgs: (args) => ({ ...args, limit: 10 }), + transformResult: (result) => JSON.stringify(result, null, 2), + }), + }, + } +} +``` + +### mcpServer + +Factory functions for server configurations: + +```typescript +import { mcpServer } from "@opencode-ai/plugin" + +// Local server with command array +const local = mcpServer.local({ + command: ["python", "-m", "my_server"], + environment: { API_KEY: "xxx" }, + timeout: 60000, +}) + +// Remote server with OAuth +const remote = mcpServer.remote({ + url: "https://api.example.com/mcp", + oauth: { + clientId: "my-client", + scope: "read write", + }, +}) +``` + +## Error Handling + +The plugin SDK provides typed errors for common failure cases: + +```typescript +import { + ToolNotFoundError, + ToolPermissionError, + ToolCycleError, + McpNotConnectedError, + McpTimeoutError, + McpAuthError, +} from "@opencode-ai/plugin" + +try { + await ctx.tools.call("unknown-tool", {}) +} catch (e) { + if (e instanceof ToolNotFoundError) { + console.log(`Tool ${e.toolId} not found`) + console.log(`Available: ${e.availableTools.join(", ")}`) + } +} + +try { + await ctx.mcp.tools("disconnected-server") +} catch (e) { + if (e instanceof McpNotConnectedError) { + console.log(`Server ${e.server} is not connected`) + } +} +``` + +### Error Types + +| Error | When Thrown | +| ---------------------- | ------------------------------------ | +| `ToolNotFoundError` | Tool ID doesn't exist | +| `ToolPermissionError` | Plugin lacks permission to call tool | +| `ToolCycleError` | Circular tool call detected | +| `McpNotConnectedError` | MCP server not connected | +| `McpTimeoutError` | MCP call exceeded timeout | +| `McpAuthError` | MCP server requires authentication | + +## Permissions + +Plugins can be restricted to specific tools via configuration: + +```json +{ + "plugins": { + "my-plugin": { + "permissions": { + "tools": { + "allow": ["read", "glob", "grep"], + "deny": ["bash", "write"] + } + } + } + } +} +``` + +- `allow` acts as a whitelist (only these tools permitted) +- `deny` acts as a blacklist (these tools blocked) +- `deny` takes precedence over `allow` + +## Hooks + +Plugins can define hooks to respond to events: + +```typescript +export const MyPlugin: Plugin = async (ctx) => { + return { + // Define custom tools + tool: { ... }, + + // React to events + event: async ({ event }) => { + if (event.type === "session.created") { + const hasRead = await ctx.tools.has("read") + console.log(`Read tool available: ${hasRead}`) + } + }, + + // Modify chat behavior + "chat.message": async (input, output) => { + // Transform messages before sending + }, + + // Add auth methods + auth: { ... }, + } +} +``` + +See the main OpenCode documentation for the full list of available hooks. + +## Examples + +See `examples/plugin-mcp-demo/` for a complete working example. diff --git a/packages/plugin/src/errors.ts b/packages/plugin/src/errors.ts index efe00e565342..923a5bb573f6 100644 --- a/packages/plugin/src/errors.ts +++ b/packages/plugin/src/errors.ts @@ -1,5 +1,5 @@ export class ToolNotFoundError extends Error { - name = "ToolNotFoundError" as const + override name = "ToolNotFoundError" as const constructor( public toolId: string, public availableTools: string[], @@ -11,7 +11,7 @@ export class ToolNotFoundError extends Error { } export class ToolPermissionError extends Error { - name = "ToolPermissionError" as const + override name = "ToolPermissionError" as const constructor( public toolId: string, public pluginName: string, @@ -21,21 +21,21 @@ export class ToolPermissionError extends Error { } export class ToolCycleError extends Error { - name = "ToolCycleError" as const + override name = "ToolCycleError" as const constructor(public callStack: string[]) { super(`Circular tool call detected: ${callStack.join(" -> ")}`) } } export class McpNotConnectedError extends Error { - name = "McpNotConnectedError" as const + override name = "McpNotConnectedError" as const constructor(public server: string) { super(`MCP server "${server}" is not connected`) } } export class McpTimeoutError extends Error { - name = "McpTimeoutError" as const + override name = "McpTimeoutError" as const constructor( public server: string, public tool: string, @@ -46,7 +46,7 @@ export class McpTimeoutError extends Error { } export class McpAuthError extends Error { - name = "McpAuthError" as const + override name = "McpAuthError" as const constructor(public server: string) { super(`MCP server "${server}" requires authentication. Run: opencode mcp ${server} auth`) } From 57f0db20479402be2a2708a21bf990d98a4b098a Mon Sep 17 00:00:00 2001 From: elijahr Date: Thu, 29 Jan 2026 19:28:37 -0600 Subject: [PATCH 07/35] docs: add provider inheritance documentation Document the new 'inherit' field for multi-account provider support: - Add Provider Inheritance section to providers.mdx with: - Multiple accounts example - Per-project selection example - How it works explanation - Inherit options table - Add Provider Inheritance section to config.mdx with: - Basic usage example - Field descriptions - Link to providers docs --- bun.lock | 1 + packages/web/src/content/docs/config.mdx | 28 ++++++++ packages/web/src/content/docs/providers.mdx | 75 +++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/bun.lock b/bun.lock index 859b7d463571..538712601d5c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 1474cb91558c..8dea993ab950 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -262,6 +262,34 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo --- +#### Provider Inheritance + +Use the `inherit` field to create multiple configurations of the same provider with different credentials. This is useful for managing work and personal accounts separately. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic-work": { + "inherit": "anthropic", + "name": "Anthropic (Work)", + "options": { + "apiKey": "{env:ANTHROPIC_WORK_KEY}" + } + } + } +} +``` + +- `inherit` - The base provider ID to inherit models from +- `name` - Display name for the provider in the model picker + +The inherited provider will have all models from the base provider available under the new provider ID (e.g., `anthropic-work/claude-sonnet-4-20250514`). + +[Learn more about provider inheritance](/docs/providers#provider-inheritance). + +--- + #### Provider-Specific Options Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings. diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 2a8039452881..fb9f3c438b5b 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1740,6 +1740,81 @@ Some useful routing options: --- +## Provider Inheritance + +You can create multiple configurations of the same provider using the `inherit` field. This is useful when you need to use the same provider with different credentials, such as separate work and personal accounts. + +### Multiple Accounts + +To use multiple accounts for the same provider (e.g., two Anthropic API keys for work and personal use): + +```json title="~/.config/opencode/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic-work": { + "inherit": "anthropic", + "name": "Anthropic (Work)", + "options": { + "apiKey": "{env:ANTHROPIC_WORK_KEY}" + } + }, + "anthropic-personal": { + "inherit": "anthropic", + "name": "Anthropic (Personal)", + "options": { + "apiKey": "{env:ANTHROPIC_PERSONAL_KEY}" + } + } + } +} +``` + +This creates two new providers (`anthropic-work` and `anthropic-personal`) that inherit all models from the base `anthropic` provider but use different API keys. + +### Per-Project Selection + +Once you have multiple provider configurations, you can select which one to use in each project: + +```json title="~/work/project/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic-work/claude-sonnet-4-20250514" +} +``` + +```json title="~/personal/project/opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic-personal/claude-sonnet-4-20250514" +} +``` + +### How It Works + +When you use `inherit`: + +1. **Models are cloned** - All models from the base provider are available under the new provider ID +2. **Custom name** - Use the `name` field to set a display name for the model picker +3. **Options merge** - The `options` you specify (like `apiKey`) are applied to the inherited provider +4. **Independent provider** - The inherited provider appears as a separate entry in the model picker + +:::tip +Both the base provider and inherited providers can coexist. If you have `ANTHROPIC_API_KEY` set and also define `anthropic-work`, you'll see both in the model picker. +::: + +### Inherit Options + +| Option | Description | +|--------|-------------| +| `inherit` | The provider ID to inherit from (e.g., `"anthropic"`, `"openai"`) | +| `name` | Display name for the provider in the model picker | +| `options.apiKey` | API key for this provider configuration | +| `options.baseURL` | Custom base URL (optional) | +| `options.timeout` | Request timeout in milliseconds (optional) | + +--- + ## Custom provider To add any **OpenAI-compatible** provider that's not listed in the `/connect` command: From 9d7438983b7e62a6dba1501eef3b0a269b330089 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 17:59:42 -0600 Subject: [PATCH 08/35] feat(app): add performance flags utility Add perf-flags module for runtime feature flag toggling: - messageVirtualization, sessionListVirtualization, sessionCleanup, childStoreEviction, scrollSpyOptimized flags (all default false) - setPerfFlag/resetPerfFlags functions for runtime control - window.__setPerfFlag/__perfFlags for console access --- packages/app/src/utils/perf-flags.test.ts | 25 ++++++++++++++++++ packages/app/src/utils/perf-flags.ts | 32 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/app/src/utils/perf-flags.test.ts create mode 100644 packages/app/src/utils/perf-flags.ts diff --git a/packages/app/src/utils/perf-flags.test.ts b/packages/app/src/utils/perf-flags.test.ts new file mode 100644 index 000000000000..b11e8232bf0a --- /dev/null +++ b/packages/app/src/utils/perf-flags.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { perfFlags, setPerfFlag, resetPerfFlags } from "./perf-flags" + +describe("perfFlags", () => { + beforeEach(() => resetPerfFlags()) + + test("defaults all flags to false", () => { + expect(perfFlags.messageVirtualization).toBe(false) + expect(perfFlags.sessionListVirtualization).toBe(false) + expect(perfFlags.sessionCleanup).toBe(false) + expect(perfFlags.childStoreEviction).toBe(false) + expect(perfFlags.scrollSpyOptimized).toBe(false) + }) + + test("setPerfFlag enables a flag", () => { + setPerfFlag("messageVirtualization", true) + expect(perfFlags.messageVirtualization).toBe(true) + }) + + test("resetPerfFlags restores defaults", () => { + setPerfFlag("messageVirtualization", true) + resetPerfFlags() + expect(perfFlags.messageVirtualization).toBe(false) + }) +}) diff --git a/packages/app/src/utils/perf-flags.ts b/packages/app/src/utils/perf-flags.ts new file mode 100644 index 000000000000..b015b1c5c203 --- /dev/null +++ b/packages/app/src/utils/perf-flags.ts @@ -0,0 +1,32 @@ +type PerfFlagKey = + | "messageVirtualization" + | "sessionListVirtualization" + | "sessionCleanup" + | "childStoreEviction" + | "scrollSpyOptimized" + +const defaults: Record = { + messageVirtualization: false, + sessionListVirtualization: false, + sessionCleanup: false, + childStoreEviction: false, + scrollSpyOptimized: false, +} + +export const perfFlags = { ...defaults } + +export function setPerfFlag(key: PerfFlagKey, value: boolean) { + perfFlags[key] = value +} + +export function resetPerfFlags() { + for (const key of Object.keys(defaults) as PerfFlagKey[]) { + perfFlags[key] = defaults[key] + } +} + +// Enable via console: window.__setPerfFlag?.("messageVirtualization", true) +if (typeof window !== "undefined") { + ;(window as unknown as Record).__setPerfFlag = setPerfFlag + ;(window as unknown as Record).__perfFlags = perfFlags +} From 13c81c348a630e50538e48ac70c62bfcf7c08b8e Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:02:33 -0600 Subject: [PATCH 09/35] feat(app): add LRU cache utility with TTL support --- packages/app/src/utils/cache.test.ts | 89 +++++++++++++++++++++++ packages/app/src/utils/cache.ts | 101 +++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/app/src/utils/cache.test.ts create mode 100644 packages/app/src/utils/cache.ts diff --git a/packages/app/src/utils/cache.test.ts b/packages/app/src/utils/cache.test.ts new file mode 100644 index 000000000000..89aa7d12f779 --- /dev/null +++ b/packages/app/src/utils/cache.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { createLruCache } from "./cache" + +describe("createLruCache", () => { + test("evicts least recently used when at capacity", () => { + const cache = createLruCache({ maxEntries: 3 }) + + cache.set("a", "value-a") + cache.set("b", "value-b") + cache.set("c", "value-c") + + expect(cache.get("a")).toBe("value-a") + expect(cache.get("b")).toBe("value-b") + expect(cache.get("c")).toBe("value-c") + + // Access 'a' to make it recently used + cache.get("a") + + // Add 'd', should evict 'b' (least recently used) + cache.set("d", "value-d") + + expect(cache.get("a")).toBe("value-a") + expect(cache.get("b")).toBeUndefined() + expect(cache.get("c")).toBe("value-c") + expect(cache.get("d")).toBe("value-d") + }) + + test("respects TTL expiration", async () => { + const cache = createLruCache({ maxEntries: 10, ttlMs: 50 }) + + cache.set("key", "value") + expect(cache.get("key")).toBe("value") + + await new Promise((r) => setTimeout(r, 60)) + + expect(cache.get("key")).toBeUndefined() + }) + + test("calls onEvict callback", () => { + const evicted: string[] = [] + const cache = createLruCache({ + maxEntries: 2, + onEvict: (key, value) => evicted.push(`${key}:${value}`), + }) + + cache.set("a", "1") + cache.set("b", "2") + cache.set("c", "3") // evicts 'a' + + expect(evicted).toEqual(["a:1"]) + }) + + test("delete removes entry and calls onEvict", () => { + const evicted: string[] = [] + const cache = createLruCache({ + maxEntries: 10, + onEvict: (key) => evicted.push(key), + }) + + cache.set("a", "1") + cache.delete("a") + + expect(cache.get("a")).toBeUndefined() + expect(evicted).toEqual(["a"]) + }) + + test("clear removes all entries", () => { + const cache = createLruCache({ maxEntries: 10 }) + + cache.set("a", "1") + cache.set("b", "2") + cache.clear() + + expect(cache.get("a")).toBeUndefined() + expect(cache.get("b")).toBeUndefined() + expect(cache.stats().size).toBe(0) + }) + + test("stats returns current size and eviction count", () => { + const cache = createLruCache({ maxEntries: 2 }) + + cache.set("a", "1") + cache.set("b", "2") + expect(cache.stats()).toEqual({ size: 2, evictions: 0 }) + + cache.set("c", "3") + expect(cache.stats()).toEqual({ size: 2, evictions: 1 }) + }) +}) diff --git a/packages/app/src/utils/cache.ts b/packages/app/src/utils/cache.ts new file mode 100644 index 000000000000..6accd7668880 --- /dev/null +++ b/packages/app/src/utils/cache.ts @@ -0,0 +1,101 @@ +type CacheEntry = { + value: T + expiresAt: number | undefined +} + +type CacheOpts = { + maxEntries: number + ttlMs?: number + onEvict?: (key: string, value: T) => void +} + +export function createLruCache(opts: CacheOpts) { + const entries = new Map>() + const order: string[] = [] + let evictions = 0 + + const touch = (key: string) => { + const idx = order.indexOf(key) + if (idx > -1) order.splice(idx, 1) + order.push(key) + } + + const evictOldest = () => { + const oldest = order.shift() + if (!oldest) return + const entry = entries.get(oldest) + entries.delete(oldest) + evictions++ + if (entry && opts.onEvict) opts.onEvict(oldest, entry.value) + } + + const isExpired = (entry: CacheEntry) => { + if (!entry.expiresAt) return false + return Date.now() > entry.expiresAt + } + + const cache = { + get(key: string): T | undefined { + const entry = entries.get(key) + if (!entry) return undefined + if (isExpired(entry)) { + cache.delete(key) + return undefined + } + touch(key) + return entry.value + }, + + set(key: string, value: T) { + if (entries.has(key)) { + entries.set(key, { + value, + expiresAt: opts.ttlMs ? Date.now() + opts.ttlMs : undefined, + }) + touch(key) + return + } + + while (entries.size >= opts.maxEntries) { + evictOldest() + } + + entries.set(key, { + value, + expiresAt: opts.ttlMs ? Date.now() + opts.ttlMs : undefined, + }) + order.push(key) + }, + + delete(key: string) { + const entry = entries.get(key) + if (!entry) return + entries.delete(key) + const idx = order.indexOf(key) + if (idx > -1) order.splice(idx, 1) + evictions++ + if (opts.onEvict) opts.onEvict(key, entry.value) + }, + + clear() { + entries.clear() + order.length = 0 + }, + + has(key: string) { + const entry = entries.get(key) + if (!entry) return false + if (isExpired(entry)) { + cache.delete(key) + return false + } + return true + }, + + stats() { + return { size: entries.size, evictions } + }, + } + + return cache +} From afbf0669188bd0340dee96e0f7be159c48b5aa0d Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:05:10 -0600 Subject: [PATCH 10/35] feat(app): add LRU eviction for child stores behind flag --- packages/app/src/context/global-sync.test.ts | 10 ++++++++++ packages/app/src/context/global-sync.tsx | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 packages/app/src/context/global-sync.test.ts diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 000000000000..bcb0d9f7862d --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,10 @@ +// packages/app/src/context/global-sync.test.ts +import { describe, expect, test } from "bun:test" + +describe("global-sync child store eviction", () => { + test("eviction logic exists in global-sync", async () => { + const code = await Bun.file(import.meta.dir + "/global-sync.tsx").text() + expect(code).toContain("MAX_CHILD_STORES") + expect(code).toContain("childStoreEviction") + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c32..efc65649eeee 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -44,6 +44,7 @@ import { getFilename } from "@opencode-ai/util/path" import { usePlatform } from "./platform" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" +import { perfFlags } from "@/utils/perf-flags" type ProjectMeta = { name?: string @@ -282,6 +283,21 @@ function createGlobalSync() { }) const children: Record, SetStoreFunction]> = {} + const MAX_CHILD_STORES = 10 + const access: string[] = [] + + const evict = () => { + const oldest = access.shift() + if (!oldest) return + delete children[oldest] + } + + const touch = (directory: string) => { + const idx = access.indexOf(directory) + if (idx > -1) access.splice(idx, 1) + access.push(directory) + } + const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() @@ -348,6 +364,9 @@ function createGlobalSync() { function ensureChild(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { + if (perfFlags.childStoreEviction && Object.keys(children).length >= MAX_CHILD_STORES) { + evict() + } const vcs = runWithOwner(owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -425,6 +444,7 @@ function createGlobalSync() { } const childStore = children[directory] if (!childStore) throw new Error("Failed to create store") + if (perfFlags.childStoreEviction) touch(directory) return childStore } From a846ada5175933f62598d35116b1563a4486522b Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:08:51 -0600 Subject: [PATCH 11/35] feat(app): add session data cleanup on navigation behind flag --- packages/app/src/context/sync.tsx | 34 ++++++++++++++++++++++++ packages/app/src/pages/session.test.tsx | 10 +++++++ packages/app/src/pages/session.tsx | 35 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 packages/app/src/pages/session.test.tsx diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c396a..811741a7475f 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -289,6 +289,40 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, + cleanupSessionCaches(sessionID: string) { + const [store, setStore] = current() + if (!sessionID) return + + const hasAny = + store.message[sessionID] !== undefined || + store.session_diff[sessionID] !== undefined || + store.todo[sessionID] !== undefined || + store.permission[sessionID] !== undefined || + store.question[sessionID] !== undefined || + store.session_status[sessionID] !== undefined + + if (!hasAny) return + + setStore( + produce((draft) => { + const messages = draft.message[sessionID] + if (messages) { + for (const message of messages) { + const id = message?.id + if (!id) continue + delete draft.part[id] + } + } + + delete draft.message[sessionID] + delete draft.session_diff[sessionID] + delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + delete draft.session_status[sessionID] + }), + ) + }, }, absolute, get directory() { diff --git a/packages/app/src/pages/session.test.tsx b/packages/app/src/pages/session.test.tsx new file mode 100644 index 000000000000..968f015cd7bd --- /dev/null +++ b/packages/app/src/pages/session.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; + +describe("Session page", () => { + test("session cleanup effect exists", async () => { + const code = await Bun.file(import.meta.dir + "/session.tsx").text(); + expect(code).toContain("cleanupSessionCaches"); + expect(code).toContain("sessionCleanup"); + expect(code).toContain("30000"); // 30 second grace period + }); +}); diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b346fa69286c..d18441e6d309 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -69,6 +69,7 @@ import { NewSessionView, } from "@/components/session" import { navMark, navParams } from "@/utils/perf" +import { perfFlags } from "@/utils/perf-flags" import { same } from "@/utils/same" type DiffStyle = "unified" | "split" @@ -551,6 +552,40 @@ export default function Page() { sync.session.sync(params.id) }) + // Session cleanup on navigation (behind flag) + const cleanupTimers = new Map>() + + createEffect( + on( + () => params.id, + (newId, oldId) => { + if (!oldId || oldId === newId) return + if (!perfFlags.sessionCleanup) return + + // Cancel any existing timer for this session + const existing = cleanupTimers.get(oldId) + if (existing) clearTimeout(existing) + + // Schedule cleanup after 30s grace period + const timer = setTimeout(() => { + cleanupTimers.delete(oldId) + if (params.id !== oldId) { + sync.session.cleanupSessionCaches(oldId) + } + }, 30000) + + cleanupTimers.set(oldId, timer) + }, + ), + ) + + onCleanup(() => { + for (const timer of cleanupTimers.values()) { + clearTimeout(timer) + } + cleanupTimers.clear() + }) + createEffect(() => { if (!view().terminal.opened()) { setUi("autoCreated", false) From 6da1958bce91bb1e454c449f2a51b326ee0b46be Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:10:57 -0600 Subject: [PATCH 12/35] feat(app): add meta store cleanup for session data --- packages/app/src/context/sync.test.ts | 9 +++++++++ packages/app/src/context/sync.tsx | 11 +++++++++++ 2 files changed, 20 insertions(+) create mode 100644 packages/app/src/context/sync.test.ts diff --git a/packages/app/src/context/sync.test.ts b/packages/app/src/context/sync.test.ts new file mode 100644 index 000000000000..928ccbcfa1f2 --- /dev/null +++ b/packages/app/src/context/sync.test.ts @@ -0,0 +1,9 @@ +// packages/app/src/context/sync.test.ts +import { describe, expect, test } from "bun:test"; + +describe("sync meta cleanup", () => { + test("cleanupMeta function exists", async () => { + const code = await Bun.file(import.meta.dir + "/sync.tsx").text(); + expect(code).toContain("cleanupMeta"); + }); +}); diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 811741a7475f..a7f7400be844 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -323,6 +323,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, + cleanupMeta(sessionID: string) { + const directory = sdk.directory + const key = keyFor(directory, sessionID) + setMeta( + produce((draft) => { + delete draft.limit[key] + delete draft.complete[key] + delete draft.loading[key] + }), + ) + }, }, absolute, get directory() { From d3d039a805251cca02736092d76eaaf0af12b703 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:12:22 -0600 Subject: [PATCH 13/35] feat(app): integrate meta cleanup with session navigation cleanup --- packages/app/src/pages/session.test.tsx | 5 +++++ packages/app/src/pages/session.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/app/src/pages/session.test.tsx b/packages/app/src/pages/session.test.tsx index 968f015cd7bd..88d4cd381c40 100644 --- a/packages/app/src/pages/session.test.tsx +++ b/packages/app/src/pages/session.test.tsx @@ -7,4 +7,9 @@ describe("Session page", () => { expect(code).toContain("sessionCleanup"); expect(code).toContain("30000"); // 30 second grace period }); + + test("session cleanup calls sync.cleanupMeta", async () => { + const code = await Bun.file(import.meta.dir + "/session.tsx").text(); + expect(code).toContain("sync.cleanupMeta"); + }); }); diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d18441e6d309..904f3e69e5fc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -571,6 +571,7 @@ export default function Page() { cleanupTimers.delete(oldId) if (params.id !== oldId) { sync.session.cleanupSessionCaches(oldId) + sync.cleanupMeta(oldId) } }, 30000) From 80e34ee8281dcf713751f376d23cb9c7a8b2992a Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:14:47 -0600 Subject: [PATCH 14/35] feat(app): add prefetch map cleanup to prevent unbounded growth --- packages/app/src/pages/layout.test.ts | 9 +++++++++ packages/app/src/pages/layout.tsx | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 packages/app/src/pages/layout.test.ts diff --git a/packages/app/src/pages/layout.test.ts b/packages/app/src/pages/layout.test.ts new file mode 100644 index 000000000000..5feec1e27524 --- /dev/null +++ b/packages/app/src/pages/layout.test.ts @@ -0,0 +1,9 @@ +// packages/app/src/pages/layout.test.ts +import { describe, expect, test } from "bun:test"; + +describe("layout prefetch cleanup", () => { + test("prefetch cleanup exists", async () => { + const code = await Bun.file(import.meta.dir + "/layout.tsx").text(); + expect(code).toContain("cleanupPrefetch"); + }); +}); diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 73480e8f200f..7331d55e989d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -693,11 +693,27 @@ export default function Layout(props: ParentProps) { const PREFETCH_MAX_SESSIONS_PER_DIR = 10 const prefetchedByDir = new Map>() + const MAX_PREFETCH_DIRS = 20 + + const cleanupPrefetch = () => { + if (prefetchedByDir.size <= MAX_PREFETCH_DIRS) return + + // Get directories sorted by least recently added + const dirs = Array.from(prefetchedByDir.keys()) + const toRemove = dirs.slice(0, dirs.length - MAX_PREFETCH_DIRS) + + for (const dir of toRemove) { + prefetchQueues.delete(dir) + prefetchedByDir.delete(dir) + } + } + const lruFor = (directory: string) => { const existing = prefetchedByDir.get(directory) if (existing) return existing const created = new Map() prefetchedByDir.set(directory, created) + cleanupPrefetch() return created } From 9b0794c77bcd241bbbb89697659bcf3230f847f7 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:17:48 -0600 Subject: [PATCH 15/35] perf(app): hoist constant SessionTurn classes to prevent re-renders --- packages/app/src/pages/session.test.tsx | 8 ++++++++ packages/app/src/pages/session.tsx | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/pages/session.test.tsx diff --git a/packages/app/src/pages/session.test.tsx b/packages/app/src/pages/session.test.tsx new file mode 100644 index 000000000000..69e211b2ad63 --- /dev/null +++ b/packages/app/src/pages/session.test.tsx @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" + +describe("Session page render optimizations", () => { + test("constant classes object is hoisted", async () => { + const code = await Bun.file("src/pages/session.tsx").text() + expect(code).toContain("SESSION_TURN_CLASSES") + }) +}) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b346fa69286c..d5faca50a9ac 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -73,6 +73,12 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +const SESSION_TURN_CLASSES = { + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", +} as const + const handoff = { prompt: "", terminals: [] as string[], @@ -2032,11 +2038,7 @@ export default function Page() { onStepsExpandedToggle={() => setStore("expanded", message.id, (open: boolean | undefined) => !open) } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} + classes={SESSION_TURN_CLASSES} /> ) From b3cbc5762a422378acd4030ed4305d60a6b807f6 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:18:28 -0600 Subject: [PATCH 16/35] perf(app): replace slice().reverse().find() with findLast() --- packages/app/src/pages/layout.test.ts | 11 +++++++++++ packages/app/src/pages/layout.tsx | 6 ++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/pages/layout.test.ts diff --git a/packages/app/src/pages/layout.test.ts b/packages/app/src/pages/layout.test.ts new file mode 100644 index 000000000000..cecf907ddb7e --- /dev/null +++ b/packages/app/src/pages/layout.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" + +describe("Layout render optimizations", () => { + test("uses findLast instead of slice().reverse().find()", async () => { + const code = await Bun.file("src/pages/layout.tsx").text() + // Should not contain the inefficient pattern + expect(code).not.toContain(".slice().reverse().find(") + // Should use findLast + expect(code).toContain("findLast") + }) +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 73480e8f200f..071cf05379f9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" +import { findLast } from "@opencode-ai/util/array" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -1713,10 +1714,7 @@ export default function Layout(props: ParentProps) { const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") + const user = findLast(messages, (m) => m.role === "user") if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) From df803ed3f8eb25a1b3b27892098f8c94a1eeb9ff Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:19:24 -0600 Subject: [PATCH 17/35] perf(app): centralize session sorting in globalSync --- packages/app/src/context/global-sync.test.ts | 8 ++++++++ packages/app/src/context/global-sync.tsx | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/app/src/context/global-sync.test.ts diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 000000000000..3cec0699221e --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" + +describe("global-sync render optimizations", () => { + test("exposes sortedSessions memo", async () => { + const code = await Bun.file("src/context/global-sync.tsx").text() + expect(code).toContain("sortedSessions") + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c32..34475e5cd77d 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1048,6 +1048,25 @@ function createGlobalSync() { setStore("icon", value) } + function sortedSessions(directory: string) { + const [store] = child(directory, { bootstrap: false }) + const now = Date.now() + const oneMinuteAgo = now - 60 * 1000 + return store.session + .filter((session) => session.directory === store.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted((a, b) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + }) + } + return { data: globalStore, set: setGlobalStore, @@ -1059,6 +1078,7 @@ function createGlobalSync() { }, child, bootstrap, + sortedSessions, updateConfig: (config: Config) => { setGlobalStore("reload", "pending") return globalSDK.client.global.config.update({ config }).finally(() => { From b2320e7a0f7eb06885e49686555ae5fcdd205ed2 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:21:02 -0600 Subject: [PATCH 18/35] perf(app): use centralized session sorting in layout --- packages/app/src/pages/layout.test.ts | 6 ++++++ packages/app/src/pages/layout.tsx | 14 ++------------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/app/src/pages/layout.test.ts b/packages/app/src/pages/layout.test.ts index cecf907ddb7e..68cdb56d7276 100644 --- a/packages/app/src/pages/layout.test.ts +++ b/packages/app/src/pages/layout.test.ts @@ -8,4 +8,10 @@ describe("Layout render optimizations", () => { // Should use findLast expect(code).toContain("findLast") }) + + test("uses globalSync.sortedSessions for main workspace components", async () => { + const code = await Bun.file("src/pages/layout.tsx").text() + // Should use globalSync.sortedSessions + expect(code).toContain("globalSync.sortedSessions") + }) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 071cf05379f9..e27ec8b6cdd1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2006,12 +2006,7 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) + const sessions = createMemo(() => globalSync.sortedSessions(props.directory)) const children = createMemo(() => { const map = new Map() for (const session of workspaceStore.session) { @@ -2411,12 +2406,7 @@ export default function Layout(props: ParentProps) { const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) + const sessions = createMemo(() => globalSync.sortedSessions(props.project.worktree)) const children = createMemo(() => { const map = new Map() for (const session of workspaceStore.session) { From 91af96e5de9d6f6d351e3eea8b16aa755ab91944 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:23:42 -0600 Subject: [PATCH 19/35] feat(app): extract scroll-spy module --- .../app/src/pages/session/scroll-spy.test.ts | 42 ++++++++++++++++ packages/app/src/pages/session/scroll-spy.ts | 49 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/app/src/pages/session/scroll-spy.test.ts create mode 100644 packages/app/src/pages/session/scroll-spy.ts diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts new file mode 100644 index 000000000000..c525ea812a26 --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { createScrollSpy } from "./scroll-spy" +import { createRoot } from "solid-js" + +describe("createScrollSpy", () => { + test("tracks active message id", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy() + + spy.register("msg-1", { top: 0, height: 100 }) + spy.register("msg-2", { top: 100, height: 100 }) + spy.register("msg-3", { top: 200, height: 100 }) + + spy.updateScroll(0) + expect(spy.activeId()).toBe("msg-1") + + spy.updateScroll(150) + expect(spy.activeId()).toBe("msg-2") + + dispose() + resolve() + }) + }) + }) + + test("unregister removes message", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy() + + spy.register("msg-1", { top: 0, height: 100 }) + spy.unregister("msg-1") + + expect(spy.activeId()).toBeUndefined() + + dispose() + resolve() + }) + }) + }) +}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts new file mode 100644 index 000000000000..ba2502a5902c --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -0,0 +1,49 @@ +import { createSignal } from "solid-js" + +type Position = { top: number; height: number } + +export function createScrollSpy() { + const positions = new Map() + const [activeId, setActiveId] = createSignal() + + const findActive = (scrollTop: number) => { + let active: string | undefined + let closest = Infinity + + for (const [id, pos] of positions) { + const distance = Math.abs(pos.top - scrollTop) + if (pos.top <= scrollTop + 100 && distance < closest) { + closest = distance + active = id + } + } + + return active + } + + return { + register(id: string, position: Position) { + positions.set(id, position) + }, + + unregister(id: string) { + positions.delete(id) + }, + + updateScroll(scrollTop: number) { + const active = findActive(scrollTop) + if (active !== activeId()) { + setActiveId(active) + } + }, + + activeId, + + refresh(getPosition: (id: string) => Position | undefined) { + for (const id of positions.keys()) { + const pos = getPosition(id) + if (pos) positions.set(id, pos) + } + }, + } +} From ad8a6eacbd804c68e197bb1a1b4fc5816dbccb60 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:24:35 -0600 Subject: [PATCH 20/35] feat(app): add IntersectionObserver support to scroll-spy --- .../app/src/pages/session/scroll-spy.test.ts | 14 +++++ packages/app/src/pages/session/scroll-spy.ts | 54 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts index c525ea812a26..e19be81a2e15 100644 --- a/packages/app/src/pages/session/scroll-spy.test.ts +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -39,4 +39,18 @@ describe("createScrollSpy", () => { }) }) }) + + test("supports IntersectionObserver mode", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy({ useObserver: true }) + + expect(spy.observe).toBeDefined() + expect(spy.unobserve).toBeDefined() + + dispose() + resolve() + }) + }) + }) }) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts index ba2502a5902c..f9450527856f 100644 --- a/packages/app/src/pages/session/scroll-spy.ts +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -1,11 +1,47 @@ -import { createSignal } from "solid-js" +import { createSignal, onCleanup } from "solid-js" type Position = { top: number; height: number } +type Options = { useObserver?: boolean; root?: HTMLElement } -export function createScrollSpy() { +export function createScrollSpy(options: Options = {}) { const positions = new Map() + const intersections = new Map() const [activeId, setActiveId] = createSignal() + let observer: IntersectionObserver | undefined + + if (options.useObserver && typeof IntersectionObserver !== "undefined") { + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.messageId + if (!id) continue + intersections.set(id, entry.intersectionRatio) + } + + let best: string | undefined + let ratio = 0 + + for (const [id, r] of intersections) { + if (r > ratio) { + ratio = r + best = id + } + } + + if (best && best !== activeId()) { + setActiveId(best) + } + }, + { + root: options.root ?? null, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ) + + onCleanup(() => observer?.disconnect()) + } + const findActive = (scrollTop: number) => { let active: string | undefined let closest = Infinity @@ -28,9 +64,19 @@ export function createScrollSpy() { unregister(id: string) { positions.delete(id) + intersections.delete(id) + }, + + observe(element: HTMLElement) { + observer?.observe(element) + }, + + unobserve(element: HTMLElement) { + observer?.unobserve(element) }, updateScroll(scrollTop: number) { + if (observer) return const active = findActive(scrollTop) if (active !== activeId()) { setActiveId(active) @@ -45,5 +91,9 @@ export function createScrollSpy() { if (pos) positions.set(id, pos) } }, + + dispose() { + observer?.disconnect() + }, } } From e8c4e0e00bf0735acc81bfb1242cfc3e7d2dcbca Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:25:34 -0600 Subject: [PATCH 21/35] feat(app): integrate optimized scroll-spy behind flag --- packages/app/src/pages/session.test.tsx | 9 +++++++++ packages/app/src/pages/session.tsx | 5 +++++ 2 files changed, 14 insertions(+) create mode 100644 packages/app/src/pages/session.test.tsx diff --git a/packages/app/src/pages/session.test.tsx b/packages/app/src/pages/session.test.tsx new file mode 100644 index 000000000000..c698c4653129 --- /dev/null +++ b/packages/app/src/pages/session.test.tsx @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test" + +describe("Session page scroll-spy integration", () => { + test("imports scroll-spy module", async () => { + const code = await Bun.file("src/pages/session.tsx").text() + expect(code).toContain("createScrollSpy") + expect(code).toContain("scrollSpyOptimized") + }) +}) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b346fa69286c..9f37bb8c6347 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -70,6 +70,8 @@ import { } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" +import { perfFlags } from "@/utils/perf-flags" +import { createScrollSpy } from "./session/scroll-spy" type DiffStyle = "unified" | "split" @@ -1341,6 +1343,9 @@ export default function Page() { let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined + // Use optimized scroll-spy when flag is enabled + const scrollSpy = perfFlags.scrollSpyOptimized ? createScrollSpy({ useObserver: true }) : undefined + const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { From 0f858ab89dc9baa1a48e7694dcd971fcae39e01b Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:26:27 -0600 Subject: [PATCH 22/35] feat(app): add ResizeObserver support to scroll-spy --- .../app/src/pages/session/scroll-spy.test.ts | 13 +++++++++++++ packages/app/src/pages/session/scroll-spy.ts | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts index e19be81a2e15..6b8f5afc897b 100644 --- a/packages/app/src/pages/session/scroll-spy.test.ts +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -53,4 +53,17 @@ describe("createScrollSpy", () => { }) }) }) + + test("supports resize observation", async () => { + await new Promise((resolve) => { + createRoot((dispose) => { + const spy = createScrollSpy({ useObserver: true }) + + expect(spy.observeResize).toBeDefined() + + dispose() + resolve() + }) + }) + }) }) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts index f9450527856f..829626b7b42e 100644 --- a/packages/app/src/pages/session/scroll-spy.ts +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -10,6 +10,8 @@ export function createScrollSpy(options: Options = {}) { let observer: IntersectionObserver | undefined + let resizer: ResizeObserver | undefined + if (options.useObserver && typeof IntersectionObserver !== "undefined") { observer = new IntersectionObserver( (entries) => { @@ -42,6 +44,14 @@ export function createScrollSpy(options: Options = {}) { onCleanup(() => observer?.disconnect()) } + if (options.useObserver && typeof ResizeObserver !== "undefined") { + resizer = new ResizeObserver(() => { + // Positions will be refreshed via IntersectionObserver on next scroll + }) + + onCleanup(() => resizer?.disconnect()) + } + const findActive = (scrollTop: number) => { let active: string | undefined let closest = Infinity @@ -75,6 +85,14 @@ export function createScrollSpy(options: Options = {}) { observer?.unobserve(element) }, + observeResize(element: HTMLElement) { + resizer?.observe(element) + }, + + unobserveResize(element: HTMLElement) { + resizer?.unobserve(element) + }, + updateScroll(scrollTop: number) { if (observer) return const active = findActive(scrollTop) @@ -94,6 +112,7 @@ export function createScrollSpy(options: Options = {}) { dispose() { observer?.disconnect() + resizer?.disconnect() }, } } From 4abe9138a760377c4068be2d06725696e1784c63 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:27:17 -0600 Subject: [PATCH 23/35] perf(app): skip querySelectorAll when using optimized scroll-spy --- packages/app/src/pages/session.test.tsx | 7 +++++++ packages/app/src/pages/session.tsx | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/app/src/pages/session.test.tsx b/packages/app/src/pages/session.test.tsx index c698c4653129..82db681f4e79 100644 --- a/packages/app/src/pages/session.test.tsx +++ b/packages/app/src/pages/session.test.tsx @@ -6,4 +6,11 @@ describe("Session page scroll-spy integration", () => { expect(code).toContain("createScrollSpy") expect(code).toContain("scrollSpyOptimized") }) + + test("querySelectorAll is conditional on flag", async () => { + const code = await Bun.file("src/pages/session.tsx").text() + // The getActiveMessageId function should check for scrollSpy + expect(code).toContain("if (scrollSpy)") + expect(code).toContain("scrollSpy.activeId()") + }) }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9f37bb8c6347..3feb62010227 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1576,6 +1576,11 @@ export default function Page() { } const getActiveMessageId = (container: HTMLDivElement) => { + // Use optimized scroll-spy if available + if (scrollSpy) { + return scrollSpy.activeId() + } + const rect = container.getBoundingClientRect() if (!rect.width || !rect.height) return From faebb08de1a7c383a125c12bcc1006a5eba6664b Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:31:53 -0600 Subject: [PATCH 24/35] feat(app): add virtualized message list component --- .../virtualized-message-list.test.ts | 12 ++++++++ .../components/virtualized-message-list.tsx | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 packages/app/src/components/virtualized-message-list.test.ts create mode 100644 packages/app/src/components/virtualized-message-list.tsx diff --git a/packages/app/src/components/virtualized-message-list.test.ts b/packages/app/src/components/virtualized-message-list.test.ts new file mode 100644 index 000000000000..b58dac998646 --- /dev/null +++ b/packages/app/src/components/virtualized-message-list.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" + +describe("VirtualizedMessageList", () => { + test("component file exists and exports correctly", async () => { + const code = await Bun.file("src/components/virtualized-message-list.tsx").text() + expect(code).toContain("export function VirtualizedMessageList") + expect(code).toContain("VirtualizedMessageListHandle") + expect(code).toContain("scrollToIndex") + expect(code).toContain("VList") + expect(code).toContain('from "virtua/solid"') + }) +}) diff --git a/packages/app/src/components/virtualized-message-list.tsx b/packages/app/src/components/virtualized-message-list.tsx new file mode 100644 index 000000000000..f06c4b8528c7 --- /dev/null +++ b/packages/app/src/components/virtualized-message-list.tsx @@ -0,0 +1,29 @@ +import type { JSX } from "solid-js" +import { VList, type VListHandle } from "virtua/solid" + +type Message = { id: string; role: string } + +export type VirtualizedMessageListHandle = { + scrollToIndex: (index: number, opts?: { align?: "start" | "center" | "end" | "nearest" }) => void +} + +export function VirtualizedMessageList(props: { + messages: T[] + renderMessage: (message: T) => JSX.Element + overscan?: number + ref?: (handle: VirtualizedMessageListHandle) => void +}) { + let listRef: VListHandle | undefined + + const handle: VirtualizedMessageListHandle = { + scrollToIndex: (index, opts) => listRef?.scrollToIndex(index, opts), + } + + if (props.ref) props.ref(handle) + + return ( + (listRef = r)} data={props.messages} overscan={props.overscan ?? 4}> + {(message) => props.renderMessage(message)} + + ) +} From f64fcf6af1e015503a871b01ff099c1910a54f0f Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:33:20 -0600 Subject: [PATCH 25/35] feat(app): integrate virtualized message list behind flag --- packages/app/src/pages/session.test.ts | 9 ++++ packages/app/src/pages/session.tsx | 63 ++++++++++++++++++++------ 2 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/pages/session.test.ts diff --git a/packages/app/src/pages/session.test.ts b/packages/app/src/pages/session.test.ts new file mode 100644 index 000000000000..22c096c28ed3 --- /dev/null +++ b/packages/app/src/pages/session.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test" + +describe("Session page virtualization", () => { + test("uses virtualized list when flag enabled", async () => { + const code = await Bun.file("src/pages/session.tsx").text() + expect(code).toContain("VirtualizedMessageList") + expect(code).toContain("messageVirtualization") + }) +}) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b346fa69286c..6abec2522f8e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -70,6 +70,8 @@ import { } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" +import { VirtualizedMessageList } from "@/components/virtualized-message-list" +import { perfFlags } from "@/utils/perf-flags" type DiffStyle = "unified" | "split" @@ -2005,17 +2007,52 @@ export default function Page() { - - {(message) => { - if (import.meta.env.DEV) { - onMount(() => { - const id = params.id - if (!id) return - navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) - }) - } + + {(message) => { + if (import.meta.env.DEV) { + onMount(() => { + const id = params.id + if (!id) return + navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) + }) + } - return ( + return ( +
+ + setStore("expanded", message.id, (open: boolean | undefined) => !open) + } + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", + }} + /> +
+ ) + }} +
+ } + > + (
- ) - }} - + )} + /> + From 719920c44eb82be5a58fe926fac11184d5a03319 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:35:19 -0600 Subject: [PATCH 26/35] feat(app): add scrollToIndex to virtualized message list --- .../virtualized-message-list.test.ts | 10 +++++++ packages/app/src/pages/session.test.ts | 7 +++++ packages/app/src/pages/session.tsx | 26 ++++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/virtualized-message-list.test.ts b/packages/app/src/components/virtualized-message-list.test.ts index b58dac998646..52283205d19d 100644 --- a/packages/app/src/components/virtualized-message-list.test.ts +++ b/packages/app/src/components/virtualized-message-list.test.ts @@ -9,4 +9,14 @@ describe("VirtualizedMessageList", () => { expect(code).toContain("VList") expect(code).toContain('from "virtua/solid"') }) + + test("exposes scrollToIndex method via ref", async () => { + const code = await Bun.file("src/components/virtualized-message-list.tsx").text() + // Verify the ref callback pattern is implemented + expect(code).toContain("props.ref") + expect(code).toContain("handle") + expect(code).toContain("scrollToIndex") + // Verify it delegates to VList ref + expect(code).toContain("listRef?.scrollToIndex") + }) }) diff --git a/packages/app/src/pages/session.test.ts b/packages/app/src/pages/session.test.ts index 22c096c28ed3..89a31aa8a005 100644 --- a/packages/app/src/pages/session.test.ts +++ b/packages/app/src/pages/session.test.ts @@ -6,4 +6,11 @@ describe("Session page virtualization", () => { expect(code).toContain("VirtualizedMessageList") expect(code).toContain("messageVirtualization") }) + + test("uses scrollToIndex for virtualized navigation", async () => { + const code = await Bun.file("src/pages/session.tsx").text() + expect(code).toContain("virtualizedListRef") + expect(code).toContain("scrollToIndex") + expect(code).toContain("VirtualizedMessageListHandle") + }) }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6abec2522f8e..51c157a92d03 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -70,7 +70,7 @@ import { } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" -import { VirtualizedMessageList } from "@/components/virtualized-message-list" +import { VirtualizedMessageList, type VirtualizedMessageListHandle } from "@/components/virtualized-message-list" import { perfFlags } from "@/utils/perf-flags" type DiffStyle = "unified" | "split" @@ -530,6 +530,7 @@ export default function Page() { let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined + let virtualizedListRef: VirtualizedMessageListHandle | undefined const scrollGestureWindowMs = 250 @@ -1493,6 +1494,17 @@ export default function Page() { scheduleTurnBackfill() requestAnimationFrame(() => { + // Use virtualized scroll when enabled + if (perfFlags.messageVirtualization && virtualizedListRef) { + const rendered = renderedUserMessages() + const idx = rendered.findIndex((m) => m.id === message.id) + if (idx !== -1) { + virtualizedListRef.scrollToIndex(idx, { align: "start" }) + updateHash(message.id) + return + } + } + const el = document.getElementById(anchor(message.id)) if (!el) { requestAnimationFrame(() => { @@ -1509,6 +1521,17 @@ export default function Page() { return } + // Use virtualized scroll when enabled + if (perfFlags.messageVirtualization && virtualizedListRef) { + const rendered = renderedUserMessages() + const idx = rendered.findIndex((m) => m.id === message.id) + if (idx !== -1) { + virtualizedListRef.scrollToIndex(idx, { align: "start" }) + updateHash(message.id) + return + } + } + const el = document.getElementById(anchor(message.id)) if (!el) { updateHash(message.id) @@ -2050,6 +2073,7 @@ export default function Page() { } > (virtualizedListRef = r)} messages={renderedUserMessages()} overscan={4} renderMessage={(message) => ( From ea39de719fc3300f23d8f3c28c49aa99bdb53ee9 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:38:21 -0600 Subject: [PATCH 27/35] feat(app): add virtualized session list component --- .../virtualized-session-list.test.tsx | 21 +++++++++++++++++++ .../components/virtualized-session-list.tsx | 16 ++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 packages/app/src/components/virtualized-session-list.test.tsx create mode 100644 packages/app/src/components/virtualized-session-list.tsx diff --git a/packages/app/src/components/virtualized-session-list.test.tsx b/packages/app/src/components/virtualized-session-list.test.tsx new file mode 100644 index 000000000000..f740f8c5da71 --- /dev/null +++ b/packages/app/src/components/virtualized-session-list.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" + +describe("VirtualizedSessionList", () => { + test("component exports VirtualizedSessionList", async () => { + const code = await Bun.file( + "src/components/virtualized-session-list.tsx", + ).text() + expect(code).toContain("export function VirtualizedSessionList") + expect(code).toContain("VList") + expect(code).toContain("virtua/solid") + }) + + test("component accepts sessions and renderSession props", async () => { + const code = await Bun.file( + "src/components/virtualized-session-list.tsx", + ).text() + expect(code).toContain("sessions:") + expect(code).toContain("renderSession:") + expect(code).toContain("overscan") + }) +}) diff --git a/packages/app/src/components/virtualized-session-list.tsx b/packages/app/src/components/virtualized-session-list.tsx new file mode 100644 index 000000000000..558defcd9daf --- /dev/null +++ b/packages/app/src/components/virtualized-session-list.tsx @@ -0,0 +1,16 @@ +import { type JSX } from "solid-js" +import { VList } from "virtua/solid" + +type Session = { id: string; directory: string; time: { created: number } } + +export function VirtualizedSessionList(props: { + sessions: T[] + renderSession: (session: T) => JSX.Element + overscan?: number +}) { + return ( + + {(session) => props.renderSession(session)} + + ) +} From b135aa221351a7891d47b3eb86bade0ca7b9ce36 Mon Sep 17 00:00:00 2001 From: elijahr Date: Fri, 30 Jan 2026 18:39:16 -0600 Subject: [PATCH 28/35] feat(app): integrate virtualized session list in LocalWorkspace behind flag --- packages/app/src/pages/layout.test.ts | 9 +++++++++ packages/app/src/pages/layout.tsx | 22 +++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/pages/layout.test.ts diff --git a/packages/app/src/pages/layout.test.ts b/packages/app/src/pages/layout.test.ts new file mode 100644 index 000000000000..083c59de932f --- /dev/null +++ b/packages/app/src/pages/layout.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test" + +describe("layout session list virtualization", () => { + test("LocalWorkspace uses virtualized list when flag enabled", async () => { + const code = await Bun.file("src/pages/layout.tsx").text() + expect(code).toContain("VirtualizedSessionList") + expect(code).toContain("sessionListVirtualization") + }) +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 73480e8f200f..e1212671ad88 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -59,6 +59,8 @@ import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" import { Worktree as WorktreeState } from "@/utils/worktree" import { agentColor } from "@/utils/agent" +import { VirtualizedSessionList } from "@/components/virtualized-session-list" +import { perfFlags } from "@/utils/perf-flags" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -2451,9 +2453,23 @@ export default function Layout(props: ParentProps) { - - {(session) => } - + + {(session) => ( + + )} + + } + > + ( + + )} + /> +
- - {(message) => { - if (import.meta.env.DEV) { - onMount(() => { - const id = params.id - if (!id) return - navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) - }) - } - - return ( -
- - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} - /> -
- ) + (virtualizedListRef = r)} + messages={renderedUserMessages()} + overscan={4} + renderMessage={(message) => ( +
- } - > - (virtualizedListRef = r)} - messages={renderedUserMessages()} - overscan={4} - renderMessage={(message) => ( -
+ + setStore("expanded", message.id, (open: boolean | undefined) => !open) + } + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", }} - > - - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} - /> -
- )} - /> - + /> +
+ )} + /> diff --git a/packages/app/src/utils/perf-flags.test.ts b/packages/app/src/utils/perf-flags.test.ts deleted file mode 100644 index b11e8232bf0a..000000000000 --- a/packages/app/src/utils/perf-flags.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test, beforeEach } from "bun:test" -import { perfFlags, setPerfFlag, resetPerfFlags } from "./perf-flags" - -describe("perfFlags", () => { - beforeEach(() => resetPerfFlags()) - - test("defaults all flags to false", () => { - expect(perfFlags.messageVirtualization).toBe(false) - expect(perfFlags.sessionListVirtualization).toBe(false) - expect(perfFlags.sessionCleanup).toBe(false) - expect(perfFlags.childStoreEviction).toBe(false) - expect(perfFlags.scrollSpyOptimized).toBe(false) - }) - - test("setPerfFlag enables a flag", () => { - setPerfFlag("messageVirtualization", true) - expect(perfFlags.messageVirtualization).toBe(true) - }) - - test("resetPerfFlags restores defaults", () => { - setPerfFlag("messageVirtualization", true) - resetPerfFlags() - expect(perfFlags.messageVirtualization).toBe(false) - }) -}) diff --git a/packages/app/src/utils/perf-flags.ts b/packages/app/src/utils/perf-flags.ts deleted file mode 100644 index b015b1c5c203..000000000000 --- a/packages/app/src/utils/perf-flags.ts +++ /dev/null @@ -1,32 +0,0 @@ -type PerfFlagKey = - | "messageVirtualization" - | "sessionListVirtualization" - | "sessionCleanup" - | "childStoreEviction" - | "scrollSpyOptimized" - -const defaults: Record = { - messageVirtualization: false, - sessionListVirtualization: false, - sessionCleanup: false, - childStoreEviction: false, - scrollSpyOptimized: false, -} - -export const perfFlags = { ...defaults } - -export function setPerfFlag(key: PerfFlagKey, value: boolean) { - perfFlags[key] = value -} - -export function resetPerfFlags() { - for (const key of Object.keys(defaults) as PerfFlagKey[]) { - perfFlags[key] = defaults[key] - } -} - -// Enable via console: window.__setPerfFlag?.("messageVirtualization", true) -if (typeof window !== "undefined") { - ;(window as unknown as Record).__setPerfFlag = setPerfFlag - ;(window as unknown as Record).__perfFlags = perfFlags -} From 2ed1773ae635fa8f65d3c8d2b6376717f921a047 Mon Sep 17 00:00:00 2001 From: elijahr Date: Sat, 31 Jan 2026 00:12:06 -0600 Subject: [PATCH 35/35] refactor: remove perf-flags, enable session list virtualization by default --- packages/app/src/e2e/perf-flags.test.ts | 34 ---------------- packages/app/src/pages/layout.tsx | 47 ++++++----------------- packages/app/src/utils/perf-flags.test.ts | 25 ------------ packages/app/src/utils/perf-flags.ts | 32 --------------- 4 files changed, 12 insertions(+), 126 deletions(-) delete mode 100644 packages/app/src/e2e/perf-flags.test.ts delete mode 100644 packages/app/src/utils/perf-flags.test.ts delete mode 100644 packages/app/src/utils/perf-flags.ts diff --git a/packages/app/src/e2e/perf-flags.test.ts b/packages/app/src/e2e/perf-flags.test.ts deleted file mode 100644 index 1fc570912f68..000000000000 --- a/packages/app/src/e2e/perf-flags.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test" - -describe("Performance feature flags", () => { - test("sessionListVirtualization flag is used in layout", async () => { - const layoutCode = await Bun.file("src/pages/layout.tsx").text() - - // Verify flag is used - expect(layoutCode).toContain("sessionListVirtualization") - // Verify virtualized component is imported - expect(layoutCode).toContain("VirtualizedSessionList") - // Verify perfFlags is imported - expect(layoutCode).toContain("perfFlags") - }) - - test("virtualized session list component exists", async () => { - const exists = await Bun.file( - "src/components/virtualized-session-list.tsx", - ).exists() - expect(exists).toBe(true) - }) - - test("virtualized session list uses virtua", async () => { - const code = await Bun.file( - "src/components/virtualized-session-list.tsx", - ).text() - expect(code).toContain("VList") - expect(code).toContain("virtua/solid") - }) - - test("perf-flags utility exists with sessionListVirtualization", async () => { - const code = await Bun.file("src/utils/perf-flags.ts").text() - expect(code).toContain("sessionListVirtualization") - }) -}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 81f684d25f6a..7424cdc4fdef 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -60,7 +60,6 @@ import { playSound, soundSrc } from "@/utils/sound" import { Worktree as WorktreeState } from "@/utils/worktree" import { agentColor } from "@/utils/agent" import { VirtualizedSessionList } from "@/components/virtualized-session-list" -import { perfFlags } from "@/utils/perf-flags" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -2203,23 +2202,12 @@ export default function Layout(props: ParentProps) { - - {(session) => ( - - )} - - } - > - ( - - )} - /> - + ( + + )} + />