feat(core): publish command catalog updates

This commit is contained in:
Aiden Cline
2026-07-02 00:16:22 -05:00
parent 3e394cd697
commit d4e4c141e2
6 changed files with 38 additions and 15 deletions
+8
View File
@@ -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 }
+5 -1
View File
@@ -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<string, Types.DeepMutable<Info>>
@@ -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<Data, Draft>({
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] })
+11 -13
View File
@@ -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)
})
+8
View File
@@ -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<typeof Info> {}
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),
}
+2
View File
@@ -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,
+4 -1
View File
@@ -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
}
}