diff --git a/bun.lock b/bun.lock index b652ae2416..69cb554417 100644 --- a/bun.lock +++ b/bun.lock @@ -680,6 +680,7 @@ "version": "1.17.11", "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/protocol": "workspace:*", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -688,6 +689,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@tsconfig/bun": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index e644c51be0..db956fffc0 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -36,6 +36,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), + Spec.make("mcp", { + description: "Manage MCP (Model Context Protocol) servers", + commands: [ + Spec.make("list", { description: "List configured MCP servers and their status" }), + Spec.make("add", { + description: "Add an MCP server to your configuration", + params: { + name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")), + command: Argument.string("command").pipe( + Argument.withDescription("Command and arguments for a local server, passed after --"), + Argument.variadic({ min: 0 }), + ), + url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional), + header: Flag.keyValuePair("header").pipe( + Flag.withDescription("HTTP header for a remote server, as name=value"), + Flag.optional, + ), + env: Flag.keyValuePair("env").pipe( + Flag.withDescription("Environment variable for a local server, as name=value"), + Flag.optional, + ), + global: Flag.boolean("global").pipe( + Flag.withDescription("Write to the global config instead of the project config"), + Flag.withDefault(false), + ), + }, + }), + Spec.make("auth", { + description: "Authenticate with an OAuth-capable remote MCP server", + params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) }, + }), + Spec.make("logout", { + description: "Remove stored OAuth credentials for an MCP server", + params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) }, + }), + ], + }), Spec.make("migrate", { description: "Migrate v1 data to v2" }), Spec.make("service", { description: "Manage the background server", diff --git a/packages/cli/src/commands/handlers/mcp/add.ts b/packages/cli/src/commands/handlers/mcp/add.ts new file mode 100644 index 0000000000..695104b3f2 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/add.ts @@ -0,0 +1,58 @@ +import { EOL } from "node:os" +import path from "node:path" +import { Effect, Option } from "effect" +import { applyEdits, modify } from "jsonc-parser" +import { Global } from "@opencode-ai/core/global" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" + +export default Runtime.handler( + Commands.commands.mcp.commands.add, + Effect.fn("cli.mcp.add")(function* (input) { + const url = Option.getOrUndefined(input.url) + const headers = Option.getOrUndefined(input.header) + const environment = Option.getOrUndefined(input.env) + // The CLI framework strands `--` operands on the root command, so read the local server command + // straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`). + const dash = process.argv.indexOf("--") + const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1) + + const hasCommand = command.length > 0 + if (url && hasCommand) + return yield* Effect.fail(new Error("Provide either --url or a command after --, not both")) + if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url or a command after --")) + if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`)) + if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers")) + if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers")) + + const server = url + ? { type: "remote" as const, url, ...(headers ? { headers } : {}) } + : { type: "local" as const, command, ...(environment ? { environment } : {}) } + + const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd())) + yield* Effect.promise(() => write(configPath, input.name, server)) + process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL) + }), +) + +async function resolveConfigPath(directory: string) { + const candidates = [ + path.join(directory, "opencode.json"), + path.join(directory, "opencode.jsonc"), + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + ] + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) return candidate + } + return candidates[0] +} + +async function write(configPath: string, name: string, server: unknown) { + const file = Bun.file(configPath) + const text = (await file.exists()) ? await file.text() : "{}" + const edits = modify(text, ["mcp", "servers", name], server, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + await Bun.write(configPath, applyEdits(text, edits)) +} diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts new file mode 100644 index 0000000000..2dc722076e --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -0,0 +1,58 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" +import { resolveIntegration } from "./resolve" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.mcp.commands.auth, + Effect.fn("cli.mcp.auth")(function* (input) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + + const integration = yield* resolveIntegration(client, input.name, location) + if (!integration) + return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) + const method = integration.methods.find( + (candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth", + ) + if (!method) + return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) + + const started = yield* Effect.promise(() => + client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), + ) + const attempt = started.data?.data + if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt")) + if (attempt.mode === "code") + return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support")) + + process.stdout.write(attempt.instructions + EOL + attempt.url + EOL) + + const result = yield* poll(client, attempt.attemptID) + if (result.status === "complete") { + process.stdout.write(`Authenticated with ${input.name}` + EOL) + return + } + const reason = result.status === "failed" ? `: ${result.message}` : "" + return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`)) + }), +) + +const poll = ( + client: OpencodeClient, + attemptID: string, +): Effect.Effect> => + Effect.gen(function* () { + const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location })) + const status = response.data?.data + if (!status || status.status === "pending") { + yield* Effect.sleep("1 second") + return yield* poll(client, attemptID) + } + return status + }) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts new file mode 100644 index 0000000000..7866705ad1 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -0,0 +1,52 @@ +import { EOL } from "node:os" +import * as Effect from "effect/Effect" +import type { McpServer } from "@opencode-ai/sdk/v2/client" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.mcp.commands.list, + Effect.fn("cli.mcp.list")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) + const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) + if (servers.length === 0) { + process.stdout.write("No MCP servers configured" + EOL) + return + } + const width = Math.max(...servers.map((server) => server.name.length)) + const lines = servers.map( + (server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`, + ) + process.stdout.write(lines.join(EOL) + EOL) + }), +) + +function icon(status: McpServer["status"]) { + switch (status.status) { + case "connected": + return "✓" + case "needs_auth": + return "⚠" + case "failed": + case "needs_client_registration": + return "✗" + default: + return "○" + } +} + +function describe(status: McpServer["status"]) { + switch (status.status) { + case "needs_auth": + return "needs authentication" + case "needs_client_registration": + return `needs client registration: ${status.error}` + case "failed": + return `failed: ${status.error}` + default: + return status.status + } +} diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts new file mode 100644 index 0000000000..6c4296ac38 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -0,0 +1,35 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" +import { resolveIntegration } from "./resolve" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.mcp.commands.logout, + Effect.fn("cli.mcp.logout")(function* (input) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + + const integration = yield* resolveIntegration(client, input.name, location) + if (!integration) { + process.stdout.write(`No stored credentials for ${input.name}` + EOL) + return + } + + const credentials = integration.connections.filter((connection) => connection.type === "credential") + if (credentials.length === 0) { + process.stdout.write(`No stored credentials for ${input.name}` + EOL) + return + } + + yield* Effect.forEach( + credentials, + (connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })), + { discard: true }, + ) + process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/mcp/resolve.ts b/packages/cli/src/commands/handlers/mcp/resolve.ts new file mode 100644 index 0000000000..58580e6def --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/resolve.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +// Resolve through the MCP-owned integrationID rather than matching integration names: the shared +// integration registry also holds provider/plugin integrations, whose names could collide with a server. +// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local +// or anonymous server), leaving that case for the caller to interpret. +export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) => + Effect.gen(function* () { + const servers = yield* Effect.promise(() => client.v2.mcp.list({ location })) + const server = (servers.data?.data ?? []).find((entry) => entry.name === name) + if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) + const integrationID = server.integrationID + if (!integrationID) return undefined + const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location })) + return found.data?.data + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e013152adb..df6370a102 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,12 @@ const Handlers = Runtime.handlers(Commands, { debug: { agents: () => import("./commands/handlers/debug/agents"), }, + mcp: { + list: () => import("./commands/handlers/mcp/list"), + add: () => import("./commands/handlers/mcp/add"), + auth: () => import("./commands/handlers/mcp/auth"), + logout: () => import("./commands/handlers/mcp/logout"), + }, migrate: () => import("./commands/handlers/migrate"), service: { start: () => import("./commands/handlers/service/start"), diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 287e6fccd9..10c3a23d0d 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,6 +1,12 @@ import { NodeFileSystem } from "@effect/platform-node" -import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" -import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" +import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode-ai/httpapi-codegen" +import { + ClientApi, + effectOmitEndpoints, + endpointNames, + groupNames, + promiseOmitEndpoints, +} from "@opencode-ai/protocol/client" import { Effect } from "effect" import { fileURLToPath } from "url" @@ -25,7 +31,11 @@ await Effect.runPromise( emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }), fileURLToPath(new URL("../src/generated-effect", import.meta.url)), ), + write( + emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }), + fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)), + ), ], - { concurrency: 2, discard: true }, + { concurrency: 3, discard: true }, ).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 2cc838c3dc..319a9f72e2 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,57 +1,7 @@ -import { makeDefaultApi } from "@opencode-ai/protocol/api" -import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { HttpApiMiddleware } from "effect/unstable/httpapi" - -class LocationMiddleware extends HttpApiMiddleware.Service()( - "@opencode-ai/client/LocationMiddleware", -) {} - -class SessionLocationMiddleware extends HttpApiMiddleware.Service()( - "@opencode-ai/client/SessionLocationMiddleware", - { error: [InvalidRequestError, SessionNotFoundError] }, -) {} - -export const ClientApi = makeDefaultApi({ - locationMiddleware: LocationMiddleware, - sessionLocationMiddleware: SessionLocationMiddleware, -}) - -export const groupNames = { - "server.health": "health", - "server.location": "location", - "server.agent": "agent", - "server.session": "session", - "server.message": "message", - "server.model": "model", - "server.generate": "generate", - "server.provider": "provider", - "server.integration": "integration", - "server.credential": "credential", - "server.permission": "permission", - "server.fs": "file", - "server.command": "command", - "server.skill": "skill", - "server.event": "event", - "server.pty": "pty", - "server.shell": "shell", - "server.question": "question", - "server.reference": "reference", - "server.project": "project", - "server.projectCopy": "projectCopy", -} as const - -export const endpointNames = { - "session.messages": "list", - "integration.connect.key": "connectKey", - "integration.connect.oauth": "connectOauth", - "integration.attempt.status": "attemptStatus", - "integration.attempt.complete": "attemptComplete", - "integration.attempt.cancel": "attemptCancel", - "permission.request.list": "listRequests", - "permission.saved.list": "listSaved", - "permission.saved.remove": "removeSaved", - "question.request.list": "listRequests", -} as const - -export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) -export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) +export { + ClientApi, + effectOmitEndpoints, + endpointNames, + groupNames, + promiseOmitEndpoints, +} from "@opencode-ai/protocol/client" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 31df1aacc3..a06b945719 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -254,9 +254,9 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ skill: Endpoint3_9(raw), compact: Endpoint3_10(raw), wait: Endpoint3_11(raw), - stage: Endpoint3_12(raw), - clear: Endpoint3_13(raw), - commit: Endpoint3_14(raw), + revertStage: Endpoint3_12(raw), + revertClear: Endpoint3_13(raw), + revertCommit: Endpoint3_14(raw), context: Endpoint3_15(raw), history: Endpoint3_16(raw), events: Endpoint3_17(raw), diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index a04597f6b1..4c75d30d7b 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -27,12 +27,12 @@ import type { SessionCompactOutput, SessionWaitInput, SessionWaitOutput, - SessionStageInput, - SessionStageOutput, - SessionClearInput, - SessionClearOutput, - SessionCommitInput, - SessionCommitOutput, + SessionRevertStageInput, + SessionRevertStageOutput, + SessionRevertClearInput, + SessionRevertClearOutput, + SessionRevertCommitInput, + SessionRevertCommitOutput, SessionContextInput, SessionContextOutput, SessionHistoryInput, @@ -465,8 +465,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - stage: (input: SessionStageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionStageOutput }>( + revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionRevertStageOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, @@ -477,8 +477,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - clear: (input: SessionClearInput, requestOptions?: RequestOptions) => - request( + revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, @@ -488,8 +488,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - commit: (input: SessionCommitInput, requestOptions?: RequestOptions) => - request( + revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 72796c3c34..e8718caf7a 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -577,13 +577,13 @@ export type SessionWaitInput = { readonly sessionID: { readonly sessionID: strin export type SessionWaitOutput = void -export type SessionStageInput = { +export type SessionRevertStageInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] } -export type SessionStageOutput = { +export type SessionRevertStageOutput = { readonly data: { readonly messageID: string readonly partID?: string @@ -599,13 +599,13 @@ export type SessionStageOutput = { } }["data"] -export type SessionClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionRevertClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionClearOutput = void +export type SessionRevertClearOutput = void -export type SessionCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionRevertCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionCommitOutput = void +export type SessionRevertCommitOutput = void export type SessionContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } @@ -2487,6 +2487,7 @@ export type ServerMcpListOutput = { | { readonly status: "failed"; readonly error: string } | { readonly status: "needs_auth" } | { readonly status: "needs_client_registration"; readonly error: string } + readonly integrationID?: string }> } diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 3d5cc3b0c0..3d60dc56b8 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -40,7 +40,7 @@ export interface Interface extends State.Transformable { readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect readonly select: (id?: ID | string) => Effect.Effect - readonly all: () => Effect.Effect + readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Agent") {} @@ -104,7 +104,7 @@ export const layer = Layer.effect( const info = selectedDefault() return { id: info?.id ?? defaultID, info } }), - all: Effect.fn("AgentV2.all")(function* () { + list: Effect.fn("AgentV2.list")(function* () { return Array.fromIterable(state.get().agents.values()) }), }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e6cd3c5a16..f289a68e2f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -139,7 +139,7 @@ export const layer = Layer.effect( const global = yield* Global.Service const location = yield* Location.Service const policy = yield* Policy.Service - const names = ["config.json", "opencode.json", "opencode.jsonc"] + const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 065790f8b3..ed18e433ed 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -122,7 +122,7 @@ type CheckReplacementErrors = [Exclude( source: Layer.Layer, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, + replacement: Layer.Layer, E2, NoInfer> & CheckReplacementErrors>, ): Replacement { return { source, replacement } } diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index a8e19830bf..5020bc7e01 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -210,9 +210,19 @@ export const layer = Layer.effect( const remote = entry.config // Key identity on name + url, not url alone: two configs for the same url under different names are // distinct logical servers that may hold different accounts, so they must not share a credential row. - const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16) + const suffix = + "mcp_" + + createHash("sha1") + .update(name + "\u0000" + remote.url) + .digest("hex") + .slice(0, 16) entry.integrationID = Integration.ID.make(suffix) - registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) }) + registrations.push({ + name, + remote, + integrationID: entry.integrationID, + methodID: Integration.MethodID.make(suffix), + }) } if (registrations.length > 0) yield* integration.transform((draft) => { @@ -323,6 +333,9 @@ export const layer = Layer.effect( const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { connection.onClose(() => { + // A reconnect closes the previous scope, but the SDK may fire this onclose after the new + // connection is already assigned; ignore the stale close so it can't null out the live client. + if (entry.client !== connection) return entry.client = undefined entry.tools = undefined entry.status = { status: "failed", error: "Connection closed" } @@ -467,7 +480,11 @@ export const layer = Layer.effect( }) const result = yield* target.entry.client .callTool({ name: input.name, args: input.args }) - .pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }))) + .pipe( + Effect.mapError( + (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }), + ), + ) return new ToolResult({ server: target.name, tool: input.name, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 2776ee20f7..6fb2348b67 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,7 +2,7 @@ export * as PluginV2 from "./plugin" import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" -import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect" +import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -11,17 +11,20 @@ import { CommandV2 } from "./command" import { EventV2 } from "./event" import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { Location } from "./location" import { PluginHost } from "./plugin/host" +import { PluginRuntime } from "./plugin/runtime" import { Reference } from "./reference" import { SkillV2 } from "./skill" import { State } from "./state" +import { ToolRegistry } from "./tool/registry" export const ID = Plugin.ID export type ID = typeof ID.Type export const Event = Plugin.Event export interface Interface { - readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect + readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect readonly wait: (id: ID) => Effect.Effect } @@ -38,9 +41,9 @@ export const layer = Layer.effect( const loading = new Set() const waiters = new Map>>() const failures = new Map>() - let host: Parameters[0] + let host: Parameters[0] - const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) { + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) yield* locks.withLock(id)( @@ -150,6 +153,8 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Reference.locationLayer), Layer.provideMerge(SkillV2.locationLayer), + Layer.provideMerge(ToolRegistry.defaultLayer), + Layer.provideMerge(PluginRuntime.layer), ) export const node = makeLocationNode({ @@ -162,7 +167,10 @@ export const node = makeLocationNode({ Catalog.node, CommandV2.node, Integration.node, + Location.node, Reference.node, SkillV2.node, + ToolRegistry.toolsNode, + PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a9d084709e..6aab6a12d9 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -8,12 +8,17 @@ import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Credential } from "../credential" import { Integration } from "../integration" +import { Location } from "../location" import { ModelV2 } from "../model" import { PluginV2 } from "../plugin" +import { PluginRuntime } from "./runtime" import { ProviderV2 } from "../provider" import { Reference } from "../reference" -import type { DeepMutable } from "../schema" +import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" +import { Tool } from "../tool/tool" +import { Tools } from "../tool/tools" +import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable @@ -23,12 +28,38 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const integration = yield* Integration.Service + const location = yield* Location.Service const reference = yield* Reference.Service const skill = yield* SkillV2.Service + const tools = yield* Tools.Service + const runtime = yield* PluginRuntime.Service + const locationInfo = () => + new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }) + const locationRef = (input?: Parameters[0]) => + input?.location === undefined + ? undefined + : Location.Ref.make({ + directory: AbsolutePath.make(input.location.directory ?? location.directory), + workspaceID: + input.location.workspace === undefined + ? location.workspaceID + : WorkspaceV2.ID.make(input.location.workspace), + }) + const isCurrentLocation = (ref: Location.Ref) => + ref.directory === location.directory && ref.workspaceID === location.workspaceID return { options: {}, agent: { + list: (input) => { + const ref = locationRef(input) + if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref) + return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data }))) + }, reload: agents.reload, transform: (callback) => agents.transform((draft) => @@ -215,5 +246,21 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), ), }, + tool: { + register: (input) => tools.register(input as Readonly>), + }, + session: { + create: (input) => + runtime.session.create({ + id: input?.id, + agent: input?.agent, + model: input?.model, + location: + input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), + }), + get: (input) => runtime.session.get(input.sessionID), + prompt: runtime.session.prompt, + interrupt: (input) => runtime.session.interrupt(input.sessionID), + }, } satisfies Interface }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 042bc030b5..67eb34bfd1 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -20,13 +20,19 @@ import { FSUtil } from "../fs-util" import { Global } from "../global" import { Integration } from "../integration" import { Location } from "../location" +import { LocationMutation } from "../location-mutation" import { ModelsDev } from "../models-dev" import { MCP } from "../mcp" import { Npm } from "../npm" import { PluginV2 } from "../plugin" +import { PluginRuntime } from "../plugin/runtime" +import { PermissionV2 } from "../permission" import { Reference } from "../reference" +import { Shell } from "../shell" import { SkillV2 } from "../skill" import { State } from "../state" +import { ToolRegistry } from "../tool/registry" +import { Tools } from "../tool/tools" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -36,6 +42,8 @@ import { ProviderPlugins } from "./provider" import { SdkPlugins } from "./sdk" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" +import { ShellTool } from "../tool/shell" +import { SubagentTool } from "../tool/subagent" export type Requirements = | AgentV2.Service @@ -49,11 +57,16 @@ export type Requirements = | HttpClient.HttpClient | Integration.Service | Location.Service + | LocationMutation.Service | ModelsDev.Service | MCP.Service | Npm.Service + | PermissionV2.Service + | PluginRuntime.Service | Reference.Service + | Shell.Service | SkillV2.Service + | Tools.Service export interface Plugin { readonly id: string @@ -82,8 +95,13 @@ const layer = Layer.effectDiscard( const filesystem = yield* FileSystem.Service const global = yield* Global.Service const http = yield* HttpClient.HttpClient + const mutation = yield* LocationMutation.Service + const permission = yield* PermissionV2.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service + const shell = yield* Shell.Service + const tools = yield* Tools.Service + const runtime = yield* PluginRuntime.Service const add = (input: Plugin) => { const loaded = { id: input.id, @@ -105,8 +123,13 @@ const layer = Layer.effectDiscard( Effect.provideService(FileSystem.Service, filesystem), Effect.provideService(Global.Service, global), Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(LocationMutation.Service, mutation), + Effect.provideService(PermissionV2.Service, permission), Effect.provideService(SkillV2.Service, skill), Effect.provideService(Reference.Service, reference), + Effect.provideService(Shell.Service, shell), + Effect.provideService(Tools.Service, tools), + Effect.provideService(PluginRuntime.Service, runtime), ), } return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) @@ -120,6 +143,8 @@ const layer = Layer.effectDiscard( yield* add(SkillPlugin.Plugin) yield* add(ModelsDevPlugin) yield* add(ConfigExternalPlugin.Plugin) + yield* add(ShellTool.Plugin) + yield* add(SubagentTool.Plugin) yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(MCPCommandPlugin.Plugin) @@ -152,6 +177,7 @@ export const node = makeLocationNode({ AgentV2.node, Config.node, Location.node, + LocationMutation.node, ModelsDev.node, MCP.node, Npm.node, @@ -160,8 +186,12 @@ export const node = makeLocationNode({ FileSystem.node, Global.node, httpClient, + PermissionV2.node, SkillV2.node, Reference.node, + Shell.node, + ToolRegistry.toolsNode, + PluginRuntime.node, SdkPlugins.node, ], }) diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts new file mode 100644 index 0000000000..4732579fd4 --- /dev/null +++ b/packages/core/src/plugin/runtime.ts @@ -0,0 +1,117 @@ +export * as PluginRuntime from "./runtime" + +import { Context, Effect, Layer } from "effect" +import { AgentV2 } from "../agent" +import { makeGlobalNode } from "../effect/app-node" +import { Job } from "../job" +import { Location } from "../location" +import { LocationServiceMap } from "../location-service-map" +import { SessionV2 } from "../session" + +export interface Interface { + readonly session: Pick< + SessionV2.Interface, + "get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic" + > + readonly job: Pick + readonly location: { + readonly agent: { + readonly list: ( + ref: Location.Ref, + ) => Effect.Effect<{ readonly location: Location.Info; readonly data: AgentV2.Info[] }> + } + } +} + +export class Service extends Context.Service()("@opencode/PluginRuntime") {} + +export interface Cell { + runtime?: Interface +} + +export const makeCell = (): Cell => ({}) + +const unavailable = () => Effect.die("Plugin runtime is unavailable") as Effect.Effect +const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect) => + Effect.suspend(() => { + const runtime = cell.runtime + if (runtime === undefined) return unavailable() + return f(runtime) + }) + +const defaultCell = makeCell() + +export const layerWithCell = (cell: Cell) => + Layer.succeed( + Service, + Service.of({ + session: { + get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)), + create: (input) => require(cell, (runtime) => runtime.session.create(input)), + messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), + prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)), + resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)), + interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), + synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), + }, + job: { + start: (input) => require(cell, (runtime) => runtime.job.start(input)), + wait: (input) => require(cell, (runtime) => runtime.job.wait(input)), + block: (input) => require(cell, (runtime) => runtime.job.block(input)), + background: (id) => require(cell, (runtime) => runtime.job.background(id)), + cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)), + }, + location: { + agent: { + list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)), + }, + }, + }), + ) + +export const providerLayerWithCell = (cell: Cell) => + Layer.effectDiscard( + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const jobs = yield* Job.Service + const locations = yield* LocationServiceMap.Service + const runtime = { + session: sessions, + job: jobs, + location: { + agent: { + list: (ref) => + Effect.gen(function* () { + const location = yield* Location.Service + const agents = yield* AgentV2.Service + return { + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: yield* agents.list(), + } + }).pipe(Effect.provide(locations.get(ref))), + }, + }, + } satisfies Interface + cell.runtime = runtime + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (cell.runtime === runtime) cell.runtime = undefined + }), + ) + }), + ) + +export const layer = layerWithCell(defaultCell) +export const providerLayer = providerLayerWithCell(defaultCell) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) + +export const providerNode = makeGlobalNode({ + name: "plugin-runtime-provider", + layer: providerLayer, + deps: [node, SessionV2.node, Job.node, LocationServiceMap.node], +}) diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index d3788116f7..78173a50a9 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -4,6 +4,14 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node" +export interface Store { + readonly plugins: Map +} + +export const makeStore = (): Store => ({ plugins: new Map() }) + +const defaultStore = makeStore() + /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginInternal` can add them on every Location boot through the ordinary @@ -12,9 +20,10 @@ import { makeGlobalNode } from "../effect/app-node" * applies to Locations booted afterward, matching config-plugin timing; * embedders register at startup before creating Sessions. * - * State lives in this global-node service (like `ApplicationTools`) rather than - * module scope, so the list belongs to one embedded instance and is disposed - * with it instead of leaking across `OpenCode.create` calls. + * The store is shared explicitly between the SDK construction graph and the + * embedded route graph because `LocationServiceMap` builds Location layers lazily + * in a nested graph. Each embedded SDK creates its own store, so instances do not + * see each other's contributions. */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect @@ -23,15 +32,25 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SdkPlugins") {} -export const layer = Layer.effect( - Service, - Effect.sync(() => { - const plugins: Plugin[] = [] - return Service.of({ - register: (plugin) => Effect.sync(() => void plugins.push(plugin)), - all: () => plugins, - }) - }), -) +export const layerWithStore = (store: Store) => + Layer.effect( + Service, + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + store.plugins.clear() + }), + ) + return Service.of({ + register: (plugin) => + Effect.sync(() => { + store.plugins.set(plugin.id, plugin) + }), + all: () => [...store.plugins.values()], + }) + }), + ) + +export const layer = layerWithStore(defaultStore) export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 5e8066e1df..3719322fa1 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -4,10 +4,9 @@ This folder owns Core's one local tool representation, process and Location regi ## Representations -- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type. -- `application-tools.ts` stores process-scoped application registrations. +- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type. - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. -- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding. +- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding. Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path. @@ -29,16 +28,15 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe ## Registration -Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`. +Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. -Both are scoped: +Registrations are scoped: - The latest active same-placement registration wins. - Closing any registration removes only that registration and reveals the next active one. -- Location registrations take precedence over application registrations. - An invocation captures the effective tool once settlement starts. -`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location. +`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location. ## Permissions @@ -54,6 +52,5 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut ## Current Gaps -- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations. - MCP and future Session-scoped registrations still need an explicit canonical registration design. - The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design. diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts deleted file mode 100644 index 5c06541820..0000000000 --- a/packages/core/src/tool/application-tools.ts +++ /dev/null @@ -1,56 +0,0 @@ -export * as ApplicationTools from "./application-tools" - -import { Context, Effect, Layer, Scope } from "effect" -import { State } from "../state" -import { Tool } from "./tool" -import { makeGlobalNode } from "../effect/app-node" - -type Data = { - readonly entries: Map -} - -type Draft = { - readonly set: (name: string, entry: Entry) => void -} - -export interface Entry { - readonly identity: object - readonly tool: Tool.AnyTool -} - -export interface Interface { - readonly register: ( - tools: Readonly>, - ) => Effect.Effect - readonly entries: () => ReadonlyMap -} - -export class Service extends Context.Service()("@opencode/ApplicationTools") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = State.create({ - initial: () => ({ entries: new Map() }), - draft: (draft) => ({ - set: (name, tool) => { - draft.entries.set(name, tool) - }, - }), - }) - - return Service.of({ - register: Effect.fn("ApplicationTools.register")(function* (tools) { - const entries = Tool.registrationEntries(tools) - if (entries.length === 0) return - const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) - yield* state.transform((draft) => { - for (const [name, entry] of registrations) draft.set(name, entry) - }) - }), - entries: () => state.get().entries, - }) - }), -) - -export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index b47c1d6c6a..41cd111e2f 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -8,7 +8,6 @@ import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { ApplicationTools } from "./application-tools" import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" @@ -47,14 +46,12 @@ export class Service extends Context.Service()("@opencode/v2 const registryLayer = Layer.effect( Service, Effect.gen(function* () { - const applications = yield* ApplicationTools.Service const resources = yield* ToolOutputStore.Service type Registration = { readonly identity: object; readonly tool: AnyTool } const local = new Map>() const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) { - const registration = - local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name) + const registration = local.get(input.call.name)?.at(-1)?.registration if (!registration) return { result: { @@ -108,7 +105,7 @@ const registryLayer = Layer.effect( ) }), materialize: Effect.fn("ToolRegistry.materialize")(function* (input) { - const registrations = new Map(applications.entries()) + const registrations = new Map() for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) @@ -143,19 +140,16 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { return rule?.resource === "*" && rule.effect === "deny" } -export const defaultLayer = layer.pipe( - Layer.provide(ApplicationTools.layer), - Layer.provide(ToolOutputStore.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(ToolOutputStore.defaultLayer)) export const node = makeLocationNode({ service: Service, layer, - deps: [ApplicationTools.node, ToolOutputStore.node], + deps: [ToolOutputStore.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ApplicationTools.node, ToolOutputStore.node], + deps: [ToolOutputStore.node], }) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index b56438a80b..911bd72c13 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -2,19 +2,16 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema, Scope } from "effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" import { FSUtil } from "../fs-util" -import { Job } from "../job" import { LocationMutation } from "../location-mutation" -import { LocationServiceMap } from "../location-service-map" import { PermissionV2 } from "../permission" +import { PluginRuntime } from "../plugin/runtime" import { PositiveInt } from "../schema" -import { SessionV2 } from "../session" import { SessionSchema } from "../session/schema" import { Shell } from "../shell" import { Tool, type Content } from "./tool" -import { ApplicationTools } from "./application-tools" -import { makeGlobalNode } from "../effect/app-node" export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 @@ -94,21 +91,22 @@ const externalCommandDirectories = (command: string, cwd: string) => { return [...directories] } -export const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* ApplicationTools.Service - const sessions = yield* SessionV2.Service - const jobs = yield* Job.Service - const locations = yield* LocationServiceMap.Service +export const Plugin = { + id: "core-shell-tool", + effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { + const runtime = yield* PluginRuntime.Service const scope = yield* Scope.Scope const fsUtil = yield* FSUtil.Service + const mutation = yield* LocationMutation.Service + const shell = yield* Shell.Service + const permission = yield* PermissionV2.Service const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( sessionID: SessionSchema.ID, callID: string, command: string, ) { - yield* jobs.wait({ id: callID }).pipe( + yield* runtime.job.wait({ id: callID }).pipe( Effect.flatMap((result) => { const state = result.info?.status === "completed" @@ -125,7 +123,7 @@ export const layer = Layer.effectDiscard( : state === "error" ? (result.info!.error ?? "Command failed") : "Command cancelled" - return sessions.synthetic({ + return runtime.session.synthetic({ sessionID, text: `\n${text}\n`, }) @@ -134,7 +132,7 @@ export const layer = Layer.effectDiscard( ) }) - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, @@ -154,124 +152,110 @@ export const layer = Layer.effectDiscard( }, execute: (input, context) => Effect.gen(function* () { - const parent = yield* sessions - .get(context.sessionID) - .pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` }))) - return yield* Effect.gen(function* () { - const mutation = yield* LocationMutation.Service - const shell = yield* Shell.Service - const permission = yield* PermissionV2.Service - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - const warnings = externalCommandDirectories(input.command, target.canonical).map( - (directory) => - `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ) + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) + const external = target.externalDirectory + if (external) yield* permission.assert({ - action: name, - resources: [input.command], - save: [input.command], + ...LocationMutation.externalDirectoryPermission(external), sessionID: context.sessionID, agent: context.agent, source, }) + const warnings = externalCommandDirectories(input.command, target.canonical).map( + (directory) => + `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, + ) + yield* permission.assert({ + action: name, + resources: [input.command], + save: [input.command], + sessionID: context.sessionID, + agent: context.agent, + source, + }) - if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") - return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) + if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") + return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS + const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS - if (input.background === true) { - const run = Effect.fn("ShellTool.run")(function* () { - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) - return yield* Effect.gen(function* () { - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) - - if (final.status === "timeout") - return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` - - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" - return `${body}${notice}` - }).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore))) - }) - - const info = yield* jobs.start({ - id: context.toolCallID, - type: name, - title: input.command, + if (input.background === true) { + const run = Effect.fn("ShellTool.run")(function* () { + const info = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, metadata: { sessionID: context.sessionID }, - run: run(), }) - yield* jobs.background(info.id) - yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) - return { - output: BACKGROUND_STARTED, - truncated: false, - status: "running" as const, - ...(warnings.length ? { warnings } : {}), - } - } + return yield* Effect.gen(function* () { + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, + if (final.status === "timeout") + return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` + + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + return `${body}${notice}` + }).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore))) }) - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) - if (final.status === "timeout") { - return { - exit: final.exit, - output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, - truncated: false, - timeout: true, - status: "completed" as const, - ...(warnings.length ? { warnings } : {}), - } + const info = yield* runtime.job.start({ + id: context.toolCallID, + type: name, + title: input.command, + metadata: { sessionID: context.sessionID }, + run: run(), + }) + yield* runtime.job.background(info.id) + yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) + return { + output: BACKGROUND_STARTED, + truncated: false, + status: "running" as const, + ...(warnings.length ? { warnings } : {}), } + } - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + const info = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, + metadata: { sessionID: context.sessionID }, + }) + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + + if (final.status === "timeout") { return { exit: final.exit, - output: `${body}${notice}`, - truncated, + output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + truncated: false, + timeout: true, status: "completed" as const, ...(warnings.length ? { warnings } : {}), } - }).pipe(Effect.provide(locations.get(parent.location))) + } + + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + return { + exit: final.exit, + output: `${body}${notice}`, + truncated, + status: "completed" as const, + ...(warnings.length ? { warnings } : {}), + } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), }) .pipe(Effect.orDie) }), -) - -export const node = makeGlobalNode({ - name: "shell-tool", - layer, - deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node], -}) +} diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index e32987aad3..517d1ce65f 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,14 +1,11 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema, Scope } from "effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" -import { Job } from "../job" -import { LocationServiceMap } from "../location-service-map" -import { SessionV2 } from "../session" +import { PluginRuntime } from "../plugin/runtime" import { SessionSchema } from "../session/schema" -import { makeGlobalNode } from "../effect/app-node" -import { ApplicationTools } from "./application-tools" import { Tool } from "./tool" export const name = "subagent" @@ -40,18 +37,17 @@ export const description = [ "Use background only for independent work that can run while you continue elsewhere.", ].join("\n") -export const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* ApplicationTools.Service - const sessions = yield* SessionV2.Service - const jobs = yield* Job.Service - const locations = yield* LocationServiceMap.Service +export const Plugin = { + id: "core-subagent-tool", + effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { + const runtime = yield* PluginRuntime.Service + const agents = yield* AgentV2.Service const scope = yield* Scope.Scope // Concatenate the child's final completed assistant text. Distinguishes "completed with no // text" (generic string) from "failed" (the run effect fails, surfaced as a job error). const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) { - const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 }) + const messages = yield* runtime.session.messages({ sessionID, order: "desc", limit: 20 }) const assistant = messages.find( (message) => message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, @@ -71,7 +67,7 @@ export const layer = Layer.effectDiscard( state: "completed" | "error" | "cancelled", text: string, ) { - yield* sessions.synthetic({ + yield* runtime.session.synthetic({ sessionID: parentID, text: `\n${text}\n`, }) @@ -82,7 +78,7 @@ export const layer = Layer.effectDiscard( childID: SessionSchema.ID, description: string, ) { - yield* jobs.wait({ id: childID }).pipe( + yield* runtime.job.wait({ id: childID }).pipe( Effect.flatMap((result) => { if (result.info?.status === "completed") return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT) @@ -96,7 +92,7 @@ export const layer = Layer.effectDiscard( ) }) - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description, @@ -105,12 +101,11 @@ export const layer = Layer.effectDiscard( toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { - const parent = yield* sessions + const parent = yield* runtime.session .get(context.sessionID) .pipe( Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })), ) - const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(parent.location))) const agent = yield* agents.resolve(input.agent) if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") @@ -118,7 +113,7 @@ export const layer = Layer.effectDiscard( // Model selection is policy/config/session state, not an LLM-facing tool argument. const model = agent.model ?? parent.model - const child = yield* sessions + const child = yield* runtime.session .create({ parentID: context.sessionID, title: input.description, @@ -135,12 +130,12 @@ export const layer = Layer.effectDiscard( const run = Effect.gen(function* () { // The child session owns its agent/model (set at create); prompt only admits input. - yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) - yield* sessions.resume(child.id) + yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) + yield* runtime.session.resume(child.id) return yield* latestAssistantText(child.id) - }).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id))) + }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) - const info = yield* jobs.start({ + const info = yield* runtime.job.start({ id: child.id, type: name, title: input.description, @@ -149,18 +144,18 @@ export const layer = Layer.effectDiscard( }) if (background) { - yield* jobs.background(info.id) + yield* runtime.job.background(info.id) yield* notifyWhenDone(context.sessionID, child.id, input.description) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } } - const result = yield* jobs - .block({ id: child.id, sessionID: context.sessionID }) - .pipe( - Effect.onInterrupt(() => - Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }), - ), - ) + const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe( + Effect.onInterrupt(() => + Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], { + discard: true, + }), + ), + ) if (result?.type === "backgrounded") { yield* notifyWhenDone(context.sessionID, child.id, input.description) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } @@ -174,13 +169,4 @@ export const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above -// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle. -// Agent lookup is resolved through the parent Session's location when the tool executes. -export const node = makeGlobalNode({ - name: "subagent-tool", - layer, - deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node], -}) +} diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 4bba451895..76ea6691e5 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -39,7 +39,7 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service - expect(yield* agent.all()).toEqual([]) + expect(yield* agent.list()).toEqual([]) expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined() }), ) @@ -56,7 +56,7 @@ describe("AgentV2", () => { ) expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" }) - expect((yield* agent.all()).map((info) => info.id)).toEqual([id]) + expect((yield* agent.list()).map((info) => info.id)).toEqual([id]) }), ) @@ -136,7 +136,7 @@ describe("AgentV2", () => { ), ) - const agents = yield* agent.all() + const agents = yield* agent.list() expect(agents.map((item) => String(item.id)).sort()).toEqual([ "build", "compaction", diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts deleted file mode 100644 index 2265e03067..0000000000 --- a/packages/core/test/application-tools.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect } from "bun:test" -import { Tool } from "@opencode-ai/core/tool/tool" -import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { SessionV2 } from "@opencode-ai/core/session" -import { SessionMessage } from "@opencode-ai/core/session/message" -import { AgentV2 } from "@opencode-ai/core/agent" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { executeTool, settleTool, toolDefinitions } from "./lib/tool" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Tools } from "@opencode-ai/core/tool/tools" -import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" -import { testEffect } from "./lib/effect" - -const permission = Layer.mock(PermissionV2.Service, { - assert: () => Effect.void, -}) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), -) -const it = testEffect(Layer.mergeAll(applications, registry)) - -const sessionID = SessionV2.ID.make("ses_application_tool") -const agent = AgentV2.ID.make("build") -const assistantMessageID = SessionMessage.ID.make("msg_application_tool") -const contextual = (contexts: Tool.Context[]) => - Tool.make({ - description: "Read application context", - input: Schema.Struct({ query: Schema.String }), - output: Schema.Struct({ answer: Schema.String }), - execute: ({ query }, context) => - Effect.sync(() => { - contexts.push(context) - return { answer: query.toUpperCase() } - }), - toModelOutput: ({ output }) => [ - { type: "text", text: output.answer }, - { type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" }, - ], - }) - -describe("ApplicationTools", () => { - it.effect("keeps the Core carrier opaque and executes its single handler", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const contexts: Tool.Context[] = [] - const tool = contextual(contexts) - expect(Object.keys(tool)).toEqual([]) - - yield* applications.register({ opaque: tool }) - expect( - yield* executeTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } }, - }), - ).toEqual({ - type: "content", - value: [ - { type: "text", text: "ONCE" }, - { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, - ], - }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }]) - }), - ) - - it.effect("exposes narrow scoped Location registration and sanitizes names", () => - Effect.gen(function* () { - const tools: Tools.Interface = yield* Tools.Service - const registry = yield* ToolRegistry.Service - const scope = yield* Scope.make() - - yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope)) - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"]) - - yield* Scope.close(scope, Exit.void) - expect(yield* toolDefinitions(registry)).toEqual([]) - }), - ) - - it.effect("filters an application tool by its name without adding execution authorization", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const contexts: Tool.Context[] = [] - yield* applications.register({ application_context: contextual(contexts) }) - - expect( - yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]), - ).toEqual([]) - expect( - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } }, - }), - ).toMatchObject({ result: { type: "content" } }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }]) - }), - ) - - it.effect("advertises and executes a scoped application tool with Session context", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const contexts: Tool.Context[] = [] - - yield* applications.register({ application_context: contextual(contexts) }) - - expect(yield* toolDefinitions(registry)).toMatchObject([ - { name: "application_context", description: "Read application context" }, - ]) - expect( - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } }, - }), - ).toEqual({ - result: { - type: "content", - value: [ - { type: "text", text: "HELLO" }, - { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, - ], - }, - output: { - structured: { answer: "HELLO" }, - content: [ - { type: "text", text: "HELLO" }, - { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, - ], - }, - }) - expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }]) - }), - ) - - it.effect("removes an application tool when its registration scope closes", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const scope = yield* Scope.make() - - yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope)) - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"]) - - yield* Scope.close(scope, Exit.void) - expect(yield* toolDefinitions(registry)).toEqual([]) - }), - ) - - it.effect("removes a tool before settling a call produced from an earlier definition", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const registrationScope = yield* Scope.make() - yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope)) - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"]) - - yield* Scope.close(registrationScope, Exit.void) - expect( - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } }, - }), - ).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } }) - }), - ) - - it.effect("does not leak a registration into an already closed scope", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const scope = yield* Scope.make() - yield* Scope.close(scope, Exit.void) - - yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope)) - - expect(yield* toolDefinitions(registry)).toEqual([]) - }), - ) - - it.effect("preserves an interrupted application registration until its scope closes", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const scope = yield* Scope.make() - const registered = yield* Deferred.make() - const fiber = yield* applications - .register({ interrupted: contextual([]) }) - .pipe( - Effect.andThen(Deferred.succeed(registered, undefined)), - Effect.andThen(Effect.never), - Scope.provide(scope), - Effect.forkChild, - ) - yield* Deferred.await(registered) - yield* Fiber.interrupt(fiber) - - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"]) - yield* Scope.close(scope, Exit.void) - expect(yield* toolDefinitions(registry)).toEqual([]) - }), - ) - - it.effect("captures the registered record before later State rebuilds", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const registered = { stable: contextual([]) } - yield* applications.register(registered) - Object.assign(registered, { late: contextual([]) }) - - yield* Effect.scoped(applications.register({ temporary: contextual([]) })) - - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"]) - }), - ) - - it.effect("settles with the current same-name application tool and restores earlier registrations", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const firstContexts: Tool.Context[] = [] - const secondContexts: Tool.Context[] = [] - const scope = yield* Scope.make() - yield* applications.register({ contextual: contextual(firstContexts) }) - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"]) - yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope)) - - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } }, - }) - yield* Scope.close(scope, Exit.void) - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } }, - }) - - expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }]) - expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }]) - }), - ) - - it.effect("keeps the Location tool when an application tool has the same name", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const registry = yield* ToolRegistry.Service - const locationContexts: Tool.Context[] = [] - const applicationContexts: Tool.Context[] = [] - const location = contextual(locationContexts) - yield* registry.register({ shared: location }) - yield* applications.register({ shared: contextual(applicationContexts) }) - - expect( - (yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map( - (definition) => definition.name, - ), - ).toEqual([]) - expect( - yield* settleTool(registry, { - sessionID, - agent, - assistantMessageID, - call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } }, - }), - ).toMatchObject({ result: { type: "content" } }) - expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }]) - expect(applicationContexts).toEqual([]) - }), - ) -}) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 855659a019..9920f55c62 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -17,6 +17,10 @@ const it = testEffect( Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer), ) const decode = Schema.decodeUnknownSync(Config.Info) +const defaultPermissions = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, +] satisfies PermissionV2.Ruleset describe("ConfigAgentPlugin.Plugin", () => { it.effect("applies all global permissions before agent-specific permissions", () => @@ -77,8 +81,7 @@ describe("ConfigAgentPlugin.Plugin", () => { const buildAgent = yield* agents.get(build) if (!buildAgent) throw new Error("expected configured build agent") expect(buildAgent.permissions).toEqual([ - { action: "*", resource: "*", effect: "allow" }, - { action: "external_directory", resource: "*", effect: "ask" }, + ...defaultPermissions, { action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, @@ -96,8 +99,7 @@ describe("ConfigAgentPlugin.Plugin", () => { model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, }) expect(reviewer.permissions).toEqual([ - { action: "*", resource: "*", effect: "allow" }, - { action: "external_directory", resource: "*", effect: "ask" }, + ...defaultPermissions, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, @@ -105,8 +107,7 @@ describe("ConfigAgentPlugin.Plugin", () => { ]) expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny") expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([ - { action: "*", resource: "*", effect: "allow" }, - { action: "external_directory", resource: "*", effect: "ask" }, + ...defaultPermissions, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "allow" }, @@ -264,21 +265,13 @@ Use native v2 fields.`, system: "Review carefully.", description: "Markdown description", request: { body: { temperature: 0.5 } }, - permissions: [ - { action: "*", resource: "*", effect: "allow" }, - { action: "external_directory", resource: "*", effect: "ask" }, - { action: "edit", resource: "*", effect: "deny" }, - ], + permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }], }) expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." }) expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({ system: "Use native v2 fields.", request: { headers: { "x-agent": "native" }, body: { effort: "high" } }, - permissions: [ - { action: "*", resource: "*", effect: "allow" }, - { action: "external_directory", resource: "*", effect: "ask" }, - { action: "edit", resource: "*", effect: "deny" }, - ], + permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }], }) expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined() expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index f16312b763..4aec63569e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -194,7 +194,7 @@ describe("Config", () => { ), ) - it.live("loads JSON and JSONC files from lowest to highest priority", () => + it.live("loads opencode JSON and JSONC files from lowest to highest priority", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -203,13 +203,9 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile( - path.join(tmp.path, "config.json"), - JSON.stringify({ $schema: "base", providers: { base: provider } }), - ), fs.writeFile( path.join(tmp.path, "opencode.json"), - JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + JSON.stringify({ $schema: "base", providers: { base: provider } }), ), fs.writeFile( path.join(tmp.path, "opencode.jsonc"), @@ -225,12 +221,12 @@ describe("Config", () => { const config = yield* Config.Service const documents = (yield* config.entries()).filter((entry) => entry.type === "document") - expect(documents).toHaveLength(3) - expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"]) - expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents).toHaveLength(2) + expect(documents.map((document) => document.type)).toEqual(["document", "document"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"]) expect(documents[0]).toBeInstanceOf(Config.Document) - expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json")) - expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json")) + expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), @@ -239,7 +235,29 @@ describe("Config", () => { (yield* config.entries()) .filter((entry) => entry.type === "document") .map((document) => document.info.$schema), - ).toEqual(["base", "middle", "last"]) + ).toEqual(["base", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("does not load legacy config.json files", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") + + expect(documents).toHaveLength(0) }).pipe(Effect.provide(testLayer(tmp.path))) }), ), @@ -681,7 +699,7 @@ describe("Config", () => { ), ) - it.live("ignores invalid files while loading valid config values", () => + it.live("ignores an invalid file while loading valid config values", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -690,9 +708,8 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), - fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), - fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"), ]), ) return yield* Effect.gen(function* () { @@ -761,7 +778,7 @@ describe("Config", () => { fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })), fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })), fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), - fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })), fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })), fs.writeFile( path.join(directory, ".opencode", "opencode.jsonc"), diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index e6944368c4..2e134c55e1 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -25,7 +25,7 @@ describe("ConfigExternalPlugin", () => { const location = yield* Location.Service const npm = yield* Npm.Service const host = yield* PluginHost.make(plugins) - const document = path.join(import.meta.dir, "config.json") + const document = path.join(import.meta.dir, "opencode.json") yield* ConfigExternalPlugin.Plugin.effect(host).pipe( Effect.provideService(PluginV2.Service, plugins), @@ -82,7 +82,7 @@ describe("ConfigExternalPlugin", () => { Effect.succeed([ new Config.Document({ type: "document", - path: path.join(import.meta.dir, "config.json"), + path: path.join(import.meta.dir, "opencode.json"), info: decode({ plugins: [ { @@ -125,7 +125,7 @@ describe("ConfigExternalPlugin", () => { Effect.succeed([ new Config.Document({ type: "document", - path: path.join(import.meta.dir, "config.json"), + path: path.join(import.meta.dir, "opencode.json"), info: decode({ plugins: [ "../plugin/fixtures/missing-plugin.ts", diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index a12bb06e3f..e6ea466f41 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -15,7 +15,7 @@ class OtherError { const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") -const build = (root: LayerNode.Node) => LayerNode.compile(root) as Layer.Layer +const build = (root: LayerNode.Node) => LayerNode.compile(root) const aLayer = Layer.succeed(A, A.of({})) const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) const cLayer = Layer.effect( @@ -32,8 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] }) const c = make({ service: C, layer: cLayer, deps: [a, b] }) const failing = make({ service: A, layer: failingA, deps: [] }) const dependent = make({ service: B, layer: bLayer, deps: [failing] }) -const inputA = LayerNode.unbound(A, tags.values.app) -const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) make({ name: "manual-a", layer: aLayer, deps: [] }) @@ -51,8 +49,8 @@ make({ service: C, layer: cLayer, deps: [a] }) const closed = build(LayerNode.group([c])) const closedWithError = build(LayerNode.group([dependent])) -const checkClosed: Layer.Layer = closed -const checkError: Layer.Layer = closedWithError +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = closedWithError void checkClosed void checkError @@ -64,7 +62,6 @@ LayerNode.replace(aLayer, Layer.succeed(B, B.of({}))) // @ts-expect-error Replacement cannot introduce a new error LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError()))) -// @ts-expect-error Replacement must be closed LayerNode.replace(bLayer, bLayer) class TagA extends Context.Service()("test/TagA") {} diff --git a/packages/core/test/fixture/tmpdir.ts b/packages/core/test/fixture/tmpdir.ts index 81d5d01667..58cfc1b40f 100644 --- a/packages/core/test/fixture/tmpdir.ts +++ b/packages/core/test/fixture/tmpdir.ts @@ -2,8 +2,8 @@ import fs from "fs/promises" import { tmpdir as osTmpdir } from "os" import path from "path" -export const tmpdir = async () => { - const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))) +export const tmpdir = async (prefix = "opencode-core-test-") => { + const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix))) return { path: dir, async [Symbol.asyncDispose]() { diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 0639837163..fa625a591b 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -18,6 +18,22 @@ export const toolDefinitions = ( model = testModel, ) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions)) +export function waitForTool( + registry: ToolRegistry.Interface, + name: string, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return + if (remaining === 0) { + yield* Effect.fail(new Error(`Timed out waiting for tool: ${name}`)) + return + } + yield* Effect.promise(() => Bun.sleep(1)) + yield* waitForTool(registry, name, remaining - 1) + }) +} + export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input))) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 9d96add1d2..ba1d183cd5 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -2,7 +2,6 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" -import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -17,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { toolDefinitions } from "./lib/tool" +import { toolDefinitions, waitForTool } from "./lib/tool" import { FSUtil } from "../src/fs-util" import { Credential } from "../src/credential" import { Database } from "../src/database/database" @@ -28,14 +27,11 @@ import { Npm } from "../src/npm" import { Project } from "../src/project" import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" -import { ApplicationTools } from "../src/tool/application-tools" -const applicationTools = ApplicationTools.layer const it = testEffect( Layer.merge( - Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), + Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer), locationServiceMapLayer.pipe( - Layer.provide(applicationTools), Layer.provide( Layer.mergeAll( Project.defaultLayer, @@ -83,14 +79,6 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { - yield* (yield* ApplicationTools.Service).register({ - application_context: Tool.make({ - description: "Read application context", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) yield* Effect.promise(() => fs.writeFile( path.join(blocked.path, "opencode.json"), @@ -105,9 +93,12 @@ describe("LocationServiceMap", () => { yield* Reference.Service const catalog = yield* Catalog.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "shell") + yield* waitForTool(registry, "subagent") return { providers: yield* catalog.provider.all(), - tools: yield* toolDefinitions(yield* ToolRegistry.Service), + tools: yield* toolDefinitions(registry), } }).pipe( Effect.scoped, @@ -119,13 +110,14 @@ describe("LocationServiceMap", () => { const blockedState = yield* update(blocked.path) expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ - "application_context", "edit", "glob", "grep", "question", "read", + "shell", "skill", + "subagent", "todowrite", "webfetch", "websearch", @@ -134,13 +126,14 @@ describe("LocationServiceMap", () => { const allowedState = yield* update(allowed.path) expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ - "application_context", "edit", "glob", "grep", "question", "read", + "shell", "skill", + "subagent", "todowrite", "webfetch", "websearch", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 1e27897653..bbd332a124 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,9 +1,12 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber } from "effect" +import { Effect, Exit, Fiber, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Tool } from "@opencode-ai/core/tool/tool" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" +import { testModel } from "./lib/tool" import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) @@ -68,4 +71,35 @@ describe("PluginV2", () => { expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) + + it.effect("registers location tools through the plugin context", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const registry = yield* ToolRegistry.Service + const plugin = define({ + id: "tool-plugin", + effect: (ctx) => + ctx.tool + .register({ + plugin_tool: Tool.make({ + description: "Plugin tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + .pipe(Effect.orDie), + }) + + yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain( + "plugin_tool", + ) + + yield* plugins.remove(PluginV2.ID.make(plugin.id)) + expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain( + "plugin_tool", + ) + }), + ) }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 4561366d4c..bca8012920 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -14,6 +14,7 @@ export function host(overrides: Overrides = {}): PluginContext { return { options: {}, agent: overrides.agent ?? { + list: () => Effect.die("unused agent.list"), transform: () => Effect.die("unused agent.transform"), reload: () => Effect.die("unused agent.reload"), }, @@ -49,11 +50,21 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, + tool: overrides.tool ?? { + register: () => Effect.die("unused tool.register"), + }, + session: overrides.session ?? { + create: () => Effect.die("unused session.create"), + get: () => Effect.die("unused session.get"), + prompt: () => Effect.die("unused session.prompt"), + interrupt: () => Effect.die("unused session.interrupt"), + }, } } export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { return { + list: () => Effect.die("unused agent.list"), reload: agent.reload, transform: (callback) => agent.transform((draft) => diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 81d80e6f9f..513f5c6fda 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -2,7 +2,6 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" import type { PermissionV2 } from "@opencode-ai/core/permission" -import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -28,9 +27,8 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) +const registry = ToolRegistry.layer.pipe(Layer.provide(outputStore)) const it = testEffect(registry) -const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry)) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -399,38 +397,6 @@ describe("ToolRegistry", () => { }), ) - integrated.effect("rejects an application call after a Location override is registered", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const service = yield* ToolRegistry.Service - yield* applications.register({ echo: make() }) - const materialized = yield* service.materialize({ model: testModel }) - yield* service.register({ echo: make() }) - - expect((yield* materialized.settle(call("echo"))).result).toEqual({ - type: "error", - value: "Stale tool call: echo", - }) - }), - ) - - integrated.effect("rejects a Location call after removal reveals an application registration", () => - Effect.gen(function* () { - const applications = yield* ApplicationTools.Service - const service = yield* ToolRegistry.Service - yield* applications.register({ echo: make() }) - const scope = yield* Scope.make() - yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) - const materialized = yield* service.materialize({ model: testModel }) - yield* Scope.close(scope, Exit.void) - - expect((yield* materialized.settle(call("echo"))).result).toEqual({ - type: "error", - value: "Stale tool call: echo", - }) - }), - ) - it.effect("keeps captured execution running after registration mutation", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2e17444321..1405108dc0 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -36,7 +36,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" @@ -124,12 +123,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), -) +const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(ToolOutputStore.defaultLayer)) const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => @@ -286,7 +280,6 @@ const it = testEffect( SessionStore.defaultLayer, client, permission, - applications, agents, registry, echo, @@ -582,14 +575,14 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { - it.effect("advertises and executes a globally attached application tool", () => + it.effect("advertises and executes a location registered tool", () => Effect.gen(function* () { yield* setup - const applicationTools = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service const session = yield* SessionV2.Service const contexts: Tool.Context[] = [] - yield* applicationTools.register({ - application_context: Tool.make({ + yield* registry.register({ + location_context: Tool.make({ description: "Read application context", input: Schema.Struct({ query: Schema.String }), output: Schema.Struct({ answer: Schema.String }), @@ -604,7 +597,7 @@ describe("SessionRunnerLLM", () => { responses = [ [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }), + LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], @@ -613,13 +606,13 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") + expect(requests[0]?.tools.map((tool) => tool.name)).toContain("location_context") expect(contexts).toEqual([ { sessionID, agent: AgentV2.ID.make("build"), assistantMessageID: expect.stringMatching(/^msg_/), - toolCallID: "call-application", + toolCallID: "call-location", }, ]) expect(yield* session.context(sessionID)).toMatchObject([ @@ -629,7 +622,7 @@ describe("SessionRunnerLLM", () => { content: [ { type: "tool", - id: "call-application", + id: "call-location", state: { status: "completed", structured: { answer: "HELLO" } }, }, ], @@ -914,10 +907,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ - "Build agent instructions", - "Initial context", - ]) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) }), ) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 42617c8f75..b79638fa41 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -24,12 +24,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionStore } from "@opencode-ai/core/session/store" import { PermissionV2 } from "@opencode-ai/core/permission" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { ShellTool } from "@opencode-ai/core/tool/shell" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_shell_tool_test") const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") }) @@ -122,7 +123,7 @@ const layer = AppNodeBuilder.build( Job.node, ToolOutputStore.cleanupNode, SessionV2.node, - ShellTool.node, + PluginRuntime.providerNode, LocationServiceMap.node, filesystem, FSUtil.node, @@ -167,6 +168,7 @@ const withSession = (directory: string, body: (registry: ToolRegistry.I const locations = yield* LocationServiceMap.Service const locationLayer = locations.get(location) const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer)) + yield* waitForTool(registry, ShellTool.name) return yield* body(registry).pipe(Effect.provide(locationLayer)) }) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 1a1f2e8705..8bf145eddb 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -18,12 +18,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SubagentTool } from "@opencode-ai/core/tool/subagent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool" +import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool" const childText = "child final response" const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") }) @@ -98,7 +99,7 @@ const layer = AppNodeBuilder.build( Job.node, ToolOutputStore.cleanupNode, SessionV2.node, - SubagentTool.node, + PluginRuntime.providerNode, LocationServiceMap.node, ]), SessionExecution.node, @@ -142,6 +143,7 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain( SubagentTool.name, ) @@ -175,6 +177,7 @@ describe("SubagentTool", () => { yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) const settled = yield* settleTool(registry, { sessionID: parent.id, @@ -226,6 +229,7 @@ describe("SubagentTool", () => { yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) expect( yield* executeTool(registry, { @@ -257,6 +261,7 @@ describe("SubagentTool", () => { yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) const settled = yield* settleTool(registry, { sessionID: parent.id, diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 43d9e92881..9bd5850bde 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -244,6 +244,16 @@ export function emitEffectImported( } } +export function emitEffectShape( + contract: Contract, + options: { readonly module: string; readonly api: string }, +): Output { + return { + operations: operations(contract.groups), + files: [{ path: "api.ts", content: renderEffectShape(contract.groups, options) }], + } +} + export function emitPromise( contract: Contract, options?: { @@ -275,6 +285,75 @@ export function emitPromise( } } +function renderEffectShape(groups: ReadonlyArray, options: { readonly module: string; readonly api: string }) { + const endpointTypes = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const endpoints = group.endpoints.map((endpoint, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = + endpoint.operation.inputMode === "none" + ? "" + : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(endpoint.endpoint.name)}]>[0]` + const input = endpoint.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const inputType = endpoint.operation.inputMode === "none" ? "" : `export type ${prefix}Input = { ${input} }` + const rawOutput = `EffectValue>` + const outputType = isStreamSchema(endpoint.successes[0]) + ? `export type ${prefix}Output = StreamValue<${rawOutput}>` + : `export type ${prefix}Output = ${endpoint.unwrapData ? `(${rawOutput})["data"]` : rawOutput}` + return [ + request, + endpoint.operation.inputMode === "none" ? "" : inputType, + outputType, + `export type ${groupShapeTypeName(group, endpoint)} = (${endpoint.operation.inputMode === "none" ? "" : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`}) => ${endpoint.operation.success === "stream" ? `Stream.Stream<${prefix}Output, E>` : `Effect.Effect<${prefix}Output, E>`}`, + ] + .filter(Boolean) + .join("\n") + }) + const methods = group.endpoints + .map( + (endpoint) => `readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}`, + ) + .join("\n") + return `${endpoints.join("\n\n")}\n\nexport interface ${groupShapeName(group)} {\n${methods}\n}` + }) + const clientFields = groups.flatMap((group) => + group.endpoints[0]?.topLevel + ? group.endpoints.map( + (endpoint) => + `readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}`, + ) + : [`readonly ${JSON.stringify(group.identifier)}: ${groupShapeName(group)}`], + ) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import type { Effect, Stream } from "effect" +import type { HttpApiClient } from "effect/unstable/httpapi" +import type { ${options.api} } from ${JSON.stringify(options.module)} + +type RawClient = HttpApiClient.ForApi +type EffectValue = A extends Effect.Effect ? Success : never +type StreamValue = A extends Stream.Stream ? Success : never + +${endpointTypes.join("\n\n")} + +export interface AppApi { +${clientFields.join("\n")} +} +` +} + +function groupShapeName(group: Group) { + return `${identifierPart(group.identifier)}Api` +} + +function groupShapeTypeName(group: Group, endpoint: Endpoint) { + return `${identifierPart(group.identifier)}${identifierPart(endpoint.operation.name)}Operation` +} + function assertPromiseEndpoint(endpoint: Endpoint) { const name = `${endpoint.group}.${endpoint.endpoint.name}` const payload = endpoint.payloads[0] diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 6e3c59bea3..46f658eebc 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,6 +22,7 @@ ], "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/protocol": "workspace:*", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:" @@ -46,6 +47,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@tsconfig/bun": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/example.ts b/packages/plugin/src/example.ts index 9d7e178a96..bf79ab511e 100644 --- a/packages/plugin/src/example.ts +++ b/packages/plugin/src/example.ts @@ -1,4 +1,4 @@ -import { Plugin } from "./index.js" +import type { Plugin } from "./index.js" import { tool } from "./tool.js" export const ExamplePlugin: Plugin = async (_ctx) => { diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 9ded7b831d..3c9e6d4210 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,4 +1,5 @@ import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { AgentApi } from "./generated/api.js" import type { Hooks } from "./registration.js" export interface AgentDraft { @@ -12,3 +13,6 @@ export interface AgentDraft { export type AgentHooks = Hooks<{ transform: AgentDraft }> + +export type AgentPluginApi = AgentHooks +export type AgentDomain = AgentApi & AgentPluginApi diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 9089334ee3..3f77fcd48d 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -1,5 +1,5 @@ import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" +import type { AgentDomain } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" @@ -8,10 +8,12 @@ import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" +import type { ToolDomain } from "./tool.js" +import type { SessionDomain } from "./runtime.js" export interface PluginContext { readonly options: PluginOptions - readonly agent: AgentHooks & Reload + readonly agent: AgentDomain & Reload readonly aisdk: AISDKHooks readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload @@ -19,4 +21,6 @@ export interface PluginContext { readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload + readonly tool: ToolDomain + readonly session: SessionDomain } diff --git a/packages/plugin/src/v2/effect/generated/.httpapi-codegen.json b/packages/plugin/src/v2/effect/generated/.httpapi-codegen.json new file mode 100644 index 0000000000..d1d5afdb85 --- /dev/null +++ b/packages/plugin/src/v2/effect/generated/.httpapi-codegen.json @@ -0,0 +1,3 @@ +[ + "api.ts" +] diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts new file mode 100644 index 0000000000..e2718d35df --- /dev/null +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -0,0 +1,705 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import type { Effect, Stream } from "effect" +import type { HttpApiClient } from "effect/unstable/httpapi" +import type { ClientApi } from "@opencode-ai/protocol/client" + +type RawClient = HttpApiClient.ForApi +type EffectValue = A extends Effect.Effect ? Success : never +type StreamValue = A extends Stream.Stream ? Success : never + +export type Endpoint0_0Output = EffectValue> +export type HealthGetOperation = () => Effect.Effect + +export interface HealthApi { + readonly get: HealthGetOperation +} + +type Endpoint1_0Request = Parameters[0] +export type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } +export type Endpoint1_0Output = EffectValue> +export type LocationGetOperation = (input?: Endpoint1_0Input) => Effect.Effect + +export interface LocationApi { + readonly get: LocationGetOperation +} + +type Endpoint2_0Request = Parameters[0] +export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +export type Endpoint2_0Output = EffectValue> +export type AgentListOperation = (input?: Endpoint2_0Input) => Effect.Effect + +export interface AgentApi { + readonly list: AgentListOperation +} + +type Endpoint3_0Request = Parameters[0] +export type Endpoint3_0Input = { + readonly workspace?: Endpoint3_0Request["query"]["workspace"] + readonly limit?: Endpoint3_0Request["query"]["limit"] + readonly order?: Endpoint3_0Request["query"]["order"] + readonly search?: Endpoint3_0Request["query"]["search"] + readonly directory?: Endpoint3_0Request["query"]["directory"] + readonly project?: Endpoint3_0Request["query"]["project"] + readonly subpath?: Endpoint3_0Request["query"]["subpath"] + readonly cursor?: Endpoint3_0Request["query"]["cursor"] +} +export type Endpoint3_0Output = EffectValue> +export type SessionListOperation = (input?: Endpoint3_0Input) => Effect.Effect + +type Endpoint3_1Request = Parameters[0] +export type Endpoint3_1Input = { + readonly id?: Endpoint3_1Request["payload"]["id"] + readonly agent?: Endpoint3_1Request["payload"]["agent"] + readonly model?: Endpoint3_1Request["payload"]["model"] + readonly location?: Endpoint3_1Request["payload"]["location"] +} +export type Endpoint3_1Output = EffectValue>["data"] +export type SessionCreateOperation = (input?: Endpoint3_1Input) => Effect.Effect + +export type Endpoint3_2Output = EffectValue>["data"] +export type SessionActiveOperation = () => Effect.Effect + +type Endpoint3_3Request = Parameters[0] +export type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] } +export type Endpoint3_3Output = EffectValue>["data"] +export type SessionGetOperation = (input: Endpoint3_3Input) => Effect.Effect + +type Endpoint3_4Request = Parameters[0] +export type Endpoint3_4Input = { + readonly sessionID: Endpoint3_4Request["params"]["sessionID"] + readonly messageID?: Endpoint3_4Request["payload"]["messageID"] +} +export type Endpoint3_4Output = EffectValue>["data"] +export type SessionForkOperation = (input: Endpoint3_4Input) => Effect.Effect + +type Endpoint3_5Request = Parameters[0] +export type Endpoint3_5Input = { + readonly sessionID: Endpoint3_5Request["params"]["sessionID"] + readonly agent: Endpoint3_5Request["payload"]["agent"] +} +export type Endpoint3_5Output = EffectValue> +export type SessionSwitchAgentOperation = (input: Endpoint3_5Input) => Effect.Effect + +type Endpoint3_6Request = Parameters[0] +export type Endpoint3_6Input = { + readonly sessionID: Endpoint3_6Request["params"]["sessionID"] + readonly model: Endpoint3_6Request["payload"]["model"] +} +export type Endpoint3_6Output = EffectValue> +export type SessionSwitchModelOperation = (input: Endpoint3_6Input) => Effect.Effect + +type Endpoint3_7Request = Parameters[0] +export type Endpoint3_7Input = { + readonly sessionID: Endpoint3_7Request["params"]["sessionID"] + readonly title: Endpoint3_7Request["payload"]["title"] +} +export type Endpoint3_7Output = EffectValue> +export type SessionRenameOperation = (input: Endpoint3_7Input) => Effect.Effect + +type Endpoint3_8Request = Parameters[0] +export type Endpoint3_8Input = { + readonly sessionID: Endpoint3_8Request["params"]["sessionID"] + readonly id?: Endpoint3_8Request["payload"]["id"] + readonly prompt: Endpoint3_8Request["payload"]["prompt"] + readonly delivery?: Endpoint3_8Request["payload"]["delivery"] + readonly resume?: Endpoint3_8Request["payload"]["resume"] +} +export type Endpoint3_8Output = EffectValue>["data"] +export type SessionPromptOperation = (input: Endpoint3_8Input) => Effect.Effect + +type Endpoint3_9Request = Parameters[0] +export type Endpoint3_9Input = { + readonly sessionID: Endpoint3_9Request["params"]["sessionID"] + readonly id?: Endpoint3_9Request["payload"]["id"] + readonly skill: Endpoint3_9Request["payload"]["skill"] + readonly resume?: Endpoint3_9Request["payload"]["resume"] +} +export type Endpoint3_9Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint3_9Input) => Effect.Effect + +type Endpoint3_10Request = Parameters[0] +export type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } +export type Endpoint3_10Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint3_10Input) => Effect.Effect + +type Endpoint3_11Request = Parameters[0] +export type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } +export type Endpoint3_11Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint3_11Input) => Effect.Effect + +type Endpoint3_12Request = Parameters[0] +export type Endpoint3_12Input = { + readonly sessionID: Endpoint3_12Request["params"]["sessionID"] + readonly messageID: Endpoint3_12Request["payload"]["messageID"] + readonly files?: Endpoint3_12Request["payload"]["files"] +} +export type Endpoint3_12Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint3_12Input) => Effect.Effect + +type Endpoint3_13Request = Parameters[0] +export type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] } +export type Endpoint3_13Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint3_13Input) => Effect.Effect + +type Endpoint3_14Request = Parameters[0] +export type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] } +export type Endpoint3_14Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint3_14Input) => Effect.Effect + +type Endpoint3_15Request = Parameters[0] +export type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } +export type Endpoint3_15Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint3_15Input) => Effect.Effect + +type Endpoint3_16Request = Parameters[0] +export type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly limit?: Endpoint3_16Request["query"]["limit"] + readonly after?: Endpoint3_16Request["query"]["after"] +} +export type Endpoint3_16Output = EffectValue> +export type SessionHistoryOperation = (input: Endpoint3_16Input) => Effect.Effect + +type Endpoint3_17Request = Parameters[0] +export type Endpoint3_17Input = { + readonly sessionID: Endpoint3_17Request["params"]["sessionID"] + readonly after?: Endpoint3_17Request["query"]["after"] +} +export type Endpoint3_17Output = StreamValue>> +export type SessionEventsOperation = (input: Endpoint3_17Input) => Stream.Stream + +type Endpoint3_18Request = Parameters[0] +export type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] } +export type Endpoint3_18Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint3_18Input) => Effect.Effect + +type Endpoint3_19Request = Parameters[0] +export type Endpoint3_19Input = { + readonly sessionID: Endpoint3_19Request["params"]["sessionID"] + readonly messageID: Endpoint3_19Request["params"]["messageID"] +} +export type Endpoint3_19Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint3_19Input) => Effect.Effect + +export interface SessionApi { + readonly list: SessionListOperation + readonly create: SessionCreateOperation + readonly active: SessionActiveOperation + readonly get: SessionGetOperation + readonly fork: SessionForkOperation + readonly switchAgent: SessionSwitchAgentOperation + readonly switchModel: SessionSwitchModelOperation + readonly rename: SessionRenameOperation + readonly prompt: SessionPromptOperation + readonly skill: SessionSkillOperation + readonly compact: SessionCompactOperation + readonly wait: SessionWaitOperation + readonly revertStage: SessionRevertStageOperation + readonly revertClear: SessionRevertClearOperation + readonly revertCommit: SessionRevertCommitOperation + readonly context: SessionContextOperation + readonly history: SessionHistoryOperation + readonly events: SessionEventsOperation + readonly interrupt: SessionInterruptOperation + readonly message: SessionMessageOperation +} + +type Endpoint4_0Request = Parameters[0] +export type Endpoint4_0Input = { + readonly sessionID: Endpoint4_0Request["params"]["sessionID"] + readonly limit?: Endpoint4_0Request["query"]["limit"] + readonly order?: Endpoint4_0Request["query"]["order"] + readonly cursor?: Endpoint4_0Request["query"]["cursor"] +} +export type Endpoint4_0Output = EffectValue> +export type MessageListOperation = (input: Endpoint4_0Input) => Effect.Effect + +export interface MessageApi { + readonly list: MessageListOperation +} + +type Endpoint5_0Request = Parameters[0] +export type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] } +export type Endpoint5_0Output = EffectValue> +export type ModelListOperation = (input?: Endpoint5_0Input) => Effect.Effect + +export interface ModelApi { + readonly list: ModelListOperation +} + +type Endpoint6_0Request = Parameters[0] +export type Endpoint6_0Input = { + readonly location?: Endpoint6_0Request["query"]["location"] + readonly prompt: Endpoint6_0Request["payload"]["prompt"] + readonly model?: Endpoint6_0Request["payload"]["model"] +} +export type Endpoint6_0Output = EffectValue>["data"] +export type GenerateTextOperation = (input: Endpoint6_0Input) => Effect.Effect + +export interface GenerateApi { + readonly text: GenerateTextOperation +} + +type Endpoint7_0Request = Parameters[0] +export type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +export type Endpoint7_0Output = EffectValue> +export type ProviderListOperation = (input?: Endpoint7_0Input) => Effect.Effect + +type Endpoint7_1Request = Parameters[0] +export type Endpoint7_1Input = { + readonly providerID: Endpoint7_1Request["params"]["providerID"] + readonly location?: Endpoint7_1Request["query"]["location"] +} +export type Endpoint7_1Output = EffectValue> +export type ProviderGetOperation = (input: Endpoint7_1Input) => Effect.Effect + +export interface ProviderApi { + readonly list: ProviderListOperation + readonly get: ProviderGetOperation +} + +type Endpoint8_0Request = Parameters[0] +export type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } +export type Endpoint8_0Output = EffectValue> +export type IntegrationListOperation = (input?: Endpoint8_0Input) => Effect.Effect + +type Endpoint8_1Request = Parameters[0] +export type Endpoint8_1Input = { + readonly integrationID: Endpoint8_1Request["params"]["integrationID"] + readonly location?: Endpoint8_1Request["query"]["location"] +} +export type Endpoint8_1Output = EffectValue> +export type IntegrationGetOperation = (input: Endpoint8_1Input) => Effect.Effect + +type Endpoint8_2Request = Parameters[0] +export type Endpoint8_2Input = { + readonly integrationID: Endpoint8_2Request["params"]["integrationID"] + readonly location?: Endpoint8_2Request["query"]["location"] + readonly key: Endpoint8_2Request["payload"]["key"] + readonly label?: Endpoint8_2Request["payload"]["label"] +} +export type Endpoint8_2Output = EffectValue> +export type IntegrationConnectKeyOperation = (input: Endpoint8_2Input) => Effect.Effect + +type Endpoint8_3Request = Parameters[0] +export type Endpoint8_3Input = { + readonly integrationID: Endpoint8_3Request["params"]["integrationID"] + readonly location?: Endpoint8_3Request["query"]["location"] + readonly methodID: Endpoint8_3Request["payload"]["methodID"] + readonly inputs: Endpoint8_3Request["payload"]["inputs"] + readonly label?: Endpoint8_3Request["payload"]["label"] +} +export type Endpoint8_3Output = EffectValue> +export type IntegrationConnectOauthOperation = ( + input: Endpoint8_3Input, +) => Effect.Effect + +type Endpoint8_4Request = Parameters[0] +export type Endpoint8_4Input = { + readonly attemptID: Endpoint8_4Request["params"]["attemptID"] + readonly location?: Endpoint8_4Request["query"]["location"] +} +export type Endpoint8_4Output = EffectValue> +export type IntegrationAttemptStatusOperation = ( + input: Endpoint8_4Input, +) => Effect.Effect + +type Endpoint8_5Request = Parameters[0] +export type Endpoint8_5Input = { + readonly attemptID: Endpoint8_5Request["params"]["attemptID"] + readonly location?: Endpoint8_5Request["query"]["location"] + readonly code?: Endpoint8_5Request["payload"]["code"] +} +export type Endpoint8_5Output = EffectValue> +export type IntegrationAttemptCompleteOperation = ( + input: Endpoint8_5Input, +) => Effect.Effect + +type Endpoint8_6Request = Parameters[0] +export type Endpoint8_6Input = { + readonly attemptID: Endpoint8_6Request["params"]["attemptID"] + readonly location?: Endpoint8_6Request["query"]["location"] +} +export type Endpoint8_6Output = EffectValue> +export type IntegrationAttemptCancelOperation = ( + input: Endpoint8_6Input, +) => Effect.Effect + +export interface IntegrationApi { + readonly list: IntegrationListOperation + readonly get: IntegrationGetOperation + readonly connectKey: IntegrationConnectKeyOperation + readonly connectOauth: IntegrationConnectOauthOperation + readonly attemptStatus: IntegrationAttemptStatusOperation + readonly attemptComplete: IntegrationAttemptCompleteOperation + readonly attemptCancel: IntegrationAttemptCancelOperation +} + +type Endpoint9_0Request = Parameters[0] +export type Endpoint9_0Input = { + readonly credentialID: Endpoint9_0Request["params"]["credentialID"] + readonly location?: Endpoint9_0Request["query"]["location"] + readonly label: Endpoint9_0Request["payload"]["label"] +} +export type Endpoint9_0Output = EffectValue> +export type CredentialUpdateOperation = (input: Endpoint9_0Input) => Effect.Effect + +type Endpoint9_1Request = Parameters[0] +export type Endpoint9_1Input = { + readonly credentialID: Endpoint9_1Request["params"]["credentialID"] + readonly location?: Endpoint9_1Request["query"]["location"] +} +export type Endpoint9_1Output = EffectValue> +export type CredentialRemoveOperation = (input: Endpoint9_1Input) => Effect.Effect + +export interface CredentialApi { + readonly update: CredentialUpdateOperation + readonly remove: CredentialRemoveOperation +} + +type Endpoint10_0Request = Parameters[0] +export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } +export type Endpoint10_0Output = EffectValue> +export type ProjectCurrentOperation = (input?: Endpoint10_0Input) => Effect.Effect + +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { + readonly projectID: Endpoint10_1Request["params"]["projectID"] + readonly location?: Endpoint10_1Request["query"]["location"] +} +export type Endpoint10_1Output = EffectValue> +export type ProjectDirectoriesOperation = (input: Endpoint10_1Input) => Effect.Effect + +export interface ProjectApi { + readonly current: ProjectCurrentOperation + readonly directories: ProjectDirectoriesOperation +} + +type Endpoint11_0Request = Parameters[0] +export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +export type Endpoint11_0Output = EffectValue> +export type PermissionListRequestsOperation = ( + input?: Endpoint11_0Input, +) => Effect.Effect + +type Endpoint11_1Request = Parameters[0] +export type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] } +export type Endpoint11_1Output = EffectValue< + ReturnType +>["data"] +export type PermissionListSavedOperation = ( + input?: Endpoint11_1Input, +) => Effect.Effect + +type Endpoint11_2Request = Parameters[0] +export type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] } +export type Endpoint11_2Output = EffectValue> +export type PermissionRemoveSavedOperation = ( + input: Endpoint11_2Input, +) => Effect.Effect + +type Endpoint11_3Request = Parameters[0] +export type Endpoint11_3Input = { + readonly sessionID: Endpoint11_3Request["params"]["sessionID"] + readonly id?: Endpoint11_3Request["payload"]["id"] + readonly action: Endpoint11_3Request["payload"]["action"] + readonly resources: Endpoint11_3Request["payload"]["resources"] + readonly save?: Endpoint11_3Request["payload"]["save"] + readonly metadata?: Endpoint11_3Request["payload"]["metadata"] + readonly source?: Endpoint11_3Request["payload"]["source"] + readonly agent?: Endpoint11_3Request["payload"]["agent"] +} +export type Endpoint11_3Output = EffectValue< + ReturnType +>["data"] +export type PermissionCreateOperation = (input: Endpoint11_3Input) => Effect.Effect + +type Endpoint11_4Request = Parameters[0] +export type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] } +export type Endpoint11_4Output = EffectValue< + ReturnType +>["data"] +export type PermissionListOperation = (input: Endpoint11_4Input) => Effect.Effect + +type Endpoint11_5Request = Parameters[0] +export type Endpoint11_5Input = { + readonly sessionID: Endpoint11_5Request["params"]["sessionID"] + readonly requestID: Endpoint11_5Request["params"]["requestID"] +} +export type Endpoint11_5Output = EffectValue< + ReturnType +>["data"] +export type PermissionGetOperation = (input: Endpoint11_5Input) => Effect.Effect + +type Endpoint11_6Request = Parameters[0] +export type Endpoint11_6Input = { + readonly sessionID: Endpoint11_6Request["params"]["sessionID"] + readonly requestID: Endpoint11_6Request["params"]["requestID"] + readonly reply: Endpoint11_6Request["payload"]["reply"] + readonly message?: Endpoint11_6Request["payload"]["message"] +} +export type Endpoint11_6Output = EffectValue> +export type PermissionReplyOperation = (input: Endpoint11_6Input) => Effect.Effect + +export interface PermissionApi { + readonly listRequests: PermissionListRequestsOperation + readonly listSaved: PermissionListSavedOperation + readonly removeSaved: PermissionRemoveSavedOperation + readonly create: PermissionCreateOperation + readonly list: PermissionListOperation + readonly get: PermissionGetOperation + readonly reply: PermissionReplyOperation +} + +type Endpoint12_0Request = Parameters[0] +export type Endpoint12_0Input = { + readonly location?: Endpoint12_0Request["query"]["location"] + readonly path?: Endpoint12_0Request["query"]["path"] +} +export type Endpoint12_0Output = EffectValue> +export type FileListOperation = (input?: Endpoint12_0Input) => Effect.Effect + +type Endpoint12_1Request = Parameters[0] +export type Endpoint12_1Input = { + readonly location?: Endpoint12_1Request["query"]["location"] + readonly query: Endpoint12_1Request["query"]["query"] + readonly type?: Endpoint12_1Request["query"]["type"] + readonly limit?: Endpoint12_1Request["query"]["limit"] +} +export type Endpoint12_1Output = EffectValue> +export type FileFindOperation = (input: Endpoint12_1Input) => Effect.Effect + +export interface FileApi { + readonly list: FileListOperation + readonly find: FileFindOperation +} + +type Endpoint13_0Request = Parameters[0] +export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } +export type Endpoint13_0Output = EffectValue> +export type CommandListOperation = (input?: Endpoint13_0Input) => Effect.Effect + +export interface CommandApi { + readonly list: CommandListOperation +} + +type Endpoint14_0Request = Parameters[0] +export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +export type Endpoint14_0Output = EffectValue> +export type SkillListOperation = (input?: Endpoint14_0Input) => Effect.Effect + +export interface SkillApi { + readonly list: SkillListOperation +} + +export type Endpoint15_0Output = StreamValue>> +export type EventSubscribeOperation = () => Stream.Stream + +export interface EventApi { + readonly subscribe: EventSubscribeOperation +} + +type Endpoint16_0Request = Parameters[0] +export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +export type Endpoint16_0Output = EffectValue> +export type PtyListOperation = (input?: Endpoint16_0Input) => Effect.Effect + +type Endpoint16_1Request = Parameters[0] +export type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly command?: Endpoint16_1Request["payload"]["command"] + readonly args?: Endpoint16_1Request["payload"]["args"] + readonly cwd?: Endpoint16_1Request["payload"]["cwd"] + readonly title?: Endpoint16_1Request["payload"]["title"] + readonly env?: Endpoint16_1Request["payload"]["env"] +} +export type Endpoint16_1Output = EffectValue> +export type PtyCreateOperation = (input?: Endpoint16_1Input) => Effect.Effect + +type Endpoint16_2Request = Parameters[0] +export type Endpoint16_2Input = { + readonly ptyID: Endpoint16_2Request["params"]["ptyID"] + readonly location?: Endpoint16_2Request["query"]["location"] +} +export type Endpoint16_2Output = EffectValue> +export type PtyGetOperation = (input: Endpoint16_2Input) => Effect.Effect + +type Endpoint16_3Request = Parameters[0] +export type Endpoint16_3Input = { + readonly ptyID: Endpoint16_3Request["params"]["ptyID"] + readonly location?: Endpoint16_3Request["query"]["location"] + readonly title?: Endpoint16_3Request["payload"]["title"] + readonly size?: Endpoint16_3Request["payload"]["size"] +} +export type Endpoint16_3Output = EffectValue> +export type PtyUpdateOperation = (input: Endpoint16_3Input) => Effect.Effect + +type Endpoint16_4Request = Parameters[0] +export type Endpoint16_4Input = { + readonly ptyID: Endpoint16_4Request["params"]["ptyID"] + readonly location?: Endpoint16_4Request["query"]["location"] +} +export type Endpoint16_4Output = EffectValue> +export type PtyRemoveOperation = (input: Endpoint16_4Input) => Effect.Effect + +export interface PtyApi { + readonly list: PtyListOperation + readonly create: PtyCreateOperation + readonly get: PtyGetOperation + readonly update: PtyUpdateOperation + readonly remove: PtyRemoveOperation +} + +type Endpoint17_0Request = Parameters[0] +export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +export type Endpoint17_0Output = EffectValue> +export type ShellListOperation = (input?: Endpoint17_0Input) => Effect.Effect + +type Endpoint17_1Request = Parameters[0] +export type Endpoint17_1Input = { + readonly location?: Endpoint17_1Request["query"]["location"] + readonly command: Endpoint17_1Request["payload"]["command"] + readonly cwd?: Endpoint17_1Request["payload"]["cwd"] + readonly timeout?: Endpoint17_1Request["payload"]["timeout"] + readonly metadata?: Endpoint17_1Request["payload"]["metadata"] +} +export type Endpoint17_1Output = EffectValue> +export type ShellCreateOperation = (input: Endpoint17_1Input) => Effect.Effect + +type Endpoint17_2Request = Parameters[0] +export type Endpoint17_2Input = { + readonly id: Endpoint17_2Request["params"]["id"] + readonly location?: Endpoint17_2Request["query"]["location"] +} +export type Endpoint17_2Output = EffectValue> +export type ShellGetOperation = (input: Endpoint17_2Input) => Effect.Effect + +type Endpoint17_3Request = Parameters[0] +export type Endpoint17_3Input = { + readonly id: Endpoint17_3Request["params"]["id"] + readonly location?: Endpoint17_3Request["query"]["location"] + readonly cursor?: Endpoint17_3Request["query"]["cursor"] + readonly limit?: Endpoint17_3Request["query"]["limit"] +} +export type Endpoint17_3Output = EffectValue> +export type ShellOutputOperation = (input: Endpoint17_3Input) => Effect.Effect + +type Endpoint17_4Request = Parameters[0] +export type Endpoint17_4Input = { + readonly id: Endpoint17_4Request["params"]["id"] + readonly location?: Endpoint17_4Request["query"]["location"] +} +export type Endpoint17_4Output = EffectValue> +export type ShellRemoveOperation = (input: Endpoint17_4Input) => Effect.Effect + +export interface ShellApi { + readonly list: ShellListOperation + readonly create: ShellCreateOperation + readonly get: ShellGetOperation + readonly output: ShellOutputOperation + readonly remove: ShellRemoveOperation +} + +type Endpoint18_0Request = Parameters[0] +export type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +export type Endpoint18_0Output = EffectValue> +export type QuestionListRequestsOperation = ( + input?: Endpoint18_0Input, +) => Effect.Effect + +type Endpoint18_1Request = Parameters[0] +export type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] } +export type Endpoint18_1Output = EffectValue>["data"] +export type QuestionListOperation = (input: Endpoint18_1Input) => Effect.Effect + +type Endpoint18_2Request = Parameters[0] +export type Endpoint18_2Input = { + readonly sessionID: Endpoint18_2Request["params"]["sessionID"] + readonly requestID: Endpoint18_2Request["params"]["requestID"] + readonly answers: Endpoint18_2Request["payload"]["answers"] +} +export type Endpoint18_2Output = EffectValue> +export type QuestionReplyOperation = (input: Endpoint18_2Input) => Effect.Effect + +type Endpoint18_3Request = Parameters[0] +export type Endpoint18_3Input = { + readonly sessionID: Endpoint18_3Request["params"]["sessionID"] + readonly requestID: Endpoint18_3Request["params"]["requestID"] +} +export type Endpoint18_3Output = EffectValue> +export type QuestionRejectOperation = (input: Endpoint18_3Input) => Effect.Effect + +export interface QuestionApi { + readonly listRequests: QuestionListRequestsOperation + readonly list: QuestionListOperation + readonly reply: QuestionReplyOperation + readonly reject: QuestionRejectOperation +} + +type Endpoint19_0Request = Parameters[0] +export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } +export type Endpoint19_0Output = EffectValue> +export type ReferenceListOperation = (input?: Endpoint19_0Input) => Effect.Effect + +export interface ReferenceApi { + readonly list: ReferenceListOperation +} + +type Endpoint20_0Request = Parameters[0] +export type Endpoint20_0Input = { + readonly projectID: Endpoint20_0Request["params"]["projectID"] + readonly location?: Endpoint20_0Request["query"]["location"] + readonly strategy: Endpoint20_0Request["payload"]["strategy"] + readonly directory: Endpoint20_0Request["payload"]["directory"] + readonly name?: Endpoint20_0Request["payload"]["name"] +} +export type Endpoint20_0Output = EffectValue> +export type ProjectCopyCreateOperation = (input: Endpoint20_0Input) => Effect.Effect + +type Endpoint20_1Request = Parameters[0] +export type Endpoint20_1Input = { + readonly projectID: Endpoint20_1Request["params"]["projectID"] + readonly location?: Endpoint20_1Request["query"]["location"] + readonly directory: Endpoint20_1Request["payload"]["directory"] + readonly force: Endpoint20_1Request["payload"]["force"] +} +export type Endpoint20_1Output = EffectValue> +export type ProjectCopyRemoveOperation = (input: Endpoint20_1Input) => Effect.Effect + +type Endpoint20_2Request = Parameters[0] +export type Endpoint20_2Input = { + readonly projectID: Endpoint20_2Request["params"]["projectID"] + readonly location?: Endpoint20_2Request["query"]["location"] +} +export type Endpoint20_2Output = EffectValue> +export type ProjectCopyRefreshOperation = (input: Endpoint20_2Input) => Effect.Effect + +export interface ProjectCopyApi { + readonly create: ProjectCopyCreateOperation + readonly remove: ProjectCopyRemoveOperation + readonly refresh: ProjectCopyRefreshOperation +} + +export interface AppApi { + readonly health: HealthApi + readonly location: LocationApi + readonly agent: AgentApi + readonly session: SessionApi + readonly message: MessageApi + readonly model: ModelApi + readonly generate: GenerateApi + readonly provider: ProviderApi + readonly integration: IntegrationApi + readonly credential: CredentialApi + readonly project: ProjectApi + readonly permission: PermissionApi + readonly file: FileApi + readonly command: CommandApi + readonly skill: SkillApi + readonly event: EventApi + readonly pty: PtyApi + readonly shell: ShellApi + readonly question: QuestionApi + readonly reference: ReferenceApi + readonly projectCopy: ProjectCopyApi +} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index f13614a54d..d55346d308 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,3 +1,5 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" export type { Plugin } from "./plugin.js" +export type { ToolDomain } from "./tool.js" +export type { SessionDomain } from "./runtime.js" diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts new file mode 100644 index 0000000000..1907067320 --- /dev/null +++ b/packages/plugin/src/v2/effect/runtime.ts @@ -0,0 +1,3 @@ +import type { SessionApi } from "./generated/api.js" + +export type SessionDomain = Pick, "create" | "get" | "prompt" | "interrupt"> diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts new file mode 100644 index 0000000000..f62d005a98 --- /dev/null +++ b/packages/plugin/src/v2/effect/tool.ts @@ -0,0 +1,5 @@ +import type { Effect, Scope } from "effect" + +export interface ToolDomain { + readonly register: (tools: Readonly>) => Effect.Effect +} diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index f8e9370d86..8ee56d236e 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -1,12 +1,10 @@ { "$schema": "https://json.schemastore.org/tsconfig.json", - "extends": "@tsconfig/node22/tsconfig.json", + "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", - "module": "nodenext", "declaration": true, - "moduleResolution": "nodenext", "lib": ["es2022", "dom", "dom.iterable"] }, "include": ["src"] diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts new file mode 100644 index 0000000000..f71ef465cf --- /dev/null +++ b/packages/protocol/src/client.ts @@ -0,0 +1,60 @@ +import { InvalidRequestError, SessionNotFoundError } from "./errors" +import { makeDefaultApi } from "./api" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/LocationMiddleware", +) {} + +class SessionLocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode-ai/client/SessionLocationMiddleware", + { error: [InvalidRequestError, SessionNotFoundError] }, +) {} + +export const ClientApi = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) + +export const groupNames = { + "server.health": "health", + "server.location": "location", + "server.agent": "agent", + "server.session": "session", + "server.message": "message", + "server.model": "model", + "server.generate": "generate", + "server.provider": "provider", + "server.integration": "integration", + "server.credential": "credential", + "server.permission": "permission", + "server.fs": "file", + "server.command": "command", + "server.skill": "skill", + "server.event": "event", + "server.pty": "pty", + "server.shell": "shell", + "server.question": "question", + "server.reference": "reference", + "server.project": "project", + "server.projectCopy": "projectCopy", +} as const + +export const endpointNames = { + "session.messages": "list", + "integration.connect.key": "connectKey", + "integration.connect.oauth": "connectOauth", + "integration.attempt.status": "attemptStatus", + "integration.attempt.complete": "attemptComplete", + "integration.attempt.cancel": "attemptCancel", + "session.revert.stage": "revertStage", + "session.revert.clear": "revertClear", + "session.revert.commit": "revertCommit", + "permission.request.list": "listRequests", + "permission.saved.list": "listSaved", + "permission.saved.remove": "removeSaved", + "question.request.list": "listRequests", +} as const + +export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) +export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 7c055a0ab2..ae50157e38 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -1,6 +1,8 @@ export * as Mcp from "./mcp" import { Schema } from "effect" +import { optional } from "./schema" +import { IntegrationID } from "./integration-id" const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "Mcp.Status.Connected", @@ -36,4 +38,7 @@ export interface Server extends Schema.Schema.Type {} export const Server = Schema.Struct({ name: Schema.String, status: Status, + // Set for remote servers registered as OAuth integrations; lets clients act on the right integration + // without matching by name, which could collide with provider or plugin integrations. + integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index 6a9e3bef3d..545606b096 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -11,7 +11,7 @@ const opencode = yield * OpenCode.create() const session = yield * opencode.sessions.get({ sessionID }) ``` -It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. +It also exports `Tool` for plugins that register tools with `ctx.tool.register(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. `sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index c6324067cd..d4c3ad8fdc 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,7 +1,6 @@ import { OpenCode } from "@opencode-ai/client/effect" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { Context, Effect, Layer, Scope } from "effect" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" @@ -9,18 +8,18 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { const scope = yield* Scope.Scope const memoMap = yield* Layer.makeMemoMap + const sdkPlugins = SdkPlugins.makeStore() const context = yield* Layer.buildWithMemoMap( - Layer.mergeAll(ApplicationTools.layer, PermissionSaved.defaultLayer, SdkPlugins.layer), + Layer.mergeAll(PermissionSaved.defaultLayer, SdkPlugins.layerWithStore(sdkPlugins)), memoMap, scope, ) - const tools = Context.get(context, ApplicationTools.Service) const plugins = Context.get(context, SdkPlugins.Service) const permissions = Context.get(context, PermissionSaved.Service) const web = yield* Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( - createEmbeddedRoutes().pipe( + createEmbeddedRoutes(sdkPlugins).pipe( HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), Layer.provide(HttpServer.layerServices), ), @@ -38,7 +37,8 @@ export const create = Effect.fn("OpenCode.create")(function* () { ) return { ...client, - tools: { register: tools.register }, + sessions: client.session, + events: client.event, // The embedded host contributes plugins through the ordinary discovery flow: // each plugin's `effect` runs inside every Location with the real // `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index e2861c3a6d..8f19ce3abc 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -1,212 +1,206 @@ -import { expect, test } from "bun:test" -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" +import { expect } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import { Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect" +import { testEffect } from "../../core/test/lib/effect" +import { tmpdir } from "../../core/test/fixture/tmpdir" import type { OpenCodeEvent } from "../src" -test("embedded client uses the real router and handlers", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") - const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) - const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) +Flag.OPENCODE_DB = ":memory:" - try { - const program = Effect.gen(function* () { - const opencode = yield* OpenCode.create() - yield* opencode.tools.register({ - embedded_tool: Tool.make({ - description: "Embedded test tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) +const it = testEffect(Layer.empty) +type Sdk = typeof import("../src") +type Fixture = { readonly directory: string; readonly sdk: Sdk } - const created = yield* opencode.session.create({ - id: sessionID, - agent: Agent.ID.make("build"), - location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), - }) - yield* opencode.session.switchModel({ sessionID, model }) - const selected = yield* opencode.session.get({ sessionID }) - const page = yield* opencode.session.list({ directory: AbsolutePath.make(directory) }) - const active = yield* opencode.session.active() - const admitted = yield* opencode.session.prompt({ - sessionID, - prompt: Prompt.make({ text: "Do not run" }), - resume: false, - }) - const context = yield* opencode.session.context({ sessionID }) - const wake = yield* opencode.session.prompt({ - sessionID, - prompt: Prompt.make({ text: "Promote this input" }), - }) - const prompted = yield* opencode.session.events({ sessionID }).pipe( - Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), - Stream.runHead, - Effect.timeout("10 seconds"), - Effect.map(Option.getOrThrow), - ) - const wakeContext = yield* opencode.session.context({ sessionID }) - const event = yield* opencode.session - .events({ sessionID }) - .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) - const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( - Option.getOrThrow, - ) - const message = yield* opencode.session.message({ sessionID, messageID: modelMessage.id }) - yield* opencode.session.interrupt({ sessionID }) - const other = yield* opencode.session.create({ - location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), - }) - const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) - const missing = yield* Effect.all( - [ - opencode.session.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), - opencode.session.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), - opencode.session.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), - ], - { concurrency: "unbounded" }, - ) - const missingMessage = yield* Effect.flip( - opencode.session.message({ - sessionID: other.id, - messageID: modelMessage.id, - }), - ) +const withEmbedded = (prefix: string, f: (fixture: Fixture) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(prefix)), + (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((directory) => + Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))), + ), + ) - expect(created.id).toBe(sessionID) - expect(selected.model?.id).toBe(model.id) - expect(selected.model?.providerID).toBe(model.providerID) - expect(page.data.some((session) => session.id === sessionID)).toBe(true) - expect(active).toEqual({}) - expect(admitted.sessionID).toBe(sessionID) - expect(prompted.type).toBe("session.next.prompted") - expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) - expect(context.some((message) => message.type === "model-switched")).toBe(true) - expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) - expect(message).toEqual(modelMessage) - expect(missing.map((error) => error._tag)).toEqual([ - "SessionNotFoundError", - "SessionNotFoundError", - "SessionNotFoundError", - ]) - expect(missingMessage._tag).toBe("MessageNotFoundError") - }) - await Effect.runPromise(Effect.scoped(program)) - } finally { - Flag.OPENCODE_DB = database - await rm(directory, { recursive: true, force: true }) - } -}) +const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create() -test("Location-owned runner events reach the ready global client", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") - const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) +const location = (fixture: Fixture) => + fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) - try { - const program = Effect.gen(function* () { - const opencode = yield* OpenCode.create() - const connected = yield* Latch.make(false) - const prompted = yield* Deferred.make() - yield* opencode.event.subscribe().pipe( - Stream.runForEach((event) => - event.type === "server.connected" - ? connected.open - : event.type === "session.next.prompted" && event.data.sessionID === sessionID - ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) - : Effect.void, - ), - Effect.forkScoped, - ) - yield* connected.await - yield* opencode.session.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), - }) - yield* opencode.session.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) +it.live( + "embedded client uses the real router and handlers", + () => + withEmbedded("opencode-embedded-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const id = sessionID(fixture) + const model = fixture.sdk.Model.Ref.make({ + id: fixture.sdk.Model.ID.make("embedded"), + providerID: fixture.sdk.Provider.ID.make("test"), + }) - const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) - expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) - }) - await Effect.runPromise(Effect.scoped(program)) - } finally { - Flag.OPENCODE_DB = database - await rm(directory, { recursive: true, force: true }) - } -}, 10_000) + yield* opencode.plugin({ + id: `embedded-tools-${crypto.randomUUID()}`, + effect: (ctx) => + ctx.tool + .register({ + embedded_tool: fixture.sdk.Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + .pipe(Effect.orDie), + }) -test("independent embedded hosts do not share live notifications", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") - const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) - - try { - const program = Effect.gen(function* () { - const first = yield* OpenCode.create() - const second = yield* OpenCode.create() - const firstReady = yield* Latch.make(false) - const secondReady = yield* Latch.make(false) - const firstEvent = yield* Latch.make(false) - const secondEvent = yield* Latch.make(false) - const observe = (ready: Latch.Latch, event: Latch.Latch) => - Stream.runForEach((notification: OpenCodeEvent) => - notification.type === "server.connected" - ? ready.open - : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID - ? event.open - : Effect.void, + const created = yield* opencode.sessions.create({ + id, + agent: fixture.sdk.Agent.ID.make("build"), + location: location(fixture), + }) + yield* opencode.sessions.switchModel({ sessionID: id, model }) + const selected = yield* opencode.sessions.get({ sessionID: id }) + const page = yield* opencode.sessions.list({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) + const active = yield* opencode.sessions.active() + const admitted = yield* opencode.sessions.prompt({ + sessionID: id, + prompt: fixture.sdk.Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID: id }) + const wake = yield* opencode.sessions.prompt({ + sessionID: id, + prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), + }) + const prompted = yield* opencode.sessions.events({ sessionID: id }).pipe( + Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.runHead, + Effect.timeout("10 seconds"), + Effect.map(Option.getOrThrow), + ) + const wakeContext = yield* opencode.sessions.context({ sessionID: id }) + const event = yield* opencode.sessions + .events({ sessionID: id }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID: id, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID: id }) + const other = yield* opencode.sessions.create({ location: location(fixture) }) + const missingSessionID = fixture.sdk.Session.ID.create() + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), ) - yield* first.event.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) - yield* second.event.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) - yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) - yield* first.session.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), - }) - yield* first.session.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + expect(created.id).toBe(id) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === id)).toBe(true) + expect(active).toEqual({}) + expect(admitted.sessionID).toBe(id) + expect(prompted.type).toBe("session.next.prompted") + expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") + }), + ), + 10_000, +) - yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) - expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) - }) - await Effect.runPromise(Effect.scoped(program)) - } finally { - Flag.OPENCODE_DB = database - await rm(directory, { recursive: true, force: true }) - } -}, 10_000) - -test("embedded client is available as a Layer service", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Location, OpenCode, Session } = await import("../src") - const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) - - try { - const created = await Effect.runPromise( +it.live( + "Location-owned runner events reach the ready global client", + () => + withEmbedded("opencode-embedded-events-", (fixture) => Effect.gen(function* () { - const opencode = yield* OpenCode.Service - return yield* opencode.session.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), - }) - }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), - ) + const opencode = yield* fixture.sdk.OpenCode.create() + const id = sessionID(fixture) + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() - expect(created.id).toBe(sessionID) - } finally { - Flag.OPENCODE_DB = database - await rm(directory, { recursive: true, force: true }) - } -}) + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === id + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ id, location: location(fixture) }) + yield* opencode.sessions.prompt({ + sessionID: id, + prompt: fixture.sdk.Prompt.make({ text: "Observe this input" }), + }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: id, seq: expect.any(Number) })) + }), + ), + 10_000, +) + +it.live( + "independent embedded hosts do not share live notifications", + () => + withEmbedded("opencode-embedded-hosts-", (fixture) => + Effect.gen(function* () { + const first = yield* fixture.sdk.OpenCode.create() + const second = yield* fixture.sdk.OpenCode.create() + const id = sessionID(fixture) + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === id + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ id, location: location(fixture) }) + yield* first.sessions.switchAgent({ sessionID: id, agent: fixture.sdk.Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }), + ), + 10_000, +) + +it.live("embedded client is available as a Layer service", () => + withEmbedded("opencode-embedded-layer-", (fixture) => { + const id = sessionID(fixture) + return Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.Service + const created = yield* opencode.sessions.create({ id, location: location(fixture) }) + expect(created.id).toBe(id) + }).pipe(Effect.provide(fixture.sdk.OpenCode.layer)) + }), +) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4788320adc..dbc173c829 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5298,6 +5298,7 @@ export type McpServer = { | McpStatusFailed2 | McpStatusNeedsAuth2 | McpStatusNeedsClientRegistration2 + integrationID?: string } export type ProjectCurrent = { diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index c1511e3c62..8d7dcb8b3c 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -7,7 +7,7 @@ import { response } from "../location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => Effect.gen(function* () { - return yield* response(AgentV2.Service.use((agent) => agent.all())) + return yield* response(AgentV2.Service.use((agent) => agent.list())) }), ), ) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index f488ed718e..4d702f24c9 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -11,7 +11,13 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.fn(function* () { const service = yield* MCP.Service return yield* response( - service.servers().pipe(Effect.map((servers) => servers.map((info) => ({ name: info.name, status: info.status })))), + service + .servers() + .pipe( + Effect.map((servers) => + servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + ), + ), ) }), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 668ec1b53e..625c5567a8 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -10,8 +10,8 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" -import { SubagentTool } from "@opencode-ai/core/tool/subagent" -import { ShellTool } from "@opencode-ai/core/tool/shell" +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" @@ -31,8 +31,7 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, SessionV2.node, - SubagentTool.node, - ShellTool.node, + PluginRuntime.providerNode, PermissionSaved.node, PtyTicket.node, Credential.node, @@ -48,13 +47,22 @@ export function createRoutes(password?: string) { ) } -export function createEmbeddedRoutes() { - return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() })) +export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { + return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() }), sdkPlugins) } -function makeRoutes(auth: Layer.Layer) { +function makeRoutes( + auth: Layer.Layer, + sdkPlugins?: SdkPlugins.Store, +) { + const pluginRuntimeCell = PluginRuntime.makeCell() const serviceLayer = AppNodeBuilder.build( LayerNode.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node), + [ + LayerNode.replace(PluginRuntime.layer, PluginRuntime.layerWithCell(pluginRuntimeCell)), + LayerNode.replace(PluginRuntime.providerLayer, PluginRuntime.providerLayerWithCell(pluginRuntimeCell)), + ...(sdkPlugins ? [LayerNode.replace(SdkPlugins.layer, SdkPlugins.layerWithStore(sdkPlugins))] : []), + ], ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e5d2670a00..85fb86cd02 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1143,7 +1143,7 @@ export function Prompt(props: PromptProps) { }) } if (session?.revert) { - const error = await sdk.api.session.commit({ sessionID }).then( + const error = await sdk.api.session.revertCommit({ sessionID }).then( () => undefined, (error) => error, ) diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b1f376c740..240be47408 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -23,7 +23,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set description: "undo messages and file changes", onSelect: async (dialog) => { await sdk.api.session - .stage({ sessionID: props.sessionID, messageID: props.messageID }) + .revertStage({ sessionID: props.sessionID, messageID: props.messageID }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 92a24ffcbb..d611a31b63 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -185,7 +185,9 @@ export function Session() { }) const permissions = createMemo(() => { if (session()?.parentID) return [] - return [route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? []) + return [route.sessionID, ...descendantSessionIDs()].flatMap( + (sessionID) => data.session.permission.list(sessionID) ?? [], + ) }) const questions = createMemo(() => { if (session()?.parentID) return [] @@ -422,7 +424,7 @@ export function Session() { dialog.clear() return } - const error = await sdk.api.session.stage({ sessionID: route.sessionID, messageID: target }).then( + const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then( () => undefined, (error) => error, ) @@ -439,7 +441,7 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -1049,7 +1051,9 @@ function SessionMessageView(props: { message: SessionMessage }) { - + }> } /> @@ -1217,11 +1221,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text return "" } - return ( - - {text()} - - ) + return {text()} } function SessionSkillMessage(props: { message: Extract }) { @@ -1267,7 +1267,7 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) diff --git a/specs/v2/config.md b/specs/v2/config.md index 9254af2094..96b0f3ac90 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -13,6 +13,8 @@ This document breaks the legacy configuration schema into small review groups. W Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review. +V2 core discovers config documents named `opencode.json` or `opencode.jsonc` in the global config directory, ancestor project directories, and `.opencode` config directories. The legacy `config.json` filename is not supported in V2. + ## Group 1: File Metadata Small fields describing the config file itself rather than application behavior. diff --git a/specs/v2/session.md b/specs/v2/session.md index 000c0a8df3..b0931fdb2f 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -186,7 +186,7 @@ Event replay owner claims are separate from clustered Session execution ownershi ## Current Tool Registry Slice -`ApplicationTools` stores process-scoped application registrations shared by all Locations. Each Location-scoped `ToolRegistry` overlays Location registrations, materializes definitions, and owns lookup and settlement. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors. +Each Location-scoped `ToolRegistry` stores scoped tool registrations, materializes definitions, and owns lookup and settlement. Built-ins and plugins contribute through the same `Tools.Service.register(...)` path. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors. When a Session omits `agent`, both execution and permission evaluation use the default `build` agent. A caller must not observe `build` model behavior while permission checks silently evaluate an empty no-agent policy.