Files
deepseek-harness/packages/hooks/hook-protocol
Tianyi Cui 7f711996ff Merge commit 'ecbf75a5e70f662b6420375140cf12eb6bac7860' into worktree/retarget-pr828-20260729
# Conflicts:
#	packages/hooks/README.i18n.yaml
#	packages/hooks/README.zh.md
#	packages/hooks/hook-protocol/README.i18n.yaml
#	packages/hooks/hook-protocol/README.zh.md
#	packages/hooks/hooks-claude/README.i18n.yaml
#	packages/hooks/hooks-claude/README.zh.md
#	packages/hooks/hooks-codex/README.i18n.yaml
#	packages/hooks/hooks-codex/README.zh.md
2026-07-29 21:34:29 +08:00
..

@deepseek-ai/dsh-hook-protocol

English | 中文

The shared core of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a library of dialect-neutral primitives the two bridge plugins (@deepseek-ai/dsh-hooks-claude, @deepseek-ai/dsh-hooks-codex) import so neither re-implements the identical halves of the protocol.

Why a shared lib at all: Codex deliberately reimplements a subset of the Claude Code hook protocol — the same hooks.json matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.

What's shared (here) vs. per-dialect (the bridges)

Concern Here (dsh-hook-protocol) The bridge (dsh-hooks-claude / -codex)
Matcher validation + test compileMatchers(patterns, mode) exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; matcherDiagnostic / matchesMatcher are contained one-shot helpers picks its native regex mode (claude = JavaScript, codex = Rust regex), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or teardown
Run a hook runHook(bash, hook, opts, now) — stdin payload + env via ctx.bash, decode builds the per-event stdin payload + the dialect's env
Decode output parseHookOutput(exit, stdout, stderr) → neutral HookOutput maps the neutral HookOutput onto a seam-specific typed Decision
Merge N hooks mergeHookOutputs(outputs) → most-restrictive MergedHookOutcome
Durable record appendHookInvoked / appendHookResult (hook/* session events; the result's decision/stderrSummary derive from the HookOutput here) calls them around each invocation
Detached-run quiescence createDetachedRuns() — track fire-and-forget run chains; drain() aborts, then awaits them passes signal to each detached runHook, registers drain as its effect disposer

Primitives

  • compileMatchers(matchers, mode) / matcherDiagnostic(matcher, mode) / matchesMatcher(matcher, query, mode) — match-all on absent/''/'*'; both dialects treat a pure [A-Za-z0-9_|]+ pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust regex for Codex (including inline flags such as (?i)). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads registry.diagnostic(pattern) from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous rregex dependency module, so they survive hook-protocol/Cordis reloads without using globalThis; one-shot helpers share the same interner. Because rregex cannot shrink its WASM allocation after free(), the process deliberately retains at most MAX_INTERNED_CODEX_REGEX_PATTERNS (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache.
  • runHook(bash, hook, options, now) — require and forward the caller-owned options.signal, serialize options.payload to the hook's stdin (with a trailing newline iff options.trailingNewline), merge options.env after the executor's credential scrub (the dsh-bash trusted-plugin surface), honor the hook's timeoutSec (else options.defaultTimeoutMs — the bridge owns the default, its config defaulting to the lib's DEFAULT_HOOK_TIMEOUT_MS 10-minute reference), and decode the result (threading options.expectedEventName to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a HookOutput with exitCode: undefined (a non-blocking error). now is injected for testable durations.
  • parseHookOutput(exitCode, stdout, stderr, expectedEventName?) decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
  • mergeHookOutputs(outputs) — fold the results of every hook that matched one point: permission precedence deny > ask > allow, halt sticky on the first continue:false, block reasons joined with \n\n, additionalContext/systemMessages accumulated in order.
  • createDetachedRuns() — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers drain() as its effect disposer: drain fires the tracker's abort signal (so a still-running hook process is killed via runHook, not awaited out to its timeout), then resolves once every tracked chain has settled. fiber.dispose() resolving therefore means no detached hook work is left to fire into a disposed context (defensive patterns: dispose must reach quiescence).

hook/* session events

Declaration-merged into SessionEventMap (log-only, like compact/* — NOT a SurfaceEventType, no surfaceOp): hook/invoked (a hook command ran) and hook/result (its outcome, paired by handlerId, with appendHookResult owning the decision rule). Payloads and per-event JSDoc are in the generated persistence log event catalog; stderrSummary is truncated to the record's stderrSummaryMaxChars (the bridge's config, reference default DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500; omitted when empty).

Hook provenance records must sit inside an open turn. The mid-turn points (PreToolUse/PostToolUse/Stop) satisfy that owner-defined relation by construction. SessionStart and the pre-turn UserPromptSubmit admission seam get no hook/* record; allowed context is instead evidenced by its sourced user/message — see the hooks Agent Note.

Model Experience

Indirectly, through dsh-hooks-claude and dsh-hooks-codex, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.

KV Cache effect

No direct invalidation; the named consumer owns any request-prefix changes.

Known Limitations and Deferred Work

  • HookOutput.updatedInput is parsed but not honored — input rewrite is a deferred consistency-design problem (the pre-tool-input-rewrite Agent Note); a bridge logs + warns when a hook sets it. See src/types.ts for the full contracts.