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.
Not to be confused with the webhook subsystem
The packages/hooks/* family above is not the same thing as the newer webhook subsystem (packages/webhook/*): ctx.webhookRuntime receives authenticated external provider events (e.g. signed GitHub deliveries) and runs trusted programmatic rules that can create ordinary root Sessions inside Web Workspaces — a fire-and-forget, event-to-Session bridge (see Webhook Events). The hooks bridges, by contrast, are outbound command-hook contracts that external CLIs run inside dsh's own agent loop. Different packages, different ctx seams, different direction of control.
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