/** * The `$` a hook receives. Each method shapes its arguments into the mods API * event of the same name and hands it to the binding's `invoke`, which runs * the mods loaded before this one and then the engine's implementation. The * hook's running-time clock pauses while a call is awaited, except for * `$.clock.sleep`, which Claude Code counts as the hook's own time. * @module */ import { uiElements } from './elements.ts' import type { BudgetClock, LoadedMod } from './chain.ts' import { messageOf } from './values.ts' import type { AskOptions, CommandInfo, CommandRunResult, CommandSpec, FsEntry, FsStat, HttpInit, HttpResponse, ModsApi, PaneOpenArgs, PaneOpenResult, ProcessRunInit, ProcessRunResult, PromptSubmitArgs, SessionMessage, SessionUsage, SessionVersion, StateRef, ModTimer, ToastOptions, ToolCallResult, ToolInfo, ToolSpec, UiLogOptions, } from './types.ts' /** Timers the engine owns on a mod's behalf, so disposal and reload can cancel them. */ export interface TimerHost { after(ms: number, fn: () => unknown): ModTimer every(ms: number, fn: () => unknown): ModTimer } /** What one `$` instance is bound to. */ export interface ApiBinding { readonly mod: LoadedMod /** The running hook's clock, paused while mods API calls are awaited; absent outside a hook. */ readonly clock: BudgetClock | undefined /** * Raise one mods API call as its event and resolve to the value the chain * answered with; a `{ deny }` answer rejects with the reason. */ readonly invoke: (op: string, input: unknown) => Promise readonly timers: TimerHost /** Receives a rejected fire-and-forget call's reason. */ readonly report: (line: string) => void } /** * Build the `$` for one hook invocation. * @param binding - the mod, clock, and engine entry points this instance uses. * @returns the mods API. */ export function createModsApi(binding: ApiBinding): ModsApi { const { mod, clock, timers } = binding async function call(op: string, input: unknown): Promise { clock?.pause() try { return await binding.invoke(op, input) as T } finally { clock?.resume() } } function fire(op: string, input: unknown): void { call(op, input).catch((error: unknown) => { binding.report(`${mod.name}: $.${op} failed: ${messageOf(error)}`) }) } // Not frozen: the proxy below returns a wrapped namespace, which a frozen target's invariant would forbid. const served: ModsApi = { plugin: Object.freeze({ name: mod.name, root: mod.root }), ui: Object.freeze({ log(text: string, options?: UiLogOptions): void { fire('ui.log', { text, to: options?.to ?? 'transcript' }) }, toast(text: string, options?: ToastOptions): void { fire('ui.toast', { text, ...options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs } }) }, status(text: string | undefined): void { fire('ui.status', { text }) }, invalidate(event: string): void { fire('ui.invalidate', { event }) }, open(pane: PaneOpenArgs): Promise { return call('ui.open', pane) }, close(pane: { id: string }): Promise { return call('ui.close', { id: pane.id, origin: 'plugin' }) }, panes(): Promise { return call('ui.panes', {}) }, ask(question: string, options?: readonly string[] | AskOptions): Promise { const normalized: AskOptions = Array.isArray(options) ? { options } : options as AskOptions | undefined ?? {} return call('ui.ask', { question, ...normalized }) }, resolve: () => uiElements(), }), command: Object.freeze({ register(command: CommandSpec): Promise { return call('command.register', command) }, run(input: { command: string; args?: string }): Promise { return call('command.run', { command: input.command, args: input.args ?? '' }) }, list(): Promise { return call('command.list', {}) }, }), tool: Object.freeze({ register(tool: ToolSpec): Promise { return call('tool.register', tool) }, call(input: { tool: string; [argument: string]: unknown }): Promise { return call('tool.call', input) }, list(): Promise { return call('tool.list', {}) }, }), prompt: Object.freeze({ submit(input: PromptSubmitArgs): Promise<{ text: string }> { return call('prompt.submit', { text: input.text, asUser: input.asUser === true }) }, }), session: Object.freeze({ id: (): Promise => call('session.id', {}), cwd: (): Promise => call('session.cwd', {}), root: (): Promise => call('session.root', {}), model: (): Promise => call('session.model', {}), turns: (): Promise => call('session.turns', {}), messages: (): Promise => call('session.messages', {}), usage: (): Promise => call('session.usage', {}), version: (): Promise => call('session.version', {}), }), state: Object.freeze({ get(ref: StateRef): Promise<{ value: unknown }> { return call('state.get', { plugin: ref.plugin, key: ref.key }) }, set(ref: StateRef, value: unknown): Promise { return call('state.set', { plugin: ref.plugin, key: ref.key, value }) }, }), store: Object.freeze({ get: (key: string): Promise => call('store.get', { key }), set: (key: string, value: unknown): Promise => call('store.set', { key, value }), delete: (key: string): Promise => call('store.delete', { key }), keys: (): Promise => call('store.keys', {}), }), clock: Object.freeze({ now: (): Promise => call('clock.now', {}), // Counted as the hook's own time, so the clock is not paused. sleep: (ms: number): Promise => binding.invoke('clock.sleep', { ms }) as Promise, after: (ms: number, fn: () => unknown): ModTimer => timers.after(ms, fn), every: (ms: number, fn: () => unknown): ModTimer => timers.every(ms, fn), }), fs: Object.freeze({ read: (path: string): Promise => call('fs.read', { path, as: 'text' }), write: (path: string, text: string): Promise => call('fs.write', { path, text }), list: (path?: string): Promise => call('fs.list', { path: path ?? '.' }), exists: (path: string): Promise => call('fs.exists', { path }), stat: (path: string): Promise => call('fs.stat', { path, resolve: true }), }), process: Object.freeze({ run(argv: readonly string[], init?: ProcessRunInit): Promise { return call('process.run', { argv, ...init === undefined ? {} : { init } }) }, }), http: Object.freeze({ fetch(url: string, init?: HttpInit): Promise { return call('http.fetch', { url, ...init === undefined ? {} : { init } }) }, }), env: Object.freeze({ get: (name: string): Promise => call('env.get', { name }), set: (name: string, value: string | undefined): Promise => call('env.set', { name, ...value === undefined ? {} : { value } }), }), } return withUnservedMembers(served, mod.name) } /** The method namespaces Claude Code's `$` has; `plugin` holds facts, not methods, and stays as served. */ const METHOD_NAMESPACES: ReadonlySet = new Set([ 'ui', 'command', 'tool', 'prompt', 'session', 'state', 'store', 'clock', 'fs', 'process', 'http', 'env', 'model', 'agent', 'config', 'settings', 'mcp', 'audio', 'telemetry', 'turn', ]) /** * Give `$` Claude Code's full namespace set: a served member is itself; any * other member of a method namespace is a function whose call rejects with * `no implementation for .`, so a mod that reaches past * this host fails with the gap named, not with a TypeError. * @param served - the frozen object of served namespaces. * @param modName - the mod the `$` belongs to, for the rejection text. * @returns the `$` a hook receives. */ function withUnservedMembers(served: ModsApi, modName: string): ModsApi { const namespaces = new Map() const namespaceOf = (name: string, base: object): object => { let wrapped = namespaces.get(name) if (wrapped === undefined) { wrapped = new Proxy(base, { get(target, member, receiver) { const own: unknown = Reflect.get(target, member, receiver) if (own !== undefined || typeof member !== 'string' || member === 'then') return own return () => Promise.reject(new Error(`${modName}: no implementation for ${name}.${member}`)) }, }) namespaces.set(name, wrapped) } return wrapped } return new Proxy(served, { get(target, namespace, receiver) { const own: unknown = Reflect.get(target, namespace, receiver) if (typeof namespace !== 'string' || !METHOD_NAMESPACES.has(namespace)) return own return namespaceOf(namespace, typeof own === 'object' && own !== null ? own : Object.freeze({})) }, // `$` is read-only, as the frozen object was. set: () => false, defineProperty: () => false, deleteProperty: () => false, }) }