From d4e4c141e298759e07d50457b96ff3f6ed6e6207 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 00:16:22 -0500 Subject: [PATCH] feat(core): publish command catalog updates --- packages/client/src/generated/types.ts | 8 ++++++++ packages/core/src/command.ts | 6 +++++- packages/core/src/mcp/index.ts | 24 +++++++++++------------- packages/schema/src/command.ts | 8 ++++++++ packages/schema/src/event-manifest.ts | 2 ++ packages/tui/src/context/data.tsx | 5 ++++- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index f7486b42eb..2d3e536f15 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -4230,6 +4230,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "command.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 2c0f8f1c16..84666688fd 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -5,9 +5,11 @@ import { Context, Effect, Layer, Schema, Types } from "effect" import { Command } from "@opencode-ai/schema/command" import { State } from "./state" import { MCP } from "./mcp/index" +import { EventV2 } from "./event" export const Info = Command.Info export type Info = Command.Info +export const Event = Command.Event export type Data = { commands: Map> @@ -44,6 +46,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const mcp = yield* MCP.Service + const events = yield* EventV2.Service const state = State.create({ initial: () => ({ commands: new Map() }), draft: (draft) => ({ @@ -59,6 +62,7 @@ const layer = Layer.effect( draft.commands.delete(name) }, }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined const mcpCommands = Effect.fnUntraced(function* () { @@ -167,4 +171,4 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g -export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node, EventV2.node] }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index b89a3fc4c0..6992942674 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -2,6 +2,7 @@ export * as MCP from "./index" import { Mcp } from "@opencode-ai/schema/mcp" import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { Command } from "@opencode-ai/schema/command" import { createHash } from "node:crypto" import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" @@ -337,7 +338,10 @@ export const layer = Layer.effect( Effect.map((defs) => { entry.prompts = defs.map((def) => toPrompt(name, def)) }), - Effect.catch(() => Effect.sync(() => (entry.prompts = []))), + Effect.andThen(events.publish(Command.Event.Updated, {})), + Effect.catch(() => + Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))), + ), ) const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { @@ -350,6 +354,7 @@ export const layer = Layer.effect( entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) @@ -392,31 +397,23 @@ export const layer = Layer.effect( // List tools as part of connect so a failure here marks the server failed rather than // leaving it connected with a silently empty tool list and no path to recover. const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe( - Effect.flatMap((connection) => - connection.tools().pipe( - Effect.flatMap((tools) => - connection.prompts().pipe( - Effect.catch(() => Effect.succeed([] as MCPClient.PromptDefinition[])), - Effect.map((prompts) => ({ connection, prompts, tools })), - ), - ), - ), - ), + Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Scope.provide(scope), Effect.exit, ) if (Exit.isSuccess(result)) { entry.client = result.value.connection entry.tools = result.value.tools.map((def) => toTool(name, def)) - entry.prompts = result.value.prompts.map((def) => toPrompt(name, def)) + entry.prompts = [] entry.status = { status: "connected" } watch(name, entry, result.value.connection) - yield* Effect.logInfo("mcp connected", { server: name, prompts: entry.prompts.length, tools: entry.tools.length }) + yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) // Announce the new tool set so the tool registry registers it. A server that finishes connecting // after the initial registration sweep and emits no list-changed notification would otherwise // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return } yield* Scope.close(scope, Exit.void) @@ -455,6 +452,7 @@ export const layer = Layer.effect( entry.client = undefined entry.tools = undefined entry.prompts = undefined + yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore) } yield* startServer(name, entry) }) diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 35f2a66e18..fb88087dbd 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,9 +1,12 @@ export * as Command from "./command.js" import { Schema } from "effect" +import { define, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +const Updated = define({ type: "command.updated", schema: {} }) + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, @@ -13,3 +16,8 @@ export const Info = Schema.Struct({ model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), }).annotate({ identifier: "CommandV2.Info" }) + +export const Event = { + Updated, + Definitions: inventory(Updated), +} diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 5e8a2871f3..0179547ca0 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -2,6 +2,7 @@ export * as EventManifest from "./event-manifest.js" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" +import { Command } from "./command.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" @@ -53,6 +54,7 @@ const featureDefinitions = Event.inventory( ...Permission.Event.Definitions, ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, + ...Command.Event.Definitions, ...Skill.Event.Definitions, ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9956fa4a96..a96f4319ef 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -208,6 +208,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "agent.updated": void result.location.agent.refresh(event.location) break + case "command.updated": + void result.location.command.refresh(event.location) + break case "skill.updated": void result.location.skill.refresh(event.location) break @@ -618,7 +621,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": if (bootstrapping) break - void Promise.all([result.location.mcp.refresh(event.location), result.location.command.refresh(event.location)]) + void result.location.mcp.refresh(event.location) break } }