Files
deepseek-harness/packages/experimental/claude-code-mods/src/api.ts
T
Tianyi Cui 35873604ad feat(experimental): add Claude Code mods bridge
Add @deepseek-ai/dsh-experimental-claude-code-mods, an alpha bridge that
loads Claude Code mods (plugin directories whose hooks/hooks.json names a
hooks module), runs register(on, options) unchanged, and raises the mod
events from harness extension points: session.start on agent/created,
prompt.submit and turn.start on agent/pre-step, tool.call around
tools/execute, turn.complete on turn/end, session.end on agent/disposed,
and command.run from registered commands. The $ mods API is served over
composed services (commands, tools, user-questions, fs, subprocess,
storage domain, projections); unserved members fail by name.

Includes a middleware chain with Claude Code's failure rules and budget
clock, a claude-code/testing-style test kit, fixtures from the mods
tutorial, full-loop and Loader composition tests, the attribution-only
claude-code-mods message source with its persistence record, and the
regenerated catalogs.
2026-10-02 11:10:41 +08:00

175 lines
7.2 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 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)}`)
})
}
return Object.freeze({
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(): never {
throw new Error(`${mod.name}: $.ui.resolve has no element table: this bridge raises no ui.render events`)
},
}),
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 } }),
}),
})
}