Codex's PR-E review found three protocol-fidelity blockers + two doc gaps, all
verified against ~/repos/refs:
- (A) Top-level `decision` accepted allow/deny/ask, but both reference schemas
reserve those for hookSpecificOutput.permissionDecision — the legacy top-level
decision is approve/block ONLY. Split topLevelDecisionOf (approve/block) from
permissionDecisionOf (allow/deny/ask), so an out-of-band {"decision":"deny"} is
now invalid and ignored instead of becoming a real blocking decision.
- (A) hookSpecificOutput was parsed without its hookEventName discriminator.
HookOutput now surfaces hookEventName so a bridge can discard a block whose
claimed event doesn't match the firing one (the schemas key the block by event).
- (A) runHook discarded raw stdout. HookOutput now carries `stdout` (trimmed,
verbatim) so a bridge can reproduce CC's plain-stdout rendering / Codex's
plain-stdout-as-additionalContext behavior.
- (B) hook/* SessionEventMap variants were only named in prose; added a payload/role
table to core-data-structures/session.md (a maintained catalog surface).
- (B) Removed PR-stack-position references (PR-F / "future bridge packages") from a
test comment and the RFC, per the current-state-wording rule.
New codec tests: top-level allow/deny/ask invalid+ignored, hookEventName capture,
raw stdout preserved on plain + JSON + empty stdout. 51 tests, per-file 100%.
@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/''/'*';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(elsedefaultTimeoutMs), and decode the result. Never throws: an executor rejection (infra fault) becomes aHookOutputwithexitCode: undefined(a non-blocking error).nowis injected for testable durations.parseHookOutput(exitCode, stdout, stderr)— 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/stopReason/suppressOutputare parsed too. 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.
hook/* session events
Declaration-merged into SessionEventMap (log-only, like compact/* — NOT a SurfaceEventType, no surfaceOp):
hook/invoked—{ turn, point, dialect, matcher?, handlerId }: a hook command ran.hook/result—{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }: its outcome, paired byhandlerId.
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.