The emit-shaped hook points (SessionStart, SubagentStart, SubagentStop) run fire-and-forget: no seam awaits the run chain, so disposing a bridge could strand a live hook process and let a late continuation inject into a disposed context. The floating continuation also made the coverage gate racy: the only coverage of the SubagentStart continuation's no-context branch arm rode on an un-awaited .then, and on a loaded CI runner the fork's per-file coverage snapshot beat it — master run 28798191671 failed the 100% branch gate on hooks-claude/src/index.ts at 99.03% (uncovered line 336) with the identical tree passing the PR run three minutes earlier. New shared primitive createDetachedRuns() in dsh-hook-protocol: a bridge tracks each detached run chain, passes the tracker's abort signal to runHook, and registers drain() as its effect disposer — drain aborts still-running hook processes (a kill via the bash seam, not a wait out to the 10-minute default hook timeout), then resolves once every chain has settled. fiber.dispose() resolving now means the bridge's detached work is quiescent (docs/defensive-patterns.md). The subagent marker test disposes the bridge as its sync point, so the formerly racy branch arm is executed deterministically before the file's coverage snapshot; new tests pin abort-on-dispose promptness for both bridges and the tracker's settle/drain contract in hook-protocol.
@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; 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
matchesMatcher(matcher, query, mode)— match-all on absent/''/'*';claudemode treats a pure[A-Za-z0-9_|]+pattern as a literal (pipe = exact-match alternation) and anything else as a regex;codexmode is always an unanchored regex. An invalid regex matches nothing (never throws).runHook(bash, hook, options, now)— serializeoptions.payloadto the hook's stdin (with a trailing newline iffoptions.trailingNewline), mergeoptions.envafter the executor's credential scrub (thedsh-bashtrusted-plugin surface), honor the hook'stimeoutSec(elseoptions.defaultTimeoutMs— the bridge owns the default, its config defaulting to the lib'sDEFAULT_HOOK_TIMEOUT_MS10-minute reference), and decode the result (threadingoptions.expectedEventNameto the codec). Never throws: an executor rejection (infra fault) becomes aHookOutputwithexitCode: undefined(a non-blocking error).nowis injected for testable durations.parseHookOutput(exitCode, stdout, stderr, expectedEventName?)— the exit-code + structured-stdout codec. Exit0→ parse JSON stdout (lenient: non-JSON is left for the bridge); exit2→ blocking error,stderris the block reason (surfaced asdecision: 'block'); other → non-blocking error.hookSpecificOutput.permissionDecision(allow/deny/ask) overrides a legacy top-leveldecision;additionalContext/updatedInput/systemMessage/continue/stopReasonare parsed too. The schemas key thehookSpecificOutputblock byhookEventName, so passingexpectedEventName(the firing event) DISCARDS a block whosehookEventNamenames a different event — or omits it entirely — its event-scoped fields don't take effect (aPreToolUseblock on aStophook 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 firstcontinue:false, block reasons joined with\n\n,additionalContext/systemMessagesaccumulated 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 registersdrain()as its effect disposer: drain fires the tracker's abortsignal(so a still-running hook process is killed viarunHook, 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).
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.