1
/**2
* Experimental bridge for Claude Code mods. A mod is a DSH plugin built with3
* {@link defineMod} around the mod's `register(on, options)`; this service4
* keeps the loaded mods and raises their events from harness extension5
* points — `session.start` on `agent/created`, `prompt.submit` and6
* `turn.start` on `agent/pre-step`, `tool.call` around `tools/execute`,7
* `turn.complete` on `turn/end`, `session.end` on `agent/disposed`, and8
* `command.run` from the commands a mod registers. The `$` a hook receives is9
* served by {@link createHostOps} over the composed harness services.10
* @module @deepseek-ai/dsh-experimental-claude-code-mods11
*/13
import type { Context } from '@deepseek-ai/cordis'14
import z from '@deepseek-ai/schemastery'15
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'16
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'17
import type { AssistantMessage, ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'18
import { SessionId } from '@deepseek-ai/dsh-session'19
import type { UserMessage } from '@deepseek-ai/dsh-session'20
import { scopeOf } from '@deepseek-ai/dsh-scope'21
import { validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'22
import type { PostToolDecision, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'23
import type { LoadedMod } from './chain.ts'24
import { RewriteRefusedError } from './chain.ts'25
import { ModsEngine } from './engine.ts'26
import { createHostOps, toolCallResultOf } from './host-ops.ts'27
import type { AgentBinding } from './host-ops.ts'28
import type { JsonValue } from '@deepseek-ai/dsh-util-values'29
import { SurfaceTable } from './surfaces.ts'30
import { createToolNameAliases } from './tool-names.ts'31
import { messageOf, record, stringify } from './values.ts'32
import type {33
ModDefinition, PromptSubmitInput, PromptSubmitResult, SessionEndInput, SessionEndResult, SessionStartInput,34
SessionStartResult, ToolCallInput, ToolCallResult, TurnCompleteInput, TurnCompleteResult, TurnStartInput,35
SurfaceSnapshot, TurnStartResult, TurnUsage, UiRenderInput, UiRenderResult,36
} from './types.ts'38
declare module '@deepseek-ai/cordis' {39
interface Context {40
claudeCodeMods: ClaudeCodeMods41
}42
}44
export type * from './types.ts'45
export type { BoxProps, ButtonProps, TextProps, UiElement, UiElements, UiNode } from './elements.ts'46
export { defineMod, type ModConfig, type ModPlugin, type ModSpec } from './define-mod.ts'47
export { DEFAULT_TOOL_ALIASES } from './tool-names.ts'48
export { KNOWN_EVENTS } from './matcher.ts'49
export { MODS_API_VERSION } from './host-ops.ts'51
/** Plugin config: the limits mod hooks run under. */52
export interface Config {53
/** A hook's own running-time limit in milliseconds (Claude Code: 10 seconds). */54
hookTimeoutMs?: number55
/** A `.catch` handler's running-time limit in milliseconds (Claude Code: 1 second). */56
catchTimeoutMs?: number57
/** Default `$.process.run` and `$.http.fetch` timeout in milliseconds (Claude Code: 30 seconds). */58
processTimeoutMs?: number59
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */60
toolAliases?: Record<string, string>61
/** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */62
bandColumns?: number63
/** Rows the band reports as `maxRows`. */64
bandRows?: number65
}67
/** Engine events this host raises; a hook on any other known event registers and is reported as unserved. */68
export const SERVED_EVENTS: ReadonlySet<string> = new Set([69
'session.start', 'session.end', 'prompt.submit', 'turn.start', 'turn.complete', 'tool.call', 'command.run', 'ui.render',70
])72
/** Where the deferred argument-rewrite mechanism is specified. */73
const REWRITE_NOTE = '.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md'75
function assertPositive(field: string, value: number): void {76
if (!Number.isFinite(value) || value <= 0) throw new Error(`claude-code-mods: ${field} must be a positive number of milliseconds`)77
}79
function textOf(blocks: readonly ContentBlock[]): string {80
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')81
}83
/** The human's own messages among a claim: the prompt a `prompt.submit` hook sees and may rewrite. */84
function promptMessages(claimed: readonly UserMessage[]): UserMessage[] {85
return claimed.filter(message => message.source.kind === 'user')86
}88
/**89
* Replace the prompt text across the human's messages with one rewritten90
* text: the first text block carries it, other text blocks go, every91
* non-text block stays in place. A prompt without any text block, such as an92
* image alone, gains the text after its blocks.93
*/94
function rewritePromptText(prompt: readonly UserMessage[], text: string): UserMessage[] {95
// Written inside the map callback, so the flag lives on an object the later read sees current.96
const placement = { placed: false }97
const rewritten = prompt.map((message) => {98
if (!message.content.some(block => block.type === 'text')) return message99
const content: ContentBlock[] = []100
for (const block of message.content) {101
if (block.type !== 'text') {102
content.push(block)103
} else if (!placement.placed) {104
placement.placed = true105
content.push({ type: 'text', text })106
}107
}108
return { ...message, content }109
})110
const first = rewritten[0]111
if (placement.placed || text.length === 0 || first === undefined) return rewritten112
return [{ ...first, content: [...first.content, { type: 'text', text }] }, ...rewritten.slice(1)]113
}115
/** Append the context a hook attached as blocks after the prompt as typed, on the last of the human's messages. */116
function appendContext(prompt: readonly UserMessage[], context: readonly string[]): UserMessage[] {117
const last = prompt.at(-1)118
/* v8 ignore next -- the caller only appends context to a prompt it raised, which has at least one human message */119
if (last === undefined) return [...prompt]120
const blocks: ContentBlock[] = context.map(line => ({ type: 'text', text: line }))121
return [...prompt.slice(0, -1), { ...last, content: [...last.content, ...blocks] }]122
}124
/** Keep only the fields of a `prompt.submit` result a mod may set, each with its declared type. */125
function acceptPromptSubmit(result: PromptSubmitResult, original: string): PromptSubmitResult {126
if (typeof result.drop === 'string') return { drop: result.drop }127
const context = Array.isArray(result.context) ? result.context.filter((line): line is string => typeof line === 'string') : []128
return { text: typeof result.text === 'string' ? result.text : original, ...context.length === 0 ? {} : { context } }129
}131
/** What the bridge folds from one session's event stream about its open turn. */132
interface TurnRecord {133
turn: number134
startedAt: number135
/** The last committed assistant text of the turn. */136
answer: string137
usage: TurnUsage | undefined138
}140
/** Fold one committed assistant message into the turn's answer and usage. */141
function foldAssistantMessage(turnRecord: TurnRecord, data: { message: AssistantMessage; usage?: TokenUsage }): void {142
turnRecord.answer = textOf(data.message.content)143
if (data.usage === undefined) return144
const { model } = data.message.source145
const usage = turnRecord.usage ?? { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, model }146
usage.input_tokens += data.usage.inputTokens147
usage.output_tokens += data.usage.outputTokens148
usage.cache_read_input_tokens += data.usage.cacheReadTokens ?? 0149
usage.cache_creation_input_tokens += data.usage.cacheWriteTokens ?? 0150
usage.model = model151
turnRecord.usage = usage152
}154
/** Detached mod runs the bridge tracks so disposal can await them; a rejection is reported, never unhandled. */155
class DetachedRuns {156
private readonly pending = new Set<Promise<unknown>>()157
readonly controller = new AbortController()159
constructor(private readonly report: (line: string) => void) {}161
track(label: string, run: Promise<unknown>): void {162
/* v8 ignore next -- the engine behaviors beneath these runs are total; the report guards a future rejecting one */163
const settled = run.then(() => undefined, (error: unknown) => { this.report(`${label} failed: ${messageOf(error)}`) })164
this.pending.add(settled)165
void settled.finally(() => { this.pending.delete(settled) })166
}168
/** Abort every run's signal; `drain` then waits for them to settle. */169
cancel(): void {170
this.controller.abort(new Error('claude-code-mods disposed'))171
}173
async drain(): Promise<void> {174
this.cancel()175
await Promise.allSettled([...this.pending])176
}177
}179
/** The lossless-JSON copy of a mod's answer, or undefined when it has none. */180
function jsonValueOf(value: unknown): JsonValue | undefined {181
const text = stringify(value)182
return text === undefined ? undefined : JSON.parse(text) as JsonValue183
}185
/** An error-shaped tool result carrying a mod's text in place of the tool's own. */186
function modAnswered(text: string, code: 'MOD_DENIED' | 'MOD_ANSWERED', rendered = text): ToolExecutionResult {187
return {188
isError: true,189
error: { message: text, info: { name: code === 'MOD_DENIED' ? 'ModDenied' : 'ModAnswered', code } },190
content: [{ type: 'text', text: rendered }],191
}192
}194
/**195
* The bridge service: loaded mods, their hooks, and the harness listeners196
* that raise their events. Mods join through {@link ClaudeCodeMods.add},197
* which {@link defineMod} calls when a mod plugin mounts. The Remote methods198
* let a Client draw each session's band above the prompt and press its buttons.199
*/200
export class ClaudeCodeMods extends TypertRemoteService {201
static Config: z<Config> = z.object({202
hookTimeoutMs: z.number().default(10_000),203
catchTimeoutMs: z.number().default(1_000),204
processTimeoutMs: z.number().default(30_000),205
toolAliases: z.dict(z.string()),206
bandColumns: z.number().default(120),207
bandRows: z.number().default(10),208
})210
// Every harness service is read through `ctx.get` when a mod's call needs it,211
// so a deployment composes only what its mods use.212
static inject: string[] = []214
private readonly engine: ModsEngine<AgentBinding>215
private readonly registrations = new Map<string, Set<() => void>>()216
private readonly surfaces: SurfaceTable218
constructor(ctx: Context, config: Config) {219
super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' })220
// The schema supplies every default; a direct construction passes the fields it needs.221
const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config222
assertPositive('hookTimeoutMs', hookTimeoutMs)223
assertPositive('catchTimeoutMs', catchTimeoutMs)224
assertPositive('processTimeoutMs', processTimeoutMs)225
assertPositive('bandColumns', bandColumns)226
assertPositive('bandRows', bandRows)227
const aliases = createToolNameAliases(config.toolAliases)228
const callOrigins = new Map<string, LoadedMod>()229
const modCommands = new Set<string>()230
const modTools = new Set<string>()231
const report = (line: string): void => { ctx.logger.warn(`claude-code-mods: ${line}`) }232
const agentOf = (sessionId: string): Agent | undefined => ctx.get('agents')?.get(SessionId(sessionId))233
const detached = new DetachedRuns(report)234
const surfaces = new SurfaceTable({235
columns: bandColumns,236
rows: bandRows,237
render: (sessionId, input) => {238
const agent = agentOf(sessionId)239
/* v8 ignore next -- agent/disposed forgets the band before the registry drops the agent; a redraw racing it draws nothing */240
if (agent === undefined) return Promise.resolve(null)241
return engine.raise<UiRenderInput, UiRenderResult>('ui.render', input, () => null, {242
binding: { agent }, signal: detached.controller.signal,243
})244
},245
runAction: async (_sessionId, callback) => {246
try {247
await callback()248
} catch (error: unknown) {249
report(`a button's onPress failed: ${messageOf(error)}`)250
}251
},252
report,253
})254
this.surfaces = surfaces255
// Deferred by one macrotask: a mod sets its own state right after the `$` call that triggers the redraw resolves.256
const redraw = (sessionId: string): void => {257
setTimeout(() => {258
if (agentOf(sessionId) !== undefined) void surfaces.refresh(sessionId)259
}, 0).unref()260
}261
const modSubmissions = new Map<string, string>()262
const ops = createHostOps({263
ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw,264
submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) },265
})266
const engine: ModsEngine<AgentBinding> = new ModsEngine<AgentBinding>({267
ops: op => ops[op],268
stateKey: binding => binding.agent?.session.id ?? '',269
budgetMs: hookTimeoutMs,270
catchBudgetMs: catchTimeoutMs,271
report,272
onStateRead: (key, slot) => { surfaces.stateRead(key, slot) },273
onStateWritten: (key, slot) => { surfaces.stateWritten(key, slot) },274
})275
this.engine = engine276
const registrations = this.registrations277
ctx.effect(() => async () => {278
// Cancel first: a timer callback waiting inside a `$` call ends on this signal, and engine.dispose awaits it.279
detached.cancel()280
surfaces.dispose()281
for (const owned of registrations.values()) for (const dispose of owned) dispose()282
registrations.clear()283
await engine.dispose()284
await detached.drain()285
}, 'claude-code-mods: unload mods')287
// Read at each use: the registry may mount after this plugin in a composition.288
const isRoot = (agent: Agent): boolean => {289
const agents = ctx.get('agents')290
return agents === undefined || agents.roots().includes(agent)291
}292
/** Root agents that received `session.start`; the registry no longer lists an agent once it is disposed. */293
const startedRoots = new Set<string>()294
const agentIdOf = (agent: Agent | undefined): { agentId: string } | Record<never, never> =>295
agent !== undefined && !isRoot(agent) ? { agentId: agent.id } : {}297
/** Per-session fold of the open turn, keyed by session id. */298
const turns = new Map<string, TurnRecord>()299
/** Turns whose first pre-step already raised `turn.start`, by session id. */300
const startedTurns = new Map<string, Set<number>>()301
/** Rewritten tool results the post-execute listener installs as content. */302
const replacements = new Map<ToolExecutionToken, string>()304
ctx.on('agent/created', async ({ agent, signal }) => {305
if (!isRoot(agent)) return306
startedRoots.add(agent.session.id)307
const input: SessionStartInput = {308
cwd: agent.session.header.cwd ?? process.cwd(),309
surface: null,310
isInteractive: ctx.get('userQuestions') !== undefined,311
}312
// Cancelling the agent's creation abandons a hook still waiting, such as one inside `$.ui.ask`.313
/* v8 ignore next -- the loop always supplies an initialization signal; the payload type keeps it optional */314
const abandon = signal === undefined ? detached.controller.signal : AbortSignal.any([signal, detached.controller.signal])315
await engine.raise<SessionStartInput, SessionStartResult>(316
'session.start', input, e => ({ cwd: e.cwd }), { binding: { agent }, signal: abandon },317
)318
redraw(agent.session.id)319
})321
ctx.on('agent/disposed', ({ agent }) => {322
const sessionId = agent.session.id323
turns.delete(sessionId)324
startedTurns.delete(sessionId)325
// The agent's scoped registrations unwound with its context; only the bookkeeping remains.326
registrations.delete(sessionId)327
surfaces.forget(sessionId)328
const forget = (): Promise<void> => engine.forgetSession(sessionId)329
if (!startedRoots.delete(sessionId)) {330
detached.track('session cleanup', forget())331
return332
}333
const input: SessionEndInput = { reason: 'other', sessionId }334
// `$.state` stays readable and the session's timers keep running until the mods' `session.end` hooks have settled.335
detached.track('session.end', engine.raise<SessionEndInput, SessionEndResult>(336
'session.end', input, e => ({ sessionId: e.sessionId }), { binding: { agent }, signal: detached.controller.signal },337
).then(forget, forget))338
})340
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {341
const binding: AgentBinding = { agent }342
const started = startedTurns.get(agent.session.id) ?? new Set<number>()343
startedTurns.set(agent.session.id, started)344
const first = !started.has(turn)345
started.add(turn)346
const prompt = promptMessages(messages)347
const text = textOf(prompt.flatMap(message => message.content))348
let submitted: PromptSubmitResult = { text }349
// A batch of injected context alone is not a prompt: Claude Code raises prompt.submit for typed prompts.350
if (prompt.length > 0) {351
const submitter = prompt.map(message => modSubmissions.get(message.id)).find(name => name !== undefined)352
for (const message of prompt) modSubmissions.delete(message.id)353
const input: PromptSubmitInput = {354
text,355
wait: false,356
origin: submitter === undefined ? { kind: 'composer' } : { kind: 'plugin', name: submitter },357
}358
submitted = acceptPromptSubmit(await engine.raise<PromptSubmitInput, PromptSubmitResult>(359
'prompt.submit', input, e => ({ text: e.text, ...e.context === undefined ? {} : { context: e.context } }), { binding, signal },360
), text)361
if (submitted.drop !== undefined) {362
ctx.logger.info(`claude-code-mods: prompt dropped: ${submitted.drop}`)363
return { kind: 'reject' }364
}365
}366
if (first) {367
const input: TurnStartInput = { text: submitted.text, turnId: String(turn), ...agentIdOf(agent) }368
await engine.raise<TurnStartInput, TurnStartResult>('turn.start', input, e => ({ turnId: e.turnId }), { binding, signal })369
}370
const downstream = await next()371
if (downstream.kind !== 'enter' || messages.length === 0) return downstream372
const context = submitted.context ?? []373
if (submitted.text === text && context.length === 0) return downstream374
// The rewritten text and the attached context replace the human's messages in place; every other message stays.375
let edited = submitted.text === text ? [...prompt] : rewritePromptText(prompt, submitted.text)376
if (context.length > 0) edited = appendContext(edited, context)377
const rewritten = new Map<UserMessage, UserMessage>()378
edited.forEach((message, index) => { rewritten.set(prompt[index] as UserMessage, message) })379
return { ...downstream, messages: downstream.messages.map(message => rewritten.get(message) ?? message) }380
})382
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {383
// A call a mod raised through `$.tool.call` reaches only the mods loaded before it, never itself.384
const raisedBy = callOrigins.get(exec.callId)385
const hooks = engine.registry.select('tool.call', raisedBy)386
if (hooks.length === 0) return next()387
const agent = exec.agent388
const callArguments = record(exec.arguments)389
const modName = aliases.toMod(exec.name)390
const input: ToolCallInput = { ...callArguments, tool: modName, tool_use_id: exec.callId, ...agentIdOf(agent) }391
const logged = JSON.stringify(callArguments)392
let beneath: ToolExecutionResult | undefined393
const answer = await engine.raiseWith<ToolCallInput, ToolCallResult>('tool.call', hooks, raisedBy, input, async () => {394
beneath = await next()395
return toolCallResultOf(beneath)396
}, {397
binding: { agent },398
signal: exec.signal,399
// The call is logged before policy runs, so the arguments a hook passes down must be the logged ones.400
validateNext: (e) => {401
const { tool, tool_use_id: _id, agentId: _agentId, ...rest } = e402
if (tool !== modName) {403
throw new RewriteRefusedError(`rerouted the call from ${modName} to ${tool}; the logged call runs the tool it named`)404
}405
if (JSON.stringify(rest) !== logged) {406
throw new RewriteRefusedError(`rewrote the arguments of ${modName}; argument rewrites need the pre-tool input rewrite mechanism (${REWRITE_NOTE})`)407
}408
},409
})410
if (agent !== undefined) redraw(agent.session.id)411
if (answer.deny !== undefined) return modAnswered(answer.deny, 'MOD_DENIED', `Error: ${answer.deny}`)412
const text = typeof answer.result === 'string' ? answer.result : stringify(answer.result) ?? ''413
if (beneath !== undefined) {414
const mapped = toolCallResultOf(beneath)415
if (mapped.result === answer.result && mapped.isError === answer.isError) return beneath416
// A success the hook marked as failed becomes a failure; a failure stays one, with the hook's text.417
if (answer.isError === true && !beneath.isError) return modAnswered(text, 'MOD_ANSWERED')418
replacements.set(exec.token, text)419
return beneath420
}421
if (answer.isError === true) return modAnswered(text, 'MOD_ANSWERED')422
if (modTools.has(exec.name)) return { isError: false, value: text, content: [{ type: 'text', text }] }423
// A built-in tool's success value must satisfy its own output schema: a424
// conforming answer is the tool's result, any other is error-shaped.425
const definition = ctx.get('tools')?.get(exec.name, agent === undefined ? undefined : scopeOf(agent.ctx))426
const value = jsonValueOf(answer.result)427
if (definition !== undefined && value !== undefined && validateJsonSchemaValue(definition.output.schema, value).length === 0) {428
return { isError: false, value, content: definition.output.render(exec.arguments, value) }429
}430
return modAnswered(text, 'MOD_ANSWERED')431
})433
// A call that never reaches post-execute (an earlier listener answered, or a pipeline failure) still clears its entry.434
ctx.on('tools/result', (exec) => { replacements.delete(exec.token) })436
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {437
const replacement = replacements.get(exec.token)438
if (replacement === undefined) return next()439
replacements.delete(exec.token)440
const downstream = await next()441
if (downstream.kind === 'block') return downstream442
return {443
kind: 'accept',444
content: [{ type: 'text', text: replacement }],445
...downstream.additionalContexts === undefined ? {} : { additionalContexts: downstream.additionalContexts },446
}447
})449
ctx.on('session/event', (session, event) => {450
if (event.type === 'turn/start') {451
turns.set(session.id, { turn: event.data.turn, startedAt: Date.now(), answer: '', usage: undefined })452
return453
}454
if (event.type === 'assistant/message') {455
const turnRecord = turns.get(session.id)456
if (turnRecord !== undefined) foldAssistantMessage(turnRecord, event.data)457
return458
}459
if (event.type !== 'turn/end') return460
const { turn, reason } = event.data461
startedTurns.get(session.id)?.delete(turn)462
const agent = ctx.get('agents')?.get(session.id)463
if (agent === undefined) return464
const turnRecord = turns.get(session.id)465
const folded = turnRecord?.turn === turn ? turnRecord : undefined466
const input: TurnCompleteInput = {467
turnId: String(turn),468
answer: folded?.answer ?? '',469
durationMs: folded === undefined ? 0 : Date.now() - folded.startedAt,470
isAborted: reason.kind === 'aborted',471
reason: reason.kind === 'aborted' ? 'aborted' : reason.kind === 'error' ? 'error' : 'answer',472
...agentIdOf(agent),473
...folded?.usage === undefined ? {} : { usage: { ...folded.usage } },474
}475
if (folded !== undefined) turns.delete(session.id)476
detached.track('turn.complete', engine.raise<TurnCompleteInput, TurnCompleteResult>(477
'turn.complete', input, () => ({ text: '' }), { binding: { agent }, signal: detached.controller.signal },478
).then((result) => {479
if (typeof result.text === 'string' && result.text.length > 0) ctx.logger.info(`claude-code-mods: ${result.text}`)480
redraw(session.id)481
}))482
})483
}485
/**486
* Watch the band above the prompt of one session: the current drawing, then487
* every redraw, until the Client stops watching.488
* @param agent - the session's agent, resolved by the Gateway.489
* @param signal - carrier cancellation.490
* @returns the band's snapshots.491
*/492
@Remote({ mode: 'stream' })493
watchBand(agent: Agent, signal: AbortSignal): AsyncIterable<SurfaceSnapshot> {494
return this.surfaces.watch(agent.session.id, signal)495
}497
/**498
* Press a button of the band's current drawing: runs the mod's `onPress` and redraws.499
* @param agent - the session's agent, resolved by the Gateway.500
* @param generation - the drawing the Client saw.501
* @param actionId - the button's action id in that drawing.502
* @returns the snapshot after the press.503
*/504
@Remote505
pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot> {506
return this.surfaces.press(agent.session.id, generation, actionId)507
}510
/**511
* Load one mod beneath every mod loaded before it: run its `register`,512
* keep its hooks, and report which of its events this host never raises.513
* @param definition - the mod as its plugin defined it.514
* @returns the disposer that removes the mod's hooks, closes its timers, and releases its registrations.515
* @throws Error when the name is invalid or taken, or when `register` throws (Claude Code's `hooks module did not load` wording).516
*/517
async add(definition: ModDefinition): Promise<() => Promise<void>> {518
const mod = await this.engine.add(definition)519
const unserved = this.engine.registry.unserved(mod, SERVED_EVENTS)520
this.ctx.logger.info(`claude-code-mods: hooks module ${mod.name}@inline loaded (tier user); events: ${this.engine.describe(mod) || '(none)'}`)521
if (unserved.length > 0) {522
this.ctx.logger.warn(`claude-code-mods: ${mod.name}: on(${unserved.map(event => JSON.stringify(event)).join(', ')}) registered, but this host never raises ${unserved.length === 1 ? 'that event' : 'those events'}`)523
}524
return async () => {525
await this.engine.unload(mod.name)526
}527
}529
/** The loaded mods in chain order. */530
get mods(): readonly LoadedMod[] {531
return this.engine.registry.list()532
}533
}535
export default ClaudeCodeMods