From e113aad5e0bda72222ac8321ecbfc85bbd82ae94 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 23:05:58 -0500 Subject: [PATCH] feat(core): expose server API in Code Mode --- packages/core/src/tool/execute.ts | 7 ++- packages/core/src/tool/registry.ts | 53 +++++++++++++------ .../test/session-runner-tool-registry.test.ts | 38 +++++++++++++ packages/server/package.json | 1 + packages/server/src/process.ts | 19 ++++++- packages/server/src/routes.ts | 42 +++++++++++++-- 6 files changed, 139 insertions(+), 21 deletions(-) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index f5bd776e5ab..3b04c3c29bc 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -43,15 +43,20 @@ export interface Registration { readonly group?: string } +export interface CodeModeTools { + [name: string]: Tool.Definition | CodeModeTools +} + export const create = (options: { readonly registrations: ReadonlyMap readonly current: (name: string) => Registration | undefined + readonly tools?: CodeModeTools }) => { const runtime = ( invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) => { - const tools: Record | Record>> = {} + const tools: CodeModeTools = Object.assign(Object.create(null), options.tools) for (const [name, registration] of options.registrations) { const child = definition(name, registration.tool) const value = Tool.make({ diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0275cd8d875..e1e78ec4d76 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,4 +1,5 @@ export * as ToolRegistry from "./registry" +export type { CodeModeTools } from "./execute" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" @@ -9,7 +10,7 @@ import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { ExecuteTool } from "./execute" +import { ExecuteTool, type CodeModeTools } from "./execute" import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" @@ -51,10 +52,14 @@ export interface Settlement { } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} +class CodeModeCatalog extends Context.Service()( + "@opencode/v2/CodeModeCatalog", +) {} const registryLayer = Layer.effect( Service, Effect.gen(function* () { + const codeModeTools = (yield* CodeModeCatalog).tools const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service type Registration = { @@ -204,11 +209,14 @@ const registryLayer = Layer.effect( } const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred)) + const tools = Flag.CODEMODE_ENABLED ? codeModeTools : undefined const execute = - deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? []) + (deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) && + !whollyDisabled("execute", input.permissions ?? []) ? ExecuteTool.create({ registrations: deferred, current: (name) => local.get(name)?.at(-1)?.registration, + tools, }) : undefined return { @@ -231,24 +239,37 @@ const registryLayer = Layer.effect( }), ) -const layer = Layer.effect( - Tools.Service, - Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), -).pipe(Layer.provideMerge(registryLayer)) +const makeLayer = (codeModeTools?: CodeModeTools) => { + return Layer.effect( + Tools.Service, + Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), + ).pipe( + Layer.provideMerge(registryLayer), + Layer.provide(Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: codeModeTools }))), + ) +} function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) return rule?.resource === "*" && rule.effect === "deny" } -export const node = makeLocationNode({ - service: Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], -}) +export function nodes(codeModeTools?: CodeModeTools) { + const layer = makeLayer(codeModeTools) + return { + node: makeLocationNode({ + service: Service, + layer, + deps: [ToolOutputStore.node, ToolHooks.node], + }), + toolsNode: makeLocationNode({ + service: Tools.Service, + layer, + deps: [ToolOutputStore.node, ToolHooks.node], + }), + } +} -export const toolsNode = makeLocationNode({ - service: Tools.Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], -}) +const defaults = nodes() +export const node = defaults.node +export const toolsNode = defaults.toolsNode diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e9..c9e8cb89d54 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -30,6 +30,22 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { }) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const it = testEffect(registryLayer) +const codeModeNodes = ToolRegistry.nodes({ + opencode: { + v2: { + health: { + get: { + _tag: "CodeModeTool" as const, + description: "Get server health", + input: Schema.Struct({}), + output: Schema.Struct({ healthy: Schema.Boolean }), + run: () => Effect.succeed({ healthy: true }), + }, + }, + }, + }, +}) +const codeModeIt = testEffect(AppNodeBuilder.build(codeModeNodes.node, [[ToolOutputStore.node, outputStore]])) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -53,6 +69,28 @@ const make = (permission?: string) => { } describe("ToolRegistry", () => { + codeModeIt.effect("includes host Code Mode trees without hosted tool registration", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const definitions = yield* toolDefinitions(service) + expect(definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect(definitions[0]?.description).toContain("tools.opencode.v2.health.get") + + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-opencode-health", + name: "execute", + input: { code: "return await tools.opencode.v2.health.get({})" }, + }, + }), + ).toEqual({ type: "text", value: '{\n "healthy": true\n}' }) + }), + ) + it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/server/package.json b/packages/server/package.json index 6a0fc15ad3f..5ffcd7d4faa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/simulation": "workspace:*", diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f5..0e08574acc9 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -52,8 +52,25 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() + const codeModeClient = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + return HttpClient.mapRequest(client, (request) => { + const address = server.address() + if (!address || typeof address === "string") throw new Error("OpenCode server is not listening") + const local = hostname === "0.0.0.0" ? "127.0.0.1" : hostname === "::" ? "::1" : hostname + const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local + const url = new URL(request.url) + return HttpClientRequest.setUrl( + request, + new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`), + ) + }) + }), + ).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)) return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + HttpRouter.serve(createRoutes(password, codeModeClient), { disableListenLog: true }).pipe( Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1c5a57f62ff..c8162c84bcd 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -17,8 +17,10 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApiBuilder } from "effect/unstable/httpapi" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { OpenAPI, Tool } from "@opencode-ai/codemode" +import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" import { Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" @@ -47,11 +49,24 @@ const applicationServices = LayerNode.group([ LocationServiceMap.node, ]) -export function createRoutes(password?: string) { +export function createRoutes(password?: string, codeModeClient?: Layer.Layer) { return makeRoutes( password ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.layer, + undefined, + codeModeClient + ? { + opencode: openCodeTools( + OpenAPI.fromSpec({ + spec: { ...OpenApi.fromApi(Api) }, + baseUrl: "http://opencode.local", + headers: ServerAuth.headers({ username: "opencode", password }), + }).tools, + codeModeClient, + ), + } + : undefined, ) } @@ -62,13 +77,18 @@ export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { function makeRoutes( auth: Layer.Layer, sdkPlugins?: SdkPlugins.Store, + codeModeTools?: ToolRegistry.CodeModeTools, ) { const pluginRuntimeCell = PluginRuntime.makeCell() + const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), + ...(codeMode + ? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const] + : []), ] const serviceLayer = simulateEnabled() ? Layer.unwrap( @@ -98,6 +118,22 @@ function makeRoutes( ) } +function openCodeTools(tools: OpenAPI.Tools, client: Layer.Layer): ToolRegistry.CodeModeTools { + return Object.fromEntries( + Object.entries(tools).map(([name, value]) => [ + name, + Tool.isDefinition(value) + ? Tool.make({ + description: value.description, + input: value.input, + output: value.output, + run: (input) => value.run(input).pipe(Effect.provide(client)), + }) + : openCodeTools(value, client), + ]), + ) +} + function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE }