feat(core): expose server API in Code Mode

This commit is contained in:
Aiden Cline 2026-07-06 23:05:58 -05:00
commit e113aad5e0
6 changed files with 139 additions and 21 deletions

View file

@ -43,15 +43,20 @@ export interface Registration {
readonly group?: string readonly group?: string
} }
export interface CodeModeTools {
[name: string]: Tool.Definition<never> | CodeModeTools
}
export const create = (options: { export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration> readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined readonly current: (name: string) => Registration | undefined
readonly tools?: CodeModeTools
}) => { }) => {
const runtime = ( const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>, invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks, hooks?: CodeMode.ToolCallHooks,
) => { ) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {} const tools: CodeModeTools = Object.assign(Object.create(null), options.tools)
for (const [name, registration] of options.registrations) { for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool) const child = definition(name, registration.tool)
const value = Tool.make({ const value = Tool.make({

View file

@ -1,4 +1,5 @@
export * as ToolRegistry from "./registry" export * as ToolRegistry from "./registry"
export type { CodeModeTools } from "./execute"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
@ -9,7 +10,7 @@ import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store" import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard" 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 { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
import { ToolHooks } from "./hooks" import { ToolHooks } from "./hooks"
@ -51,10 +52,14 @@ export interface Settlement {
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools?: CodeModeTools }>()(
"@opencode/v2/CodeModeCatalog",
) {}
const registryLayer = Layer.effect( const registryLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const codeModeTools = (yield* CodeModeCatalog).tools
const resources = yield* ToolOutputStore.Service const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service const toolHooks = yield* ToolHooks.Service
type Registration = { type Registration = {
@ -204,11 +209,14 @@ const registryLayer = Layer.effect(
} }
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
const deferred = 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 = 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({ ? ExecuteTool.create({
registrations: deferred, registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration, current: (name) => local.get(name)?.at(-1)?.registration,
tools,
}) })
: undefined : undefined
return { return {
@ -231,24 +239,37 @@ const registryLayer = Layer.effect(
}), }),
) )
const layer = Layer.effect( const makeLayer = (codeModeTools?: CodeModeTools) => {
Tools.Service, return Layer.effect(
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), Tools.Service,
).pipe(Layer.provideMerge(registryLayer)) 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) { function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny" return rule?.resource === "*" && rule.effect === "deny"
} }
export const node = makeLocationNode({ export function nodes(codeModeTools?: CodeModeTools) {
service: Service, const layer = makeLayer(codeModeTools)
layer, return {
deps: [ToolOutputStore.node, ToolHooks.node], 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({ const defaults = nodes()
service: Tools.Service, export const node = defaults.node
layer, export const toolsNode = defaults.toolsNode
deps: [ToolOutputStore.node, ToolHooks.node],
})

View file

@ -30,6 +30,22 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
}) })
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer) 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 = { const identity = {
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"), assistantMessageID: SessionMessage.ID.make("msg_registry"),
@ -53,6 +69,28 @@ const make = (permission?: string) => {
} }
describe("ToolRegistry", () => { 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", () => it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service

View file

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",

View file

@ -52,8 +52,25 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
const server = createServer() 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( 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.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
), ),

View file

@ -17,8 +17,10 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { HttpApiBuilder } from "effect/unstable/httpapi" 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 { Effect, Layer, Option } from "effect"
import { Api } from "./api" import { Api } from "./api"
import { ServerAuth } from "./auth" import { ServerAuth } from "./auth"
@ -47,11 +49,24 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node, LocationServiceMap.node,
]) ])
export function createRoutes(password?: string) { export function createRoutes(password?: string, codeModeClient?: Layer.Layer<HttpClient.HttpClient>) {
return makeRoutes( return makeRoutes(
password password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer, : 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<AuthError, AuthServices>( function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>, auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
sdkPlugins?: SdkPlugins.Store, sdkPlugins?: SdkPlugins.Store,
codeModeTools?: ToolRegistry.CodeModeTools,
) { ) {
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node], [SessionExecution.node, SessionExecutionLocal.node],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
...(codeMode
? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const]
: []),
] ]
const serviceLayer = simulateEnabled() const serviceLayer = simulateEnabled()
? Layer.unwrap( ? Layer.unwrap(
@ -98,6 +118,22 @@ function makeRoutes<AuthError, AuthServices>(
) )
} }
function openCodeTools(tools: OpenAPI.Tools, client: Layer.Layer<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
return Object.fromEntries(
Object.entries(tools).map(([name, value]) => [
name,
Tool.isDefinition<HttpClient.HttpClient>(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() { function simulateEnabled() {
return !!process.env.OPENCODE_SIMULATE return !!process.env.OPENCODE_SIMULATE
} }