feat(core): publish command catalog updates
This commit is contained in:
@@ -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,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] })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user