/** * 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 { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import { CommandId } from './brand.ts' export { CommandId } from './brand.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u /** * Producer record for one command invocation (the `command/run` event's * provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s * shape; minimal today because every executor caller is a human-facing UI * surface dispatching a human-typed line, so the sole variant is `user`. */ export interface CommandSourceMap { user: { kind: 'user' } } /** The union over {@link CommandSourceMap} — who issued a command line. */ export type CommandSource = CommandSourceMap[keyof CommandSourceMap] /** Immutable metadata for a command's optional 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 /** 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 } /** * One settled command execution: the handler's normalized result plus the * lifecycle pairing id minted for its `command/run`/`command/done` records, * so a dispatching surface can correlate the RPC-level acknowledgment with * the flow node those events produce. */ export interface CommandExecution { /** Pairing id carried by this execution's lifecycle events. */ readonly commandId: CommandId /** The handler's normalized outcome. */ readonly result: CommandResult } /** 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 /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } /** 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 } /** 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 descriptor: CommandDescriptor } /** All command registrations owned by one global or scoped layer. */ class CommandLayer implements ScopeLayer { readonly commands: NamedEntries /** * Create one command layer with diagnostics specific to its ownership scope. * @param scope - the scoped owner, or `undefined` for global registrations. */ constructor(scope: ScopeKey | undefined) { this.commands = new NamedEntries(name => new Error(scope === undefined ? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` : `command "${name}" is already registered in this scope`)) } /** @returns whether this layer owns no command registrations. */ isEmpty(): boolean { return this.commands.isEmpty() } } declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** * A resolved slash command entered its handler. Log-only (never model * surface); paired with `command/done` by `commandId`, mirroring the * `tool/call`↔`tool/result` pairing. The payload is structured — `name` * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses * a line. */ 'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the * rendered failure); presentation stays client-computed at render time. */ 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } } } 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. * Observer failures are contained and cannot veto the registry mutation. * @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') } /** Render arbitrary thrown values without trusting their string coercion. */ function renderThrown(value: unknown): string { try { return String(value) } catch { return '' } } /** Stop awaiting an uncooperative handler once its owning UI request aborts. */ function withAbort(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(abortError(signal)) return new Promise((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: ${renderThrown(error)}`, { cause: error })) }, ) }) } /** 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 (typeof definition.description !== 'string') { throw new TypeError(`command "${definition.name}" description must be a string`) } 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 rawInput: unknown = definition.input let input: CommandInputDescriptor | undefined if (rawInput !== undefined) { if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput) || typeof rawInput.hint !== 'string') { throw new TypeError(`command "${definition.name}" input hint must be a string`) } if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } input = Object.freeze({ hint: rawInput.hint }) } const normalized = Object.freeze({ name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, handler: definition.handler, }) const descriptor = Object.freeze({ name: normalized.name, description: normalized.description, ...normalized.input === undefined ? {} : { input: normalized.input }, }) 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 layers = new ScopedLayers( scope => new CommandLayer(scope), () => { this.notifyChange() }, ) /** Monotonic per-instance counter behind {@link mintCommandId}. */ private commandSeq = 0 /** Instance token keeping minted ids unique across process restarts over one resumed log. */ private readonly instanceToken = crypto.randomUUID().slice(0, 8) constructor(ctx: Context) { super(ctx, 'commands') } /** * Register a global or calling-agent-scoped command. * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void { const registered = normalizeDefinition(definition) return this.layers.effect( this.ctx, layer => layer.commands.insert(registered.definition.name, registered), { label: 'commands.register()' }, ) } /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. * @returns name-sorted descriptors after scoped shadowing. */ list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] .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 name - command name without a slash. * @returns the scoped shadow or global definition. */ find(agent: Agent, name: string): CommandDefinition | undefined { return this.view(agent).get(name)?.definition } /** * Parse and execute a known command without sending it to the model. * * A resolved command's lifecycle is logged: `command/run` is appended * before the handler is invoked and `command/done` after settlement (a * thrown or aborted handler settles as `kind: 'error'`). Both are direct * log-only appends — no turn wraps them, and persistence drains them at * ordinary checkpoints. Admission misses (syntax or unknown name) log * nothing — they never entered a handler. A `command/run` append failure * fails the execution loud; a `command/done` append failure on the * handler-failure path is contained so the handler's own error stays the * reported failure. * * @param agent - exact receiving agent. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise { const parsed = parseCommand(line) if (parsed === undefined) return undefined const command = this.view(agent).get(parsed.name) if (command === undefined) return undefined if (signal.aborted) throw abortError(signal) const commandId = this.mintCommandId() this.appendLifecycle(agent.session, 'command/run', { commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' }, }) const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) let result: CommandResult try { const output = command.definition.handler(invocation) result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) } catch (error: unknown) { try { this.appendLifecycle(agent.session, 'command/done', { commandId, kind: 'error', text: error instanceof Error ? error.message : renderThrown(error), }) } catch (appendError: unknown) { this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`) } throw error } this.appendLifecycle(agent.session, 'command/done', { commandId, kind: result.kind, ...result.text === undefined ? {} : { text: result.text }, }) return Object.freeze({ commandId, result }) } /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */ private mintCommandId(): CommandId { this.commandSeq += 1 return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`) } /** * Append one log-only lifecycle event directly: no turn is opened for it and * no flush is forced — persistence observes the eager `session/event` path * and drains at ordinary checkpoints and teardown, like every other * standalone plugin event. */ private appendLifecycle( session: Session, type: T, data: SessionEventMap[T], ): SessionEvent { // Both admitted types are log-only (non-surface), but TypeScript does not // reduce Session.append's conditional rest parameter through a generic // type parameter. Preserve the proven two-argument call shape. const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent return appendLogOnly(type, data) } /** Resolve global definitions followed by exact scoped shadows. */ private view(agent: Agent): Map { return this.layers.merge(agent, layer => layer.commands) } /** Notify every registry observer without making UI refresh load-bearing. */ private notifyChange(): void { // Cordis emit uses Array.map: one synchronous throw starves later listeners, // and returned promises are discarded. Registry notifications are // non-vetoing, so contain each callback independently. for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) { try { const returned: unknown = callback() void Promise.resolve(returned).catch((error: unknown) => { this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`) }) } catch (error: unknown) { this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`) } } } } export default CommandService