Files
deepseek-harness/packages/hooks/hooks-codex/README.md
Tianyi Cui ecb8aa5b8e Add a gated Known Limitations and Deferred Work section to every package README
Every packages/*/* README now carries a canonical '## Known Limitations and
Deferred Work' section: condensed, evidence-backed bullets for consumer-visible
gaps (unimplemented features, platform caveats, MVP cuts) and consciously
postponed work (TODO/FIXME/XXX markers, RFC deferrals still open). The ten
pre-existing ad-hoc variants ('What is NOT here (TODO)', 'Deferred',
'Limitations (MVP)', 'Known limitations (tracked TODOs)', ...) are normalized
into the canonical heading.

A new doc-sync gate, scripts/verify-readme-limitations.ts, enforces the shape:
exactly one limitations-like heading per package README, byte-equal to the
canonical h2, with at least one bullet; near-miss headings fail so variants
cannot creep back. Packages with genuinely nothing to declare (dsh-brand,
dsh-timeout, dsh-subagent-mock, dsh-app-boot) are whitelisted in the script and
must NOT carry the section; whitelist entries are validated against the scanned
package set so a rename fails loud.

Wired into the doc-sync chain (package.json) and the run-gates doc-sync leaf
set; the standing rule lands in packages/AGENTS.md and the adding-a-package
cookbook; decision record in
docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md
(RFC index regenerated).

Also fixes two stale '(deferred)' markers claiming dsh-compact-basic is
unimplemented (the dsh-compact seam README's package table and the seam's
module doc comment).
2026-07-12 01:46:34 +08:00

5.4 KiB
Raw Blame History

@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
  stderrSummaryMaxChars: 500,                // optional: char cap on the hook/result event's persisted stderr summary
}

In a cordis.yml:

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

The config is parsed once at load. configPath is process-level — a relative path resolves against the process launch cwd at load time, not per-session (TODO(per-session-hook-config)). 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; one that sets neither runs under the protocol's reference default (DEFAULT_HOOK_TIMEOUT_MS from dsh-hook-protocol, 10 minutes). Events outside the five Codex points are dropped at parse.

The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's cwd as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.

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-only → delegate via next() then fold context onto the downstream decision
PreToolUse tools/pre-execute (waterfall) blockPreToolDecision.deny (no allow/ask)
PostToolUse tools/post-execute (waterfall) blockblock with feedback; additionalContext-only → delegate via next() then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see the pipeline doc)
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.

SessionStart — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (createDetachedRuns in dsh-hook-protocol).

Context source

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

Known Limitations and Deferred Work

  • 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, and a hook author must self-limit until it lands.
  • systemMessage — a hook's user-facing warning is logged + warned, not surfaced; there is no user-message channel on these seams yet (only model-facing additionalContext).
  • {"continue": false} is recorded, not enforced — the hook/result event records decision stop, but the run is not halted (TODO(hook-continue-false)).
  • SessionStart cannot gate the first turnagent/session-start is a synchronous emit with a detached continuation, so a hook's injected context lands best-effort before turn 1 (TODO(session-start-gating)).
  • Hook config is process-level — one configPath parsed at load for the whole process; per-session discovery of a project-local config is deferred (TODO(per-session-hook-config)).