1
/**2
* Bridge for unmodified Claude Code command hooks on harness interception3
* extension points. It supports SessionStart, prompt/tool pre/post, Stop, and subagent4
* start/stop. It owns Claude payloads, environment, substitution, and decision5
* mapping; shared execution and parsing live in `dsh-hook-protocol`.6
* `updatedInput` is logged and warned but not honored. Bespoke behavior should7
* use typed native plugins on the same extension points.8
* @module @deepseek-ai/dsh-hooks-claude-code9
*/11
import { readFileSync } from 'node:fs'12
import type { Context } from '@deepseek-ai/cordis'13
import z from '@deepseek-ai/schemastery'14
import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'15
import type {} from '@deepseek-ai/dsh-session-projection'16
import { createUserMessage } from '@deepseek-ai/dsh-llm'17
import type { ContextFormed } from '@deepseek-ai/dsh-llm'18
declare module '@deepseek-ai/dsh-llm' {19
interface MessageSourceMap {20
'hooks-claude-code': { kind: 'hooks-claude-code' } & ContextFormed21
}22
}24
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'25
import type { UserMessage } from '@deepseek-ai/dsh-session'26
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'27
import {28
appendHookInvoked,29
appendHookResult,30
createDetachedRuns,31
DEFAULT_HOOK_TIMEOUT_MS,32
DEFAULT_STDERR_SUMMARY_MAX_CHARS,33
matchesMatcher,34
mergeHookOutputs,35
runHook,36
type HookOutput,37
type MatcherGroup,38
type MergedHookOutcome,39
} from '@deepseek-ai/dsh-hook-protocol'40
// Pulls in the declaration-merged subagent events and the identity pairing their41
// start/end edges.42
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'43
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'45
export const name = 'hooks-claude-code'46
// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest47
// are read opportunistically via ctx.get so a deployment can omit them.48
export const inject = ['shell', 'sessionProjections']50
/** Plugin config: where the CC hook config lives + substitution roots. */51
export interface Config {52
/**53
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.54
* Process-level: read once at load, a relative path resolves against the process55
* launch cwd, so one config applies to the whole process.56
* TODO(per-session-hook-config): per-session discovery of a project-local57
* `hooks.json` from each `session/new.cwd`.58
*/59
configPath: string60
/**61
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).62
*/63
pluginRoot?: string64
/**65
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the66
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var67
* defaults per-run to the agent's session workspace (`session.header.cwd`, the68
* same dir the hook runs in) — Claude Code always exports this var, and common69
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.70
*/71
projectDir?: string72
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */73
defaultTimeoutMs?: number74
/** Character cap for the `hook/result` event's persisted stderr summary. */75
stderrSummaryMaxChars?: number76
}78
export const Config: z<Config> = z.object({79
configPath: z.string().required(),80
pluginRoot: z.string(),81
projectDir: z.string(),82
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),83
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),84
})86
/** A stable per-handler id so an invoked/result pair correlates in the log. */87
let handlerCounter = 088
function nextHandlerId(point: string): string {89
return `claude-code:${point}:${++handlerCounter}`90
}92
/** The `{kind:'hooks-claude-code'}` producer source stamped on every context this bridge injects. */93
const CONTEXT_SOURCE: MessageSource = { kind: 'hooks-claude-code' }95
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */96
function assertPositiveInteger(name: string, value: number): void {97
if (!Number.isInteger(value) || value < 1) {98
throw new Error(`hooks-claude-code: ${name} must be a positive integer`)99
}100
}102
export function apply(ctx: Context, config: Config): void {103
// Validate before config parsing so a bad value cannot be hidden by its early return.104
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS105
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)106
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS107
// Parse once at load. A read or parse failure logs and registers nothing.108
let parsed: ClaudeCodeHookConfig = {}109
try {110
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))111
const result = parseClaudeCodeConfig(raw, {112
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},113
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},114
})115
parsed = result.config116
for (const s of result.skipped) {117
ctx.logger.warn(`hooks-claude-code: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)118
}119
} catch (error: unknown) {120
ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)121
return122
}124
// Emit-shaped points run detached, so track their chains; disposal aborts125
// active hooks and drains continuations before resolving.126
const detached = createDetachedRuns()127
// Only the start edge guarantees registry access. Retain each local child128
// through its paired end so stop hooks keep the session workspace after the129
// handle unregisters the agent. Every retained entry relies on that paired130
// end; a producer that can omit it must provide another release edge.131
const subagentChildren = new Map<SubagentRunId, Agent>()132
ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')134
/**135
* Run every command hook configured for `point` whose matcher selects136
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.137
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names138
* an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral,139
* already-most-restrictive view) for the caller to map onto its extension point140
* decision. `matchQuery` is the event's matcher subject (tool name, session141
* source, …); `''` for events that ignore matchers.142
*/143
async function runPoint(144
point: string,145
matchQuery: string,146
payload: unknown,147
opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal },148
): Promise<MergedHookOutcome> {149
const groups: MatcherGroup[] = parsed[point] ?? []150
const outputs: HookOutput[] = []151
// Run the hook in the agent's session workspace (the `session/new` cwd on the session152
// header), not the executor or entry-point process's launch dir.153
const workdir = opts.agent?.session.header.cwd154
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session155
// workspace (the same dir the hook runs in).156
const projectDir = config.projectDir ?? workdir157
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined158
for (const group of groups) {159
if (!matchesMatcher(group.matcher, matchQuery, 'claude-code')) continue160
for (const hook of group.hooks) {161
const handlerId = nextHandlerId(point)162
const session = opts.agent?.session163
if (session && opts.turn !== undefined) {164
appendHookInvoked(session, {165
turn: opts.turn, point, dialect: 'claude-code', handlerId,166
...group.matcher !== undefined ? { matcher: group.matcher } : {},167
})168
}169
const { output, durationMs } = await runHook(ctx.shell, hook, {170
payload,171
defaultTimeoutMs,172
...hookEnv ? { env: hookEnv } : {},173
...workdir !== undefined ? { cwd: workdir } : {},174
signal: opts.signal,175
trailingNewline: true,176
// Discard a `hookSpecificOutput` block whose `hookEventName` names a177
// different event than the one firing (the schemas key it by event).178
expectedEventName: point,179
}, () => performance.now())180
outputs.push(output)181
if (output.updatedInput !== undefined) {182
ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)183
}184
if (output.systemMessage !== undefined) {185
ctx.logger.warn(`hooks-claude-code: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)186
}187
if (session && opts.turn !== undefined) {188
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })189
}190
}191
}192
return mergeHookOutputs(outputs)193
}195
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.197
/** Build additional model context from hook output, or return undefined when empty. */198
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {199
if (merged.additionalContext.length === 0) return undefined200
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))201
return createUserMessage({ content, source: CONTEXT_SOURCE })202
}204
/** Prepend one context without flattening source fields or other downstream metadata. */205
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {206
return [ours, ...theirs ?? []]207
}209
ctx.on('agent/created', async ({ agent, source, signal }) => {210
const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])211
const run = runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: ownerSignal })212
.then((merged) => {213
const context = contextFrom(merged)214
if (context) agent.inject(context)215
})216
.catch((error: unknown) => {217
ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)218
})219
detached.track(run)220
await run221
})223
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no224
// matcher subject (CC ignores matchers for this event). ---225
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {226
if (messages.length === 0) return next()227
const content = messages.flatMap(message => message.content)228
const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn, signal })229
if (merged.decision === 'deny') {230
return { kind: 'reject' }231
}232
// Delegate so later listeners may still rewrite or reject, then prepend our233
// context only to a downstream enter decision.234
const downstream = await next()235
const ours = contextFrom(merged)236
if (!ours || downstream.kind !== 'enter') return downstream237
return {238
...downstream,239
messages: [...downstream.messages, ours],240
}241
})243
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---244
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {245
const turn = lastTurn(ctx, exec.agent)246
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })247
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }248
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }249
return next()250
})252
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---253
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {254
const turn = lastTurn(ctx, exec.agent)255
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })256
const context = contextFrom(merged)257
if (merged.decision === 'deny') {258
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }259
}260
// Our hooks did not block. DELEGATE so a later listener can still block/replace,261
// then fold our context onto its decision (a downstream block carries it too).262
const downstream = await next()263
if (!context) return downstream264
if (downstream.kind === 'block') {265
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }266
}267
return {268
...downstream,269
additionalContexts: prependContext(context, downstream.additionalContexts),270
}271
})273
// A blocking Stop hook steers at the stopping boundary, which makes the274
// machine observe pending input and run another step.275
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.276
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {277
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn, signal })278
if (merged.decision === 'deny') {279
// A blocking Stop hook forces continuation.280
const text = merged.reason ?? 'continue: blocked by Stop hook'281
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE }))282
}283
})285
// SubagentStart may inject child context; SubagentStop only observes. Both286
// use the live child's workspace and the generic agent-type matcher subject.287
ctx.on('subagent/start', (info) => {288
const child = ctx.get('agents')?.get(info.id)289
if (child !== undefined) subagentChildren.set(info.runId, child)290
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })291
.then((merged) => {292
const context = contextFrom(merged)293
if (context && child) child.inject(context)294
})295
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStart hook failed: ${String(error)}`) }))296
})297
ctx.on('subagent/end', (info) => {298
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)299
subagentChildren.delete(info.runId)300
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))301
})302
}304
/**305
* The `agent_type` value the bridge reports for SubagentStart/Stop. The harness306
* subagent seam carries no per-kind label, so the bridge uses Claude Code's own307
* Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher308
* fires; a config matching a specific kind (e.g. `code-reviewer`) does not.309
*/310
const SUBAGENT_TYPE = 'general-purpose'312
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's313
// hook input schema; this is the part a bridge owns. ---315
/** The last open turn number in the agent's log, or 0 without an agent. */316
function lastTurn(ctx: Context, agent: Agent | undefined): number {317
if (!agent) return 0318
const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection319
return boundary.lastTurn320
}322
/** Flatten content blocks to the text a hook payload carries (the common case). */323
function blocksToText(content: ContentBlock[]): string {324
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')325
}327
function base(agent: Agent | undefined, event: string): Record<string, unknown> {328
return {329
session_id: agent?.session.header.id ?? '',330
// The persistence seam exposes no artifact path; the field stays empty331
// (a durable consumer gap recorded in this package's README).332
transcript_path: '',333
cwd: agent?.session.header.cwd ?? process.cwd(),334
hook_event_name: event,335
}336
}338
function sessionStartPayload(agent: Agent, source: string): Record<string, unknown> {339
return { ...base(agent, 'SessionStart'), source }340
}341
function promptPayload(agent: Agent, content: ContentBlock[]): Record<string, unknown> {342
return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) }343
}344
function preToolPayload(exec: ToolExecution): Record<string, unknown> {345
return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }346
}347
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {348
return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }349
}350
function stopPayload(agent: Agent): Record<string, unknown> {351
return { ...base(agent, 'Stop'), stop_hook_active: false }352
}353
/**354
* Build a SubagentStart/SubagentStop payload from the CC base (the child's355
* `session_id`/`cwd` when the child agent is available) plus the subagent-hook356
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`357
* is present on SubagentStop only (the loop-guard flag, always false).358
*/359
function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {360
return {361
...base(child, event),362
agent_id: info.id,363
agent_type: SUBAGENT_TYPE,364
...event === 'SubagentStop' ? { stop_hook_active: false } : {},365
}366
}