Files
deepseek-harness/packages/hooks/hooks-codex/README.md
T
Tianyi Cui 8870da4313 fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false
Round-1 Codex review findings on the bridges:

- Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr
  yielded decision 'deny' + reason undefined, and the `&& reason !== undefined`
  guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue
  on any deny; fall back to a generic steering line when there is no reason.
- Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher
  subject, so a real Codex `matcher:"Bash"` never fired against the harness's
  lowercase `bash` tool. Use exec.name in both payload builders (matches the
  matcher subject and the sibling CC bridge). Doc/RFC updated.
- Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to
  treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing
  folded it. runPoint now folds plain stdout into context for those two events,
  gated on the codec's JSON gate so structured stdout is never dumped as prose.
- continue:false is deferred, not honored: the seams have no hard-halt primitive
  yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two
  tests now assert the LOG records the halt request AND that the run is NOT
  actually halted (no longer misleading).
- README concurrency wording: hooks run SERIALLY (deliberate — adjacent
  invoked/result log pairs, order-independent fold), not concurrently. Fixed the
  CC README claim + an RFC note.

Regression guards proven red on the unfixed code, then reverted. The mismatched-
hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00

55 lines
3.5 KiB
Markdown

# @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`](../hook-protocol/README.md); 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 model** — `allow`/`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](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
```ts
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`:
```yaml
- 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) | `block``PreToolDecision.deny` (no `allow`/`ask`) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext → `accept` with context |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). 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.