M7.4
Lifecycle hooks and the interop question
1,728 words · about 11 min
Concept
A hook is a callback at a named lifecycle point. In dsh, "a 'native hook' is just an ordinary Cordis plugin on those extension points" (packages/hooks/README.md) — everything M7.1 said about waterfall listeners already is the hook system. What packages/hooks/ adds is something different and more interesting: bridges that let dsh execute other harnesses' hook configurations.
The framing sentence, from packages/hooks/README.md:
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing
hooks.json(or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception points; a "native hook" is just an ordinary Cordis plugin on those extension points. These packages are the bridges that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
Mechanism: the event points
Mechanismdocs/architecture.md gives the turn flow:
turn/start → claim input → assemble prompt sections + tool schemas
→ agent/pre-step (reject | enter) → step/start
→ append user/message → derive model history
→ agent/request → llm/stream → assistant/chunk* → assistant/message
→ tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
→ step/end → agent/turn-stopping → turn/end"agent/pre-step, agent/request, llm/stream, and the three tools/* events are waterfalls, whose listeners must call next() to delegate; agent/turn-stopping is serial and has no next()."
docs/event-producer-consumer.md is generated from the TypeScript program, so the listener lists are facts rather than documentation. agent/pre-step alone carries listeners from agent-instructions, compaction-basic, goal-round-driver, both hook bridges, plan-mode, repeat-tool-reminder, session-checkpoint-policy, session-reference, subagent-in-process-driver, time-context, tmux-context, tool-cordis, and tool-skill. Fourteen independent features on one point, none of which edited the loop. That table is the microkernel claim made checkable — and it is also the M7.1 indirection tax made visible.
Mechanism: the wire protocol
Mechanismpackages/hooks/hook-protocol is explicitly "NOT a cordis plugin — it registers nothing and injects nothing. It is a library." It exists because "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 shared vocabulary, from packages/hooks/hook-protocol/src/types.ts:
export interface CommandHook {
/** The shell command line to run. */
command: string
/** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */
timeoutSec?: number
}
export interface MatcherGroup {
matcher?: string
hooks: CommandHook[]
}The protocol has four stages, and each is a shared function:
1. Match. matchesMatcher(pattern, query, mode) with mode chosen by the bridge. Absent, '', or '*' match all. 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. One matcher string means different things in the two dialects — which is exactly the kind of detail that makes "just support their format" more expensive than it looks.
2. Run. runHook(bash, hook, opts, now) executes through ctx.shell — the M7.2 seam again, so hooks inherit the composition's confinement. It serializes the payload to stdin (with a trailing newline iff options.trailingNewline — Claude Code sends one, Codex does not), merges options.env after the executor's credential scrub, and honours the hook's timeoutSec else the bridge's configured default. Critically: "Never throws: an executor rejection (infra fault) becomes a HookOutput with exitCode: undefined (a non-blocking error)." A broken hook must not take down a turn.
3. Decode. parseHookOutput(exitCode, stdout, stderr, expectedEventName?) produces the neutral HookOutput. The governing rule: exit 2 blocks with stderr; other failures are non-blocking. The type's JSDoc explains the trickiest normalization:
The neutral blocking decision a hook expressed, folded from the two channels the reference protocols keep DISTINCT: the legacy top-level
decision(approve/blockonly) andhookSpecificOutput.permissionDecision(allow/deny/ask). We normalize them to one enum … but'allow'/'deny'/'ask'arise ONLY from apermissionDecision, never from a top-leveldecision.
That is what faithfully implementing someone else's protocol looks like: you inherit their historical accidents and must reproduce them exactly, including which spellings are invalid in which channel.
4. Merge. mergeHookOutputs(outputs) folds many results into one: permission precedence deny > ask > allow, halt sticky on the first continue:false, block reasons joined with \n\n, additionalContext/systemMessages accumulated in order. Most-restrictive-wins is the only defensible default — any other rule lets adding a hook weaken a policy, which means an operator can never reason about a hook set.
Every invocation writes the log-only pair hook/invoked and hook/result, declaration-merged into SessionEventMap and correlated by handlerId. And createDetachedRuns() handles a subtle lifecycle problem: emit-shaped points run fire-and-forget, so drain() aborts then awaits the run chains, which is what makes fiber.dispose() resolving actually mean no work is left. A harness without that has a shutdown that appears clean and isn't.
Mechanism: the mapping, and what it costs
MechanismFrom packages/hooks/hooks-claude-code/README.md:
| CC hook | Harness point | Mapping |
|---|---|---|
SessionStart | agent/session-start (emit) | additionalContext → agent.inject(); cannot block |
UserPromptSubmit | agent/pre-step (waterfall) | deny → PreStepDecision.reject; additionalContext-only → delegate via next() then append |
PreToolUse | tools/pre-execute (waterfall) | deny → PreToolDecision.deny; ask → PreToolDecision.ask |
PostToolUse | tools/post-execute (waterfall) | deny → block with feedback; additionalContext-only → delegate then prepend |
Stop | agent/turn-stopping (serial) | blocking Stop feeds reason through steer(), forcing another step |
SubagentStart | subagent/start (emit) | additionalContext → agent.inject() into live in-process child |
SubagentStop | subagent/end (emit) | observe-only |
Configuration is an ordinary row:
- dsh-hooks-claude-code:
configPath: ./.claude/hooks.json
pluginRoot: ./.claude/plugins/my-plugin
projectDir: .The Codex bridge takes configPath, an optional model "stamped on every payload (Codex includes model)", defaultTimeoutMs, and stderrSummaryMaxChars. It maps five of Codex's ten points, uses regex-only matchers, sends snake_case payloads with turn_id/model extras and no trailing newline, and — the sharpest contrast — maps block → PreToolDecision.deny with no allow or ask, because Codex's protocol has no such decisions to express.
Both bridges resolve transcript_path through ctx.sessionPersistence.locate(session.header) — another seam consumer — and differ in the empty case: the CC bridge sends '', the Codex bridge sends null, "preserving the Codex string | null shape." Getting that right matters because a hook script written against Codex may test is not None.
The interop analysis: what does it buy, what does it cost?
This is the part the objective is really asking for.
What it buys.
Zero migration cost for existing users. A team with a working .claude/hooks.json — formatters, secret scanners, commit gates — points a config row at it and their tooling runs. The hooks are shell scripts with a documented contract, not code coupled to a vendor's internals.
A protocol you did not have to design. Hook-point taxonomy is genuinely hard, and Claude Code's has been beaten on by real users. Adopting it is inheriting that design work.
A portability claim with teeth. Because the same hooks.json runs in two harnesses, hook authors gain a reason to write portable hooks. That is how a de facto standard forms — and hook interop is one of the few places the agent-harness ecosystem is actually converging.
What it costs.
Fidelity debt, and it is large. packages/hooks/hooks-claude-code/README.md lists 23 of Claude Code's current 30 hook events as unsupported: Setup, InstructionsLoaded, UserPromptExpansion, MessageDisplay, PermissionRequest, PostToolUseFailure, PostToolBatch, PermissionDenied, Notification, TaskCreated, TaskCompleted, StopFailure, TeammateIdle, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, SessionEnd, Elicitation, ElicitationResult. The Codex bridge supports five of ten. A user's mental model is "my hooks work," and the truth is "the mapped subset works" — a gap that is invisible until a hook silently never fires.
Partial semantics inside supported events. On PreToolUse: "deny and ask decisions work; allow does not pre-approve, defer is unsupported, additionalContext is ignored, and updatedInput is logged + warned but not honored." HookOutput.updatedInput is "parsed but not honored — input rewrite is a deferred consistency-design problem," and the reason is a real architectural constraint, not laziness: rewriting a tool call's input after it has been logged breaks the model-visible ⟺ logged invariant unless the rewrite is itself a logged event.
Behavioural drift on execution model. "Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers." dsh chose serial deliberately — it "keeps each hook's hook/invoked/hook/result pair adjacent in the log" — but a hook set tuned for parallel execution is now slower, and a hook accidentally registered twice now runs twice.
Missing safety rails. The Stop hook's consecutive-block cap is not implemented (TODO(stop-loop-guard)), so "an unconditionally blocking hook therefore force-continues every step unless it self-limits." A Claude Code user whose hook relies on the host's cap gets an infinite loop.
Configuration model mismatch. "One process-level configPath is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented."
A permanent chase. The source protocol is a moving target owned by someone else. Every upstream hook point is a new gap.
The design conclusion dsh draws is the honest one, stated in packages/hooks/hooks-claude-code/README.md:
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. The bridge exists only as a compatibility path for the mapped CC command-hook subset; anything bespoke should be a native plugin on the same extension points.
That is the transferable judgement. Adopt a foreign protocol as a migration path, not as your extension architecture. The bridge is the on-ramp; the typed interception points are the road. A harness that makes the foreign protocol its primary surface inherits a permanent fidelity gap and a taxonomy it does not control — and gets no compensating type safety, because a shell-out with JSON on stdin is the weakest possible contract.
How others differ
Claude Code owns the format, so it has no fidelity problem and can evolve the taxonomy at will — at the cost that its hooks run nowhere else. Codex implemented a deliberate subset of the same format, making the same trade dsh did at a smaller scale, and demonstrating that the format is becoming a de facto standard. Git is the long-run precedent worth studying: hook scripts in a known directory with exit-code semantics, essentially unchanged for two decades, and universally implemented by third-party tools. Simple wire protocols outlive rich typed ones. That is an argument for the bridge, and it is why dsh maintains both surfaces rather than picking.
Check yourself
3 questions on M7.4