Files
deepseek-harness/packages/hooks/hooks-codex/README.md
Tianyi Cui 8adcbceeed feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F)
The two bridge plugins that run a user's existing Claude Code / Codex hook
config on the harness's typed interception seams, built on the shared
dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power
tool: anything it does a native cordis plugin does more powerfully — the
bridge exists only to run UNMODIFIED external hooks.

- dsh-hooks-claude: CC dialect. Seven hook points (SessionStart,
  UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart,
  SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/
  ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher.
- dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points,
  always-regex matcher, snake_case payloads (turn_id/model, no trailing
  newline), no env/substitution, block-only decisions.

Both map the neutral merged outcome onto the seam's typed Decision and stamp
an explicit {kind:'plugin'} source on injected context (so it is never
mislabeled as a user prompt). Config parse-failure is contained; only command
hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop
loop-guard is deferred (TODO).

Tests: per-file 100% — config-parse unit branches + per-seam mappings
end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted
mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot
scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt
end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a
with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash
(verified on disk). The snapshot normalizer now scrubs hook/result.durationMs.

RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:23:49 +08:00

3.5 KiB

@deepseek-ai/dsh-hooks-codex

A cordis plugin that runs a user's existing Codex hooks.json on the harness's canonical interception seams. The Codex dialect half of the hooks subsystem. The dialect-agnostic primitives come from @deepseek-ai/dsh-hook-protocol; this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.

Codex's hook protocol is a deliberate subset of Claude Code's (same hooks.json shape):

  • Five hook points only: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop — no subagent / notification / compaction hooks.
  • Regex-only matchers (no literal fast path; the matcher is always an unanchored regex).
  • snake_case stdin payloads with turn_id/model extras, written without a trailing newline.
  • No env vars and no command substitution (a literal ${…} in a command survives verbatim).
  • A block-only decision modelallow/ask are not honored; a hook can only block, never pre-approve.

A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see the interception-seams RFC).

Config

import type { Config } from '@deepseek-ai/dsh-hooks-codex'
const config: Config = {
  configPath: '/path/to/.codex/hooks.json', // required
  model: 'deepseek-v4',                      // optional: stamped on every payload (Codex includes `model`)
  defaultTimeoutMs: 600_000,                 // optional: per-hook timeout when a hook sets none
}

In a cordis.yml:

- dsh-hooks-codex:
    configPath: ./.codex/hooks.json
    model: deepseek-v4

The config is parsed once at load; a read/parse failure is contained (logs + registers nothing). Only sync type: 'command' hooks run — a non-command or async: true hook is parsed-and-skipped with a warning. A hook accepts timeout or the timeoutSec alias. Events outside the five Codex points are dropped at parse.

Hook points → seam Decisions

Codex hook Harness seam Mapping
SessionStart agent/session-start (emit) a plain-stdout hook's output → additionalContext → agent.inject()
UserPromptSubmit agent/prompt-submit (waterfall) block (exit 2) → PromptDecision.block; additionalContext → allow with context
PreToolUse tools/pre-execute (waterfall) blockPreToolDecision.deny (no allow/ask)
PostToolUse tools/post-execute (waterfall) blockblock with feedback; additionalContext → accept with context
Stop agent/turn-continuation (waterfall) a blocking Stop hook forces continue with the reason as next-step steering

Codex hardcodes a tool call's tool_name to "Bash" and tool_input to { command } (extracted from the call's arguments, or '' when absent). The matcher subject is the tool name (PreToolUse/PostToolUse) or the session source (SessionStart); UserPromptSubmit/Stop ignore matchers.

Context source

Injected context carries an explicit { kind: 'plugin', plugin: 'hooks-codex' } source (agent.inject() would otherwise default it to { kind: 'user' }).

Deferred

Stop loop-guard (TODO(stop-loop-guard)): as in CC, a Stop hook that unconditionally blocks would force-continue every step (stop_hook_active is always false here); the loop-guard is deferred. A hook author must self-limit until it lands.