Files
deepseek-harness/packages/experimental/claude-code-mods/src/api.ts
T
Tianyi Cui 233032b8f4 fix(experimental): close the mods bridge review findings
- Disposal cancels detached runs before the engine waits for timer callbacks,
  so a callback parked in a $ call ends instead of blocking unload.
- A forgotten band wakes and ends its watch streams; a refresh asked for
  during a pass joins one more pass that settles after it; the Web band drops
  an ended stream so the next subscriber opens a fresh one.
- $ has Claude Code's namespaces: an unserved member rejects with
  'no implementation for <namespace>.<member>' instead of a TypeError.
- A .catch handler's next makes the rewrite check; a tool rename is refused
  like an argument rewrite; a null op answer is reported as no answer;
  defineMod returns its disposer from apply; the live turn record is copied
  before the chain freezes it; prompt.submit is raised only for batches with
  a human message and names the submitting mod as origin.
- $.http.fetch bounds the body while streaming; bandColumns/bandRows are
  Config; the client package drops its stale gateway edges; the source
  overlay lives in the package and anchors its entries to the file, and the
  dsh installation no longer depends on the experimental packages.
2026-10-03 05:18:32 +08:00

220 lines
9.3 KiB
TypeScript

/**
* 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<unknown>
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<T>(op: string, input: unknown): Promise<T> {
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<PaneOpenResult> {
return call('ui.open', pane)
},
close(pane: { id: string }): Promise<void> {
return call('ui.close', { id: pane.id, origin: 'plugin' })
},
panes(): Promise<never[]> {
return call('ui.panes', {})
},
ask(question: string, options?: readonly string[] | AskOptions): Promise<string> {
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<void> {
return call('command.register', command)
},
run(input: { command: string; args?: string }): Promise<CommandRunResult> {
return call('command.run', { command: input.command, args: input.args ?? '' })
},
list(): Promise<CommandInfo[]> {
return call('command.list', {})
},
}),
tool: Object.freeze({
register(tool: ToolSpec): Promise<void> {
return call('tool.register', tool)
},
call(input: { tool: string; [argument: string]: unknown }): Promise<ToolCallResult> {
return call('tool.call', input)
},
list(): Promise<ToolInfo[]> {
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<string> => call('session.id', {}),
cwd: (): Promise<string> => call('session.cwd', {}),
root: (): Promise<string> => call('session.root', {}),
model: (): Promise<string> => call('session.model', {}),
turns: (): Promise<number> => call('session.turns', {}),
messages: (): Promise<SessionMessage[]> => call('session.messages', {}),
usage: (): Promise<SessionUsage> => call('session.usage', {}),
version: (): Promise<SessionVersion> => 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<void> {
return call('state.set', { plugin: ref.plugin, key: ref.key, value })
},
}),
store: Object.freeze({
get: (key: string): Promise<unknown> => call('store.get', { key }),
set: (key: string, value: unknown): Promise<void> => call('store.set', { key, value }),
delete: (key: string): Promise<void> => call('store.delete', { key }),
keys: (): Promise<string[]> => call('store.keys', {}),
}),
clock: Object.freeze({
now: (): Promise<number> => call('clock.now', {}),
// Counted as the hook's own time, so the clock is not paused.
sleep: (ms: number): Promise<void> => binding.invoke('clock.sleep', { ms }) as Promise<void>,
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<string> => call('fs.read', { path, as: 'text' }),
write: (path: string, text: string): Promise<void> => call('fs.write', { path, text }),
list: (path?: string): Promise<FsEntry[]> => call('fs.list', { path: path ?? '.' }),
exists: (path: string): Promise<boolean> => call('fs.exists', { path }),
stat: (path: string): Promise<FsStat> => call('fs.stat', { path, resolve: true }),
}),
process: Object.freeze({
run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult> {
return call('process.run', { argv, ...init === undefined ? {} : { init } })
},
}),
http: Object.freeze({
fetch(url: string, init?: HttpInit): Promise<HttpResponse> {
return call('http.fetch', { url, ...init === undefined ? {} : { init } })
},
}),
env: Object.freeze({
get: (name: string): Promise<string | undefined> => call('env.get', { name }),
set: (name: string, value: string | undefined): Promise<void> => 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<string> = 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 <namespace>.<member>`, 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<string, object>()
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,
})
}