Files
deepseek-harness/packages/hooks/hook-protocol
Tianyi Cui 232f314c3a Add generated persistence log event catalog with freshness + completeness gates
docs/persistence-catalog/log-events.md enumerates every SessionEventMap
member — the owning dsh-session vocabulary plus the dsh-compact and
dsh-hook-protocol declaration merges — with payload, surface/log-only badge,
JSDoc prose, and declaration site. scripts/gen-persistence-catalog.ts is a
pure AST pass in the gen-cordis-catalog mold: verify-persistence-catalog
(--check) joins doc-sync, so a stale committed catalog fails pre-push and CI.

The walk enforces JSDoc completeness (every member needs description prose;
@mode is rejected as a category error — log events do not dispatch on the
cordis bus), derives the surface badge from the SurfaceEventType union with a
stale-member cross-check, and hard-errors on duplicate declarations. Payloads
render through the TypeScript printer so newline-separated multi-line type
literals still emit valid one-line fragments.

Documented the five previously JSDoc-less core events (turn/step boundaries,
tool/call), removed the two stray @mode tags on the hook/* merges, and
replaced the hand-restated event enumerations (session.md hook/* table,
compact README table, hook-protocol README bullets, session README name-list
— whose merge note had already drifted) with links to the catalog. RFC:
docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md.
2026-07-04 22:58:28 +08:00
..

@deepseek-ai/dsh-hook-protocol

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 test matchesMatcher(pattern, query, mode) — literal-or-regex by mode picks its mode (claude = literal-or-regex, codex = always regex)
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) calls them around each invocation

Primitives

  • matchesMatcher(matcher, query, mode) — match-all on absent/''/'*'; claude mode treats a pure [A-Za-z0-9_|]+ pattern as a literal (pipe = exact-match alternation) and anything else as a regex; codex mode is always an unanchored regex. An invalid regex matches nothing (never throws).
  • runHook(bash, hook, options, now) — 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 defaultTimeoutMs), and decode the result (threading options.expectedEventName to the codec). 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?) — the exit-code + structured-stdout codec. Exit 0 → parse JSON stdout (lenient: non-JSON is left for the bridge); exit 2 → blocking error, stderr is the block reason (surfaced as decision: 'block'); other → non-blocking error. hookSpecificOutput.permissionDecision (allow/deny/ask) overrides a legacy top-level decision; additionalContext/updatedInput/systemMessage/continue/stopReason/suppressOutput are parsed too. The schemas key the hookSpecificOutput block by hookEventName, so passing expectedEventName (the firing event) DISCARDS a block whose hookEventName names a different event — or omits it entirely — its event-scoped fields don't take effect (a PreToolUse block on a Stop hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
  • 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.

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). Payloads and per-event JSDoc are in the generated persistence log event catalog.

Like every event they must sit inside an open turn. The mid-turn points (PreToolUse/PostToolUse/UserPromptSubmit/Stop) fire inside the loop's open turn by construction; SessionStart gets no hook/* record (its injected context/message is the durable evidence) — see the hooks RFC.

Input rewrite is parsed but not honored

HookOutput.updatedInput carries a hook's requested tool-input rewrite (CC updatedInput), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem (the pre-tool-input-rewrite RFC). A bridge logs + warns when a hook sets it. See src/types.ts for the full contracts.