Files
deepseek-harness/packages/hooks/hooks-codex/src/index.ts
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

260 lines
12 KiB
TypeScript

/**
* `dsh-hooks-codex` — a bridge 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.
*
* Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points
* (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no
* subagent/notification/compaction), regex-only matchers, snake_case stdin
* payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and
* no command substitution, and a block-only decision model (allow/ask are not
* honored — a hook can only block, never pre-approve). The dialect-agnostic
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
* Codex-specific payloads + matcher mode + decision mapping.
*
* @module @deepseek-ai/dsh-hooks-codex
*/
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
appendHookResult,
matchesMatcher,
mergeHookOutputs,
runHook,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
export const name = 'hooks-codex'
export const inject = ['bash']
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
export interface Config {
/** Path to a Codex `hooks.json`. */
configPath: string
/** The model name stamped on every payload (Codex includes `model` on each event). */
model?: string
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
defaultTimeoutMs?: number
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
model: z.string().default(''),
defaultTimeoutMs: z.number().default(600_000),
})
let handlerCounter = 0
function nextHandlerId(point: string): string {
return `codex:${point}:${++handlerCounter}`
}
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' }
function summarize(stderr: string): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > 500 ? t.slice(0, 500) + '…' : t
}
export function apply(ctx: Context, config: Config): void {
let parsed: CodexHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
const result = parseCodexConfig(raw)
parsed = result.config
for (const s of result.skipped) {
ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`)
}
} catch (error: unknown) {
ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
const model = config.model ?? ''
async function runPoint(
point: string,
matchQuery: string,
payload: unknown,
opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean },
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
for (const group of groups) {
// Codex matches with PURE regex (no literal fast path).
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
const session = opts.agent?.session
if (session && opts.turn !== undefined) {
appendHookInvoked(session, {
turn: opts.turn, point, dialect: 'codex', handlerId,
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
payload,
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
}, () => performance.now())
// Codex's SessionStart/UserPromptSubmit treat a clean hook's PLAIN
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
// `output.stdout` but only sets `additionalContext` from a JSON
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
// merge + contextFrom path carry it. Guarded on the codec's own JSON gate
// (stdout starting with `{`) so a structured hook's raw JSON is never
// injected as prose, and it never clobbers an explicit additionalContext.
if (opts.plainStdoutAsContext === true && output.additionalContext === undefined
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
output.additionalContext = output.stdout
}
outputs.push(output)
if (session && opts.turn !== undefined) {
const stderrSummary = summarize(output.stderr)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
}
}
}
return mergeHookOutputs(outputs)
}
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
// a hook's `continue:false`, but no seam below honors it — there is no
// "hard-halt the whole agent" primitive on the interception seams yet. Deferred
// with the loop-guard work; until then a `continue:false` hook keeps its
// per-point effect and the halt request is recorded in `hook/result`, not acted on.
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
}
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
ctx.on('agent/session-start', (agent, source) => {
void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
const context = contextFrom(merged)
if (context) return { kind: 'allow', additionalContext: context }
return next()
})
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
})
// PostToolUse → PostToolDecision (block with feedback, or attach context).
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
}
if (context) return { kind: 'accept', additionalContext: context }
return next()
})
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
// TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would
// force-continue every step (`stop_hook_active` is always false here); the
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
// empty stderr) still forces it — fall back to a generic steering line
// rather than letting the turn stop.
const text = merged.reason ?? 'continue: blocked by Stop hook'
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
}
return next()
})
}
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
// turn-scoped events. ---
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
/* v8 ignore next -- the `: 0` arm is a defensive fallback: when an agent is
present, lastTurn is only called from the mid-turn seams, which always run
inside an open turn, so `last` is always a turn/start here. */
return last?.type === 'turn/start' ? last.data.turn : 0
}
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
/** Base fields on every Codex payload (no turn_id). */
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return {
session_id: agent?.session.header.id ?? '',
transcript_path: null,
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
model,
permission_mode: 'default',
}
}
/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
function turnBase(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
}
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
function commandOf(args: unknown): string {
if (typeof args === 'object' && args !== null && 'command' in args) {
const command: unknown = args.command
if (typeof command === 'string') return command
}
return ''
}
function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> {
// `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
// a hardcoded constant would disagree with what the matcher tests and make a
// config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
// shape (its shell payload), derived from the call's `command` arg when present.
return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
}
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}