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.
260 lines
12 KiB
TypeScript
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) }
|
|
}
|