feat(ui): add plugin command registry

This commit is contained in:
Tianyi Cui
2026-07-19 22:11:59 +08:00
parent 25555c9cfc
commit 2850c22b7a
110 changed files with 2000 additions and 130 deletions

View File

@@ -0,0 +1,319 @@
/**
* Plugin-owned human-command registry shared by interactive UI adapters.
* @module @deepseek-ai/dsh-commands
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u
const DEFAULT_SURFACES = ['tui', 'acp'] as const
/** A UI adapter capable of listing and executing human commands. */
export type CommandSurface = 'tui' | 'acp' | (string & {})
/** Immutable command input metadata compatible with ACP unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
readonly hint: string
}
/** Invocation passed to one registered command handler. */
export interface CommandInvocation {
/** Exact agent whose human-facing surface received the command. */
readonly agent: Agent
/** UI adapter that dispatched the command. */
readonly surface: CommandSurface
/** Exact text following the registered command name, including separator whitespace. */
readonly rawInput: string
/** Cancellation signal owned by the dispatching UI request. */
readonly signal: AbortSignal
}
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| { readonly kind: 'error'; readonly text: string }
/** Plugin-owned command registration. */
export interface CommandDefinition {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/** Surfaces exposing this command; omission means both shipped surfaces. */
readonly surfaces?: readonly CommandSurface[]
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}
/** Handler-free immutable command view returned to UI adapters. */
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/** Surfaces on which this definition is visible. */
readonly surfaces: readonly CommandSurface[]
}
/** Syntactically valid slash command before registry resolution. */
export interface ParsedCommand {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Exact text following the command name. */
readonly rawInput: string
}
interface RegisteredCommand {
readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] }
readonly descriptor: CommandDescriptor
}
declare module 'cordis' {
interface Context {
commands: CommandService
}
interface Events {
/**
* A command was registered or unregistered. This is an unfiltered registry
* notification because a global or scoped change may affect any UI view.
* @mode emit
*/
'commands/change'(): void
}
}
/**
* Parse an exact slash command without normalizing its trailing input.
*
* @param line - Complete candidate command line.
* @returns The parsed command, or `undefined` when the line is not a command.
*/
export function parseCommand(line: string): ParsedCommand | undefined {
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
if (match === null) return undefined
const name = match[1]
/* v8 ignore next -- the first capture is required whenever the regular expression matches */
if (name === undefined) return undefined
return Object.freeze({ name, rawInput: line.slice(match[0].length) })
}
/** Convert arbitrary abort reasons to one stable rejected Error. */
function abortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
}
/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener('abort', onAbort)
reject(abortError(signal))
}
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error instanceof Error
? error
: new Error('command handler rejected with a non-Error value'))
},
)
})
}
/** Reject invalid command metadata before it can reach a UI protocol. */
function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
if (!COMMAND_NAME.test(definition.name)) {
throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
}
if (definition.description.trim().length === 0) {
throw new TypeError(`command "${definition.name}" description must not be empty`)
}
if (typeof definition.handler !== 'function') {
throw new TypeError(`command "${definition.name}" handler must be a function`)
}
const input = definition.input === undefined
? undefined
: Object.freeze({ hint: definition.input.hint })
if (input !== undefined && input.hint.trim().length === 0) {
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
}
const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
if (surfaces.length === 0) {
throw new TypeError(`command "${definition.name}" must expose at least one surface`)
}
const unique = new Set<CommandSurface>()
for (const surface of surfaces) {
if (!SURFACE_NAME.test(surface)) {
throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`)
}
if (unique.has(surface)) {
throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`)
}
unique.add(surface)
}
const frozenSurfaces = Object.freeze(surfaces)
const normalized = Object.freeze({
name: definition.name,
description: definition.description,
...input === undefined ? {} : { input },
surfaces: frozenSurfaces,
handler: definition.handler,
})
const descriptor = Object.freeze({
name: normalized.name,
description: normalized.description,
...normalized.input === undefined ? {} : { input: normalized.input },
surfaces: normalized.surfaces,
})
return { definition: normalized, descriptor }
}
/** Validate and detach an untrusted handler result at the registry boundary. */
function normalizeResult(command: string, value: unknown): CommandResult {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
throw new TypeError(`command "${command}" handler must return a CommandResult`)
}
const result = value as { kind?: unknown; text?: unknown }
if (result.kind === 'success') {
if (result.text !== undefined && typeof result.text !== 'string') {
throw new TypeError(`command "${command}" success text must be a string when supplied`)
}
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
}
if (result.kind === 'error') {
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
throw new TypeError(`command "${command}" error text must be a non-empty string`)
}
return Object.freeze({ kind: 'error', text: result.text })
}
throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
}
/**
* Human-command registry. Plain-context definitions are global; definitions
* registered through a command-injected child of an agent context shadow
* globals for that agent.
*/
export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>()
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
constructor(ctx: Context) {
super(ctx, 'commands')
}
/**
* Register a global or calling-agent-scoped command.
* @param definition - discovery metadata, surface mask, and direct UI handler.
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(registered.definition.name)) {
throw new Error(scope === undefined
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${registered.definition.name}" is already registered in this scope`)
}
layer.set(registered.definition.name, registered)
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('commands/change')
}
this.ctx.emit('commands/change')
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
}
/**
* List the effective immutable command descriptors for one agent and surface.
* @param agent - exact receiving agent and scoped-layer key.
* @param surface - UI adapter requesting discovery metadata.
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
*/
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] {
return Object.freeze([...this.view(agent).values()]
.filter(command => command.definition.surfaces.includes(surface))
.map(command => command.descriptor)
// Names are unique in the effective view, so equality is impossible.
.sort((left, right) => left.name < right.name ? -1 : 1))
}
/**
* Resolve one effective command definition.
* @param agent - exact receiving agent and scoped-layer key.
* @param surface - UI adapter performing the lookup.
* @param name - command name without a slash.
* @returns the scoped shadow or global definition when visible on the surface.
*/
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined {
const command = this.view(agent).get(name)
return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined
}
/**
* Parse and execute a known command without sending it to the model.
* @param agent - exact receiving agent.
* @param surface - dispatching UI adapter.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
*/
async execute(
agent: Agent,
surface: CommandSurface,
line: string,
signal: AbortSignal,
): Promise<CommandResult | undefined> {
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined
if (signal.aborted) throw abortError(signal)
const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal })
const output = command.definition.handler(invocation)
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
}
/** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global)
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
return visible
}
/** Create the registration layer for one agent scope on demand. */
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
}
export default CommandService