The hooks subsystem lets external CLI agents — Claude Code and Codex — participate in dsh's agent loop through their native command-hook mechanisms. A bridge plugin reads the dialect's hooks.json matcher groups, executes matched hooks through ctx.shell, decodes their exit-code/stdout contract into a neutral shape, merges the results most-restrictively, and records everything as durable hook/* session events.
| Package | Role |
|---|---|
@deepseek-ai/dsh-hook-protocol | Dialect-neutral protocol library (NOT a plugin) |
@deepseek-ai/dsh-hooks-claude-code | Claude Code hook bridge plugin |
@deepseek-ai/dsh-hooks-codex | Codex hook bridge plugin |
The shared protocol library
packages/hooks/hook-protocol is the shared core of the Claude Code / Codex hook wire protocol. It is not a Cordis plugin — it registers nothing and injects nothing. It is a library of dialect-neutral primitives the two bridge plugins import so neither re-implements the identical halves of the protocol. 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 — so the genuinely shared parts live here and each bridge owns only what differs.
From the README's split table:
| Concern | Here (dsh-hook-protocol) | The bridge |
|---|---|---|
| Matcher validation + test | matcherDiagnostic(pattern, mode), matchesMatcher(pattern, query, mode) | picks its mode (claude-code = literal-or-regex, codex = always regex), rejects config groups carrying a diagnostic |
| Run a hook | runHook(bash, hook, opts, now) — stdin payload + env via ctx.shell, decode | builds the per-event stdin payload + the dialect's env |
| Decode output | parseHookOutput(exit, stdout, stderr) → neutral HookOutput | maps HookOutput onto an extension-point-specific typed Decision |
| Merge N hooks | mergeHookOutputs(outputs) → most-restrictive MergedHookOutcome | — |
| Durable record | appendHookInvoked / appendHookResult (hook/* session events) | calls them around each invocation |
| Detached runs | createDetachedRuns() — track fire-and-forget chains; drain() aborts then awaits | passes signal to each detached runHook, registers drain as its effect disposer |
The library lives in packages/hooks/hook-protocol/src/ — matcher.ts (pattern diagnostics and matching), runner.ts (hook execution via a ShellExecutor-shaped bash argument), merge.ts (most-restrictive merging), events.ts (the hook/* event shapes), codec.ts, detached.ts (quiescence tracking), and types.ts.
The bridge plugins
dsh-hooks-claude-code and dsh-hooks-codex are thin adapters: each owns its dialect's config (src/config.ts), the per-event stdin payload and environment, and the mapping from neutral HookOutput to the typed decision its extension point expects. Matcher mode differs by design — Claude Code treats a matcher as literal-or-regex, Codex always as regex — and a config group carrying a diagnostic is rejected by whichever bridge owns it.
Configuration (both bridges, from src/config.ts) drives: which hook events are enabled, the matcher groups, and the command lines executed. Hook execution goes through ctx.shell, so a hook command inherits the sandbox/policy world of the session — including whichever shell provider is composed (see Shell & Terminal).
How a hook decision flows
agent loop reaches an extension point (e.g. PreToolUse / Stop)
│
▼
bridge plugin builds stdin payload + env for its dialect
│
▼
runHook(bash, hook, opts) ──► ctx.shell executes the hook command
│
▼
parseHookOutput(exit, stdout, stderr) ──► neutral HookOutput
│
▼
bridge maps HookOutput ──► typed Decision (allow / deny / modify ...)
│
▼
mergeHookOutputs across hooks ──► most-restrictive MergedHookOutcome
│
▼
appendHookInvoked / appendHookResult ──► durable hook/* session eventsDetached (fire-and-forget) runs are tracked by createDetachedRuns() so process teardown can drain() them — abort, then await — instead of leaking chains.
Why this matters for dsh
The hooks subsystem is one of the ways dsh interoperates with the broader agent ecosystem: instead of reimplementing Claude Code or Codex, a dsh composition can host their hook protocol and let their command hooks act on dsh sessions. It is the mirror image of the subagent backends (subagent-claude-code, subagent-codex) that run those CLIs as agents (see Subagents) — there the CLI is the worker, here its hook contract is the extension point.
Key source files
| Repo-relative path | What it provides |
|---|---|
packages/hooks/hook-protocol/src/matcher.ts | Matcher diagnostics + matching |
packages/hooks/hook-protocol/src/runner.ts | runHook via ctx.shell |
packages/hooks/hook-protocol/src/merge.ts | mergeHookOutputs |
packages/hooks/hook-protocol/src/events.ts | hook/* session events |
packages/hooks/hooks-claude-code/src/config.ts | Claude Code bridge config |
packages/hooks/hooks-codex/src/config.ts | Codex bridge config |
Further reading
- Subagents — the other direction: running Claude Code / Codex as subagents
- Shell & Terminal —
ctx.shell, the execution world hooks run in - Session Management — where
hook/*events land in the log - Repo:
packages/hooks/hook-protocol/README.md,packages/hooks/hooks-claude-code/README.md,packages/hooks/hooks-codex/README.md