mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Give durable user context its producer-owned source kind instead of a generic plugin wrapper. The V3-to-V4 conversion preserves the frozen rename and collision rules, all own JSON metadata, event coordinates, and historical request-only kinds. Native catalog and JSONL admission retain source validation so unknown attribution can survive without its producer while malformed owned sources are refused. Use an explicit user-only RequestUserInput for auxiliary LLM prompts that do not own durable message identity. Auto Review and compaction remove their request-only source registrations while retaining prompt content and freezing; provider serializers and projections keep the same wire data, assistant replay isolation, image ordering, and tool correlation. Session writes, Agent delivery, and recorded title requests continue to require identified durable messages. Bind the saved compatibility policy only to user sources and qualify tmux location attribution without weakening fixed system, model, or tool semantics. Rebuild the current inventory and unpublished V4 acknowledgement on the actual tool-role parent, preserving its rationale and all accepted historical records. The catalog format version records tooling metadata; Session remains V4. Developer-role policy and activation belong to their own feature. The latest-master Messages adapter, serialization, request-input typing, EpochHeader reservation, and attribution checks pass 103 tests across five files. The retained V3 Messages input-history snapshot passes through the source profile; Host/Client typechecks and all 41 documentation gates pass. The generated comparison records four changed roots and 447 unchanged type fingerprints. Latest-master ARIA changes are retained, with producer source labels applied independently; new Messages fixtures retain their producer names as declared source kinds and use the fixed system-message helper; recorded Session generations are unchanged by this regrouping. Co-authored-by: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
367 lines
18 KiB
TypeScript
367 lines
18 KiB
TypeScript
/**
|
|
* Bridge for unmodified Claude Code command hooks on harness interception
|
|
* extension points. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
|
|
* start/stop. It owns Claude payloads, environment, substitution, and decision
|
|
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
|
|
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
|
|
* use typed native plugins on the same extension points.
|
|
* @module @deepseek-ai/dsh-hooks-claude-code
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
|
declare module '@deepseek-ai/dsh-llm' {
|
|
interface MessageSourceMap {
|
|
'hooks-claude-code': { kind: 'hooks-claude-code' } & ContextFormed
|
|
}
|
|
}
|
|
|
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
|
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
|
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
|
import {
|
|
appendHookInvoked,
|
|
appendHookResult,
|
|
createDetachedRuns,
|
|
DEFAULT_HOOK_TIMEOUT_MS,
|
|
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
|
matchesMatcher,
|
|
mergeHookOutputs,
|
|
runHook,
|
|
type HookOutput,
|
|
type MatcherGroup,
|
|
type MergedHookOutcome,
|
|
} from '@deepseek-ai/dsh-hook-protocol'
|
|
// Pulls in the declaration-merged subagent events and the identity pairing their
|
|
// start/end edges.
|
|
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
|
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'
|
|
|
|
export const name = 'hooks-claude-code'
|
|
// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest
|
|
// are read opportunistically via ctx.get so a deployment can omit them.
|
|
export const inject = ['shell', 'sessionProjections']
|
|
|
|
/** Plugin config: where the CC hook config lives + substitution roots. */
|
|
export interface Config {
|
|
/**
|
|
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
|
|
* Process-level: read once at load, a relative path resolves against the process
|
|
* launch cwd, so one config applies to the whole process.
|
|
* TODO(per-session-hook-config): per-session discovery of a project-local
|
|
* `hooks.json` from each `session/new.cwd`.
|
|
*/
|
|
configPath: string
|
|
/**
|
|
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
|
|
*/
|
|
pluginRoot?: string
|
|
/**
|
|
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
|
|
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
|
|
* defaults per-run to the agent's session workspace (`session.header.cwd`, the
|
|
* same dir the hook runs in) — Claude Code always exports this var, and common
|
|
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
|
|
*/
|
|
projectDir?: string
|
|
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
|
|
defaultTimeoutMs?: number
|
|
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
|
stderrSummaryMaxChars?: number
|
|
}
|
|
|
|
export const Config: z<Config> = z.object({
|
|
configPath: z.string().required(),
|
|
pluginRoot: z.string(),
|
|
projectDir: z.string(),
|
|
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
|
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
|
})
|
|
|
|
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
|
let handlerCounter = 0
|
|
function nextHandlerId(point: string): string {
|
|
return `claude-code:${point}:${++handlerCounter}`
|
|
}
|
|
|
|
/** The `{kind:'hooks-claude-code'}` producer source stamped on every context this bridge injects. */
|
|
const CONTEXT_SOURCE: MessageSource = { kind: 'hooks-claude-code' }
|
|
|
|
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
|
|
function assertPositiveInteger(name: string, value: number): void {
|
|
if (!Number.isInteger(value) || value < 1) {
|
|
throw new Error(`hooks-claude-code: ${name} must be a positive integer`)
|
|
}
|
|
}
|
|
|
|
export function apply(ctx: Context, config: Config): void {
|
|
// Validate before config parsing so a bad value cannot be hidden by its early return.
|
|
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
|
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
|
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
|
// Parse once at load. A read or parse failure logs and registers nothing.
|
|
let parsed: ClaudeCodeHookConfig = {}
|
|
try {
|
|
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
|
const result = parseClaudeCodeConfig(raw, {
|
|
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
|
|
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
|
|
})
|
|
parsed = result.config
|
|
for (const s of result.skipped) {
|
|
ctx.logger.warn(`hooks-claude-code: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
|
|
}
|
|
} catch (error: unknown) {
|
|
ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
|
return
|
|
}
|
|
|
|
// Emit-shaped points run detached, so track their chains; disposal aborts
|
|
// active hooks and drains continuations before resolving.
|
|
const detached = createDetachedRuns()
|
|
// Only the start edge guarantees registry access. Retain each local child
|
|
// through its paired end so stop hooks keep the session workspace after the
|
|
// handle unregisters the agent. Every retained entry relies on that paired
|
|
// end; a producer that can omit it must provide another release edge.
|
|
const subagentChildren = new Map<SubagentRunId, Agent>()
|
|
ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')
|
|
|
|
/**
|
|
* Run every command hook configured for `point` whose matcher selects
|
|
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
|
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
|
|
* an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral,
|
|
* already-most-restrictive view) for the caller to map onto its extension point
|
|
* decision. `matchQuery` is the event's matcher subject (tool name, session
|
|
* source, …); `''` for events that ignore matchers.
|
|
*/
|
|
async function runPoint(
|
|
point: string,
|
|
matchQuery: string,
|
|
payload: unknown,
|
|
opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal },
|
|
): Promise<MergedHookOutcome> {
|
|
const groups: MatcherGroup[] = parsed[point] ?? []
|
|
const outputs: HookOutput[] = []
|
|
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
|
|
// header), not the executor or entry-point process's launch dir.
|
|
const workdir = opts.agent?.session.header.cwd
|
|
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
|
|
// workspace (the same dir the hook runs in).
|
|
const projectDir = config.projectDir ?? workdir
|
|
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
|
|
for (const group of groups) {
|
|
if (!matchesMatcher(group.matcher, matchQuery, 'claude-code')) continue
|
|
for (const hook of group.hooks) {
|
|
const handlerId = nextHandlerId(point)
|
|
const session = opts.agent?.session
|
|
if (session && opts.turn !== undefined) {
|
|
appendHookInvoked(session, {
|
|
turn: opts.turn, point, dialect: 'claude-code', handlerId,
|
|
...group.matcher !== undefined ? { matcher: group.matcher } : {},
|
|
})
|
|
}
|
|
const { output, durationMs } = await runHook(ctx.shell, hook, {
|
|
payload,
|
|
defaultTimeoutMs,
|
|
...hookEnv ? { env: hookEnv } : {},
|
|
...workdir !== undefined ? { cwd: workdir } : {},
|
|
signal: opts.signal,
|
|
trailingNewline: true,
|
|
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
|
// different event than the one firing (the schemas key it by event).
|
|
expectedEventName: point,
|
|
}, () => performance.now())
|
|
outputs.push(output)
|
|
if (output.updatedInput !== undefined) {
|
|
ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
|
|
}
|
|
if (output.systemMessage !== undefined) {
|
|
ctx.logger.warn(`hooks-claude-code: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
|
}
|
|
if (session && opts.turn !== undefined) {
|
|
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
|
}
|
|
}
|
|
}
|
|
return mergeHookOutputs(outputs)
|
|
}
|
|
|
|
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.
|
|
|
|
/** Build additional model context from hook output, or return undefined when empty. */
|
|
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {
|
|
if (merged.additionalContext.length === 0) return undefined
|
|
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
|
|
return createUserMessage({ content, source: CONTEXT_SOURCE })
|
|
}
|
|
|
|
/** Prepend one context without flattening source fields or other downstream metadata. */
|
|
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {
|
|
return [ours, ...theirs ?? []]
|
|
}
|
|
|
|
ctx.on('agent/created', async ({ agent, source, signal }) => {
|
|
const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])
|
|
const run = runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: ownerSignal })
|
|
.then((merged) => {
|
|
const context = contextFrom(merged)
|
|
if (context) agent.inject(context)
|
|
})
|
|
.catch((error: unknown) => {
|
|
ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
|
|
})
|
|
detached.track(run)
|
|
await run
|
|
})
|
|
|
|
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
|
|
// matcher subject (CC ignores matchers for this event). ---
|
|
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
|
|
if (messages.length === 0) return next()
|
|
const content = messages.flatMap(message => message.content)
|
|
const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn, signal })
|
|
if (merged.decision === 'deny') {
|
|
return { kind: 'reject' }
|
|
}
|
|
// Delegate so later listeners may still rewrite or reject, then prepend our
|
|
// context only to a downstream enter decision.
|
|
const downstream = await next()
|
|
const ours = contextFrom(merged)
|
|
if (!ours || downstream.kind !== 'enter') return downstream
|
|
return {
|
|
...downstream,
|
|
messages: [...downstream.messages, ours],
|
|
}
|
|
})
|
|
|
|
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
|
|
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
|
const turn = lastTurn(ctx, exec.agent)
|
|
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
|
|
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
|
|
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
|
|
return next()
|
|
})
|
|
|
|
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
|
|
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
|
const turn = lastTurn(ctx, exec.agent)
|
|
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
|
|
const context = contextFrom(merged)
|
|
if (merged.decision === 'deny') {
|
|
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
|
|
}
|
|
// Our hooks did not block. DELEGATE so a later listener can still block/replace,
|
|
// then fold our context onto its decision (a downstream block carries it too).
|
|
const downstream = await next()
|
|
if (!context) return downstream
|
|
if (downstream.kind === 'block') {
|
|
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
|
|
}
|
|
return {
|
|
...downstream,
|
|
additionalContexts: prependContext(context, downstream.additionalContexts),
|
|
}
|
|
})
|
|
|
|
// A blocking Stop hook steers at the stopping boundary, which makes the
|
|
// machine observe pending input and run another step.
|
|
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
|
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
|
|
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn, signal })
|
|
if (merged.decision === 'deny') {
|
|
// A blocking Stop hook forces continuation.
|
|
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
|
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE }))
|
|
}
|
|
})
|
|
|
|
// SubagentStart may inject child context; SubagentStop only observes. Both
|
|
// use the live child's workspace and the generic agent-type matcher subject.
|
|
ctx.on('subagent/start', (info) => {
|
|
const child = ctx.get('agents')?.get(info.id)
|
|
if (child !== undefined) subagentChildren.set(info.runId, child)
|
|
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
|
.then((merged) => {
|
|
const context = contextFrom(merged)
|
|
if (context && child) child.inject(context)
|
|
})
|
|
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStart hook failed: ${String(error)}`) }))
|
|
})
|
|
ctx.on('subagent/end', (info) => {
|
|
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)
|
|
subagentChildren.delete(info.runId)
|
|
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The `agent_type` value the bridge reports for SubagentStart/Stop. The harness
|
|
* subagent seam carries no per-kind label, so the bridge uses Claude Code's own
|
|
* Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher
|
|
* fires; a config matching a specific kind (e.g. `code-reviewer`) does not.
|
|
*/
|
|
const SUBAGENT_TYPE = 'general-purpose'
|
|
|
|
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
|
|
// hook input schema; this is the part a bridge owns. ---
|
|
|
|
/** The last open turn number in the agent's log, or 0 without an agent. */
|
|
function lastTurn(ctx: Context, agent: Agent | undefined): number {
|
|
if (!agent) return 0
|
|
const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection
|
|
return boundary.lastTurn
|
|
}
|
|
|
|
/** Flatten content blocks to the text a hook payload carries (the common case). */
|
|
function blocksToText(content: ContentBlock[]): string {
|
|
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
|
|
}
|
|
|
|
function base(agent: Agent | undefined, event: string): Record<string, unknown> {
|
|
return {
|
|
session_id: agent?.session.header.id ?? '',
|
|
// The persistence seam exposes no artifact path; the field stays empty
|
|
// (a durable consumer gap recorded in this package's README).
|
|
transcript_path: '',
|
|
cwd: agent?.session.header.cwd ?? process.cwd(),
|
|
hook_event_name: event,
|
|
}
|
|
}
|
|
|
|
function sessionStartPayload(agent: Agent, source: string): Record<string, unknown> {
|
|
return { ...base(agent, 'SessionStart'), source }
|
|
}
|
|
function promptPayload(agent: Agent, content: ContentBlock[]): Record<string, unknown> {
|
|
return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
|
|
}
|
|
function preToolPayload(exec: ToolExecution): Record<string, unknown> {
|
|
return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
|
|
}
|
|
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
|
|
return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
|
}
|
|
function stopPayload(agent: Agent): Record<string, unknown> {
|
|
return { ...base(agent, 'Stop'), stop_hook_active: false }
|
|
}
|
|
/**
|
|
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
|
|
* `session_id`/`cwd` when the child agent is available) plus the subagent-hook
|
|
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
|
|
* is present on SubagentStop only (the loop-guard flag, always false).
|
|
*/
|
|
function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
|
|
return {
|
|
...base(child, event),
|
|
agent_id: info.id,
|
|
agent_type: SUBAGENT_TYPE,
|
|
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
|
|
}
|
|
}
|