Files
deepseek-harness/packages/experimental/claude-code-mods/src/module.ts
T
Tianyi Cui f1715c67ec refactor(experimental): make mods DSH plugins and drop the persistence change
A mod is now a plugin built with defineMod() around its register(on,
options); the bridge is the ClaudeCodeMods service (ctx.claudeCodeMods)
that keeps the loaded mods and raises their events. The plugin.json /
hooks.json loader, pluginDirs, and dynamic imports are gone.

prompt.submit context becomes text blocks appended to the human's own
prompt message and $.prompt.submit queues a user-sourced message, so the
bridge adds no message source kind: the persistence record and the
regenerated schema are removed and the artifacts match master again.

Engine correctness from the review round: next() is memoized and every
started run beneath is awaited before a hook's answer settles; a rewritten
tool argument skips the hook with a report pointing at the pre-tool input
rewrite proposal; timers belong to the session whose event started them;
$.tool.call injects a tool's deferred contexts; $.clock.sleep honors the
event signal; a built-in tool answered with { result } is validated against
its output schema before it becomes an error-shaped result; hooks on events
this host never raises are reported at load.

Examples: the three mods from the Claude Code blog post under examples/,
Token Weather and its test unchanged, Blast Radius and Replay Theater
completed from the published fragments, run by their own tests through a
claude-code/testing alias over the new test kit ($.ui.mount/find/press).
2026-10-03 01:38:21 +08:00

162 lines
6.3 KiB
TypeScript

/**
* Mod registration: run a mod's `register(on, options)` and keep every
* `on(...)` registration in one ordered registry that dispatch selects from.
* @module
*/
import type { LoadedMod, RegisteredHook } from './chain.ts'
import { messageOf } from './values.ts'
import { describeMatcher, ENGINE_EVENTS, eventMatches, isEventPattern } from './matcher.ts'
import type { AnyHook, HookMatcher, ModDefinition, ModOn, HookRegistration } from './types.ts'
/** Plugin names Claude Code accepts: letters, digits, `_` and `-`. */
const PLUGIN_NAME = /^[A-Za-z0-9_-]{1,64}$/u
/** Mods ordered by load; hooks ordered by mod, then by registration. */
export class HookRegistry {
private readonly hooks: RegisteredHook[] = []
private readonly mods: LoadedMod[] = []
/**
* The loaded mods.
* @returns the mods in load order.
*/
list(): readonly LoadedMod[] {
return this.mods
}
/**
* Add one mod and its registrations; a mod of the same name must be removed first.
* @param mod - the mod the hooks belong to.
* @param hooks - its registrations in `on` order.
*/
add(mod: LoadedMod, hooks: readonly RegisteredHook[]): void {
if (this.mods.some(loaded => loaded.name === mod.name)) {
throw new Error(`hooks module ${mod.name} not loaded: another plugin of that name loads first`)
}
this.mods.push(mod)
this.hooks.push(...hooks)
}
/**
* Drop one mod and its hooks.
* @param name - the plugin name.
*/
remove(name: string): void {
const index = this.mods.findIndex(mod => mod.name === name)
if (index === -1) return
const [mod] = this.mods.splice(index, 1)
for (let i = this.hooks.length - 1; i >= 0; i -= 1) {
if (this.hooks[i]?.mod === mod) this.hooks.splice(i, 1)
}
}
/**
* Hooks whose pattern selects `event`, outermost first. A mods API call a mod
* raised is seen only by the mods loaded before it.
* @param event - the event name.
* @param raisedBy - the mod whose `$` call became the event, or undefined for the engine.
* @returns the ordered hooks; matchers are evaluated at run time.
*/
select(event: string, raisedBy?: LoadedMod): RegisteredHook[] {
return this.hooks
.filter(hook => (raisedBy === undefined || hook.mod.order < raisedBy.order) && eventMatches(hook.event, event))
.sort((left, right) => left.mod.order - right.mod.order)
}
/**
* The engine events one mod hooks by exact name that the host never raises.
* @param mod - the loaded mod.
* @param served - the engine events the host raises.
* @returns the unserved event names in registration order, each once.
*/
unserved(mod: LoadedMod, served: ReadonlySet<string>): string[] {
const names: string[] = []
for (const hook of this.hooks) {
if (hook.mod !== mod || !ENGINE_EVENTS.has(hook.event) || served.has(hook.event) || names.includes(hook.event)) continue
names.push(hook.event)
}
return names
}
/**
* Event names with matchers, as `claude plugin validate` prints the `hooks:` line.
* @param mod - the loaded mod.
* @returns its events with matchers, comma-separated.
*/
describe(mod: LoadedMod): string {
return this.hooks
.filter(hook => hook.mod === mod)
.map(hook => `${hook.event}${describeMatcher(hook.matcher)}`)
.join(', ')
}
}
function isMatcher(value: unknown): value is HookMatcher {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Build the `on` function one `register` call receives and collect its
* registrations. Validation follows Claude Code: the event name must be a
* known name or glob, and one event may be registered without a matcher only
* once.
* @param mod - the mod registering.
* @returns `on` and the list it appends to.
*/
export function createOn(mod: LoadedMod): { on: ModOn; hooks: RegisteredHook[] } {
const hooks: RegisteredHook[] = []
const unmatched = new Set<string>()
const on = ((event: unknown, matcherOrHook: unknown, maybeHook?: unknown): HookRegistration => {
if (typeof event !== 'string') throw new TypeError(`${mod.name}: the event name passed to on() is not a string literal`)
if (!isEventPattern(event)) throw new Error(`${mod.name}: "${event}" is not an event`)
const hook = maybeHook ?? matcherOrHook
const matcher = maybeHook === undefined ? undefined : matcherOrHook
if (typeof hook !== 'function') throw new TypeError(`${mod.name}: on("${event}") needs a hook function`)
if (matcher !== undefined && !isMatcher(matcher)) throw new TypeError(`${mod.name}: on("${event}") matcher must be an object`)
if (matcher === undefined) {
if (unmatched.has(event)) throw new Error(`${mod.name}: on("${event}") is registered twice without a matcher`)
unmatched.add(event)
}
const registered: RegisteredHook = {
mod, event, matcher, hook: hook as AnyHook, catchHandler: undefined, reported: new Set(),
}
hooks.push(registered)
return {
catch(handler: AnyHook): void {
if (typeof handler !== 'function') throw new TypeError(`${mod.name}: on("${event}").catch needs a handler function`)
registered.catchHandler = handler
},
}
}) as ModOn
return { on, hooks }
}
/**
* Run one mod's `register` and collect its hooks. A `register` that throws
* fails the mod with Claude Code's wording; the caller decides whether the
* session continues without it.
* @param definition - the mod as its plugin defined it.
* @param order - the mod's position in the chain; earlier mods run outside later ones.
* @returns the loaded mod and its registrations.
*/
export async function registerMod(definition: ModDefinition, order: number): Promise<{ mod: LoadedMod; hooks: RegisteredHook[] }> {
if (!PLUGIN_NAME.test(definition.name)) {
throw new Error(`mod "${definition.name}" not loaded: a plugin name uses letters, digits, _ and - only`)
}
const mod: LoadedMod = Object.freeze({
name: definition.name,
version: definition.version,
root: definition.root ?? process.cwd(),
options: Object.freeze({ ...definition.options }),
order,
})
const { on, hooks } = createOn(mod)
try {
await definition.register(on, mod.options)
} catch (error: unknown) {
throw new Error(`${definition.name}: hooks module did not load: register threw ${messageOf(error)}`, { cause: error })
}
return { mod, hooks }
}