返回源码地图

packages/experimental/claude-code-mods/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 12 / 12;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Experimental bridge for Claude Code mods. A mod is a DSH plugin built with
3 * {@link defineMod} around the mod's `register(on, options)`; this service
4 * keeps the loaded mods and raises their events from harness extension
5 * points — `session.start` on `agent/created`, `prompt.submit` and
6 * `turn.start` on `agent/pre-step`, `tool.call` around `tools/execute`,
7 * `turn.complete` on `turn/end`, `session.end` on `agent/disposed`, and
8 * `command.run` from the commands a mod registers. The `$` a hook receives is
9 * served by {@link createHostOps} over the composed harness services.
10 * @module @deepseek-ai/dsh-experimental-claude-code-mods
11 */
12
13import type { Context } from '@deepseek-ai/cordis'
14import z from '@deepseek-ai/schemastery'
15import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
16import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
17import type { AssistantMessage, ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
18import { SessionId } from '@deepseek-ai/dsh-session'
19import type { UserMessage } from '@deepseek-ai/dsh-session'
20import { scopeOf } from '@deepseek-ai/dsh-scope'
21import { validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
22import type { PostToolDecision, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
23import type { LoadedMod } from './chain.ts'
24import { RewriteRefusedError } from './chain.ts'
25import { ModsEngine } from './engine.ts'
26import { createHostOps, toolCallResultOf } from './host-ops.ts'
27import type { AgentBinding } from './host-ops.ts'
28import type { JsonValue } from '@deepseek-ai/dsh-util-values'
29import { SurfaceTable } from './surfaces.ts'
30import { createToolNameAliases } from './tool-names.ts'
31import { messageOf, record, stringify } from './values.ts'
32import 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'
37
38declare module '@deepseek-ai/cordis' {
39 interface Context {
40 claudeCodeMods: ClaudeCodeMods
41 }
42}
43
44export type * from './types.ts'
45export type { BoxProps, ButtonProps, TextProps, UiElement, UiElements, UiNode } from './elements.ts'
46export { defineMod, type ModConfig, type ModPlugin, type ModSpec } from './define-mod.ts'
47export { DEFAULT_TOOL_ALIASES } from './tool-names.ts'
48export { KNOWN_EVENTS } from './matcher.ts'
49export { MODS_API_VERSION } from './host-ops.ts'
50
51/** Plugin config: the limits mod hooks run under. */
52export interface Config {
53 /** A hook's own running-time limit in milliseconds (Claude Code: 10 seconds). */
54 hookTimeoutMs?: number
55 /** A `.catch` handler's running-time limit in milliseconds (Claude Code: 1 second). */
56 catchTimeoutMs?: number
57 /** Default `$.process.run` and `$.http.fetch` timeout in milliseconds (Claude Code: 30 seconds). */
58 processTimeoutMs?: number
59 /** 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?: number
63 /** Rows the band reports as `maxRows`. */
64 bandRows?: number
65}
66
67/** Engine events this host raises; a hook on any other known event registers and is reported as unserved. */
68export 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])
71
72/** Where the deferred argument-rewrite mechanism is specified. */
73const REWRITE_NOTE = '.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md'
74
75function 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}
78
79function 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}
82
83/** The human's own messages among a claim: the prompt a `prompt.submit` hook sees and may rewrite. */
84function promptMessages(claimed: readonly UserMessage[]): UserMessage[] {
85 return claimed.filter(message => message.source.kind === 'user')
86}
87
88/**
89 * Replace the prompt text across the human's messages with one rewritten
90 * text: the first text block carries it, other text blocks go, every
91 * non-text block stays in place. A prompt without any text block, such as an
92 * image alone, gains the text after its blocks.
93 */
94function 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 message
99 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 = true
105 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 rewritten
112 return [{ ...first, content: [...first.content, { type: 'text', text }] }, ...rewritten.slice(1)]
113}
114
115/** Append the context a hook attached as blocks after the prompt as typed, on the last of the human's messages. */
116function 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}
123
124/** Keep only the fields of a `prompt.submit` result a mod may set, each with its declared type. */
125function 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}
130
131/** What the bridge folds from one session's event stream about its open turn. */
132interface TurnRecord {
133 turn: number
134 startedAt: number
135 /** The last committed assistant text of the turn. */
136 answer: string
137 usage: TurnUsage | undefined
138}
139
140/** Fold one committed assistant message into the turn's answer and usage. */
141function foldAssistantMessage(turnRecord: TurnRecord, data: { message: AssistantMessage; usage?: TokenUsage }): void {
142 turnRecord.answer = textOf(data.message.content)
143 if (data.usage === undefined) return
144 const { model } = data.message.source
145 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.inputTokens
147 usage.output_tokens += data.usage.outputTokens
148 usage.cache_read_input_tokens += data.usage.cacheReadTokens ?? 0
149 usage.cache_creation_input_tokens += data.usage.cacheWriteTokens ?? 0
150 usage.model = model
151 turnRecord.usage = usage
152}
153
154/** Detached mod runs the bridge tracks so disposal can await them; a rejection is reported, never unhandled. */
155class DetachedRuns {
156 private readonly pending = new Set<Promise<unknown>>()
157 readonly controller = new AbortController()
158
159 constructor(private readonly report: (line: string) => void) {}
160
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 }
167
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 }
172
173 async drain(): Promise<void> {
174 this.cancel()
175 await Promise.allSettled([...this.pending])
176 }
177}
178
179/** The lossless-JSON copy of a mod's answer, or undefined when it has none. */
180function jsonValueOf(value: unknown): JsonValue | undefined {
181 const text = stringify(value)
182 return text === undefined ? undefined : JSON.parse(text) as JsonValue
183}
184
185/** An error-shaped tool result carrying a mod's text in place of the tool's own. */
186function 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}
193
194/**
195 * The bridge service: loaded mods, their hooks, and the harness listeners
196 * that raise their events. Mods join through {@link ClaudeCodeMods.add},
197 * which {@link defineMod} calls when a mod plugin mounts. The Remote methods
198 * let a Client draw each session's band above the prompt and press its buttons.
199 */
200export 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 })
209
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[] = []
213
214 private readonly engine: ModsEngine<AgentBinding>
215 private readonly registrations = new Map<string, Set<() => void>>()
216 private readonly surfaces: SurfaceTable
217
218 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 } = config
222 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 = surfaces
255 // 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 = engine
276 const registrations = this.registrations
277 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')
286
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 } : {}
296
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>()
303
304 ctx.on('agent/created', async ({ agent, signal }) => {
305 if (!isRoot(agent)) return
306 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 })
320
321 ctx.on('agent/disposed', ({ agent }) => {
322 const sessionId = agent.session.id
323 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 return
332 }
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 })
339
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 downstream
372 const context = submitted.context ?? []
373 if (submitted.text === text && context.length === 0) return downstream
374 // 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 })
381
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.agent
388 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 | undefined
393 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 } = e
402 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 beneath
416 // 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 beneath
420 }
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: a
424 // 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 })
432
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) })
435
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 downstream
442 return {
443 kind: 'accept',
444 content: [{ type: 'text', text: replacement }],
445 ...downstream.additionalContexts === undefined ? {} : { additionalContexts: downstream.additionalContexts },
446 }
447 })
448
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 return
453 }
454 if (event.type === 'assistant/message') {
455 const turnRecord = turns.get(session.id)
456 if (turnRecord !== undefined) foldAssistantMessage(turnRecord, event.data)
457 return
458 }
459 if (event.type !== 'turn/end') return
460 const { turn, reason } = event.data
461 startedTurns.get(session.id)?.delete(turn)
462 const agent = ctx.get('agents')?.get(session.id)
463 if (agent === undefined) return
464 const turnRecord = turns.get(session.id)
465 const folded = turnRecord?.turn === turn ? turnRecord : undefined
466 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 }
484
485 /**
486 * Watch the band above the prompt of one session: the current drawing, then
487 * 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 }
496
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 @Remote
505 pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot> {
506 return this.surfaces.press(agent.session.id, generation, actionId)
507 }
508
509
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 }
528
529 /** The loaded mods in chain order. */
530 get mods(): readonly LoadedMod[] {
531 return this.engine.registry.list()
532 }
533}
534
535export default ClaudeCodeMods