1
/**2
* Bridge for unmodified Codex command hooks on harness interception points. It3
* supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only4
* matchers, snake_case payloads without a trailing newline, no hook environment5
* or command substitution, and no pre-tool approval or rewrite path; only6
* blocking decisions are honored. Shared execution and parsing live in7
* `dsh-hook-protocol`.8
* @module @deepseek-ai/dsh-hooks-codex9
*/11
// Each dialect bridge keeps its complete dependency list visible at the entry12
// point; a cross-package facade for imports alone would add indirection.13
/* jscpd:ignore-start */14
import { readFileSync } from 'node:fs'15
import type { Context } from '@deepseek-ai/cordis'16
import z from '@deepseek-ai/schemastery'17
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'18
import type {} from '@deepseek-ai/dsh-session-projection'19
import { createUserMessage } from '@deepseek-ai/dsh-llm'20
import type { ContextFormed } from '@deepseek-ai/dsh-llm'21
declare module '@deepseek-ai/dsh-llm' {22
interface MessageSourceMap {23
'hooks-codex': { kind: 'hooks-codex' } & ContextFormed24
}25
}27
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'28
import type { UserMessage } from '@deepseek-ai/dsh-session'29
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'30
import {31
appendHookInvoked,32
appendHookResult,33
createDetachedRuns,34
DEFAULT_HOOK_TIMEOUT_MS,35
DEFAULT_STDERR_SUMMARY_MAX_CHARS,36
matchesMatcher,37
mergeHookOutputs,38
runHook,39
type HookOutput,40
type MatcherGroup,41
type MergedHookOutcome,42
} from '@deepseek-ai/dsh-hook-protocol'43
import { parseCodexConfig, type CodexHookConfig } from './config.ts'44
/* jscpd:ignore-end */46
export const name = 'hooks-codex'47
export const inject = ['shell', 'sessionProjections']49
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */50
export interface Config {51
/**52
* Path to a Codex `hooks.json`. Process-level: read once at load, a relative53
* path resolves against the process launch cwd.54
* TODO(per-session-hook-config): per-session project-local discovery from each55
* `session/new.cwd`.56
*/57
configPath: string58
/** The model name stamped on every payload (Codex includes `model` on each event). */59
model?: string60
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */61
defaultTimeoutMs?: number62
/** Character cap for the `hook/result` event's persisted stderr summary. */63
stderrSummaryMaxChars?: number64
}66
export const Config: z<Config> = z.object({67
configPath: z.string().required(),68
model: z.string().default(''),69
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),70
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),71
})73
let handlerCounter = 074
function nextHandlerId(point: string): string {75
return `codex:${point}:${++handlerCounter}`76
}78
const CONTEXT_SOURCE: MessageSource = { kind: 'hooks-codex' }80
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */81
function assertPositiveInteger(name: string, value: number): void {82
if (!Number.isInteger(value) || value < 1) {83
throw new Error(`hooks-codex: ${name} must be a positive integer`)84
}85
}87
export function apply(ctx: Context, config: Config): void {88
// Validate before config parsing so a bad value cannot be hidden by its early return.89
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS90
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)91
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS92
let parsed: CodexHookConfig = {}93
try {94
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))95
const result = parseCodexConfig(raw)96
parsed = result.config97
for (const s of result.skipped) {98
ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`)99
}100
} catch (error: unknown) {101
ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)102
return103
}105
const model = config.model ?? ''107
// SessionStart is the one emit-shaped (detached) point Codex has: track its108
// run chains so disposal aborts a still-running hook process and drains the109
// continuation (docs/defensive-patterns.md: dispose must reach quiescence).110
const detached = createDetachedRuns()111
ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs')113
/**114
* Run and fold one configured Codex hook point.115
*116
* A supplied turn records the hook invocation/result pair inside that open turn.117
* Detached lifecycle points omit it.118
*/119
async function runPoint(120
point: string,121
matchQuery: string,122
payload: unknown,123
opts: {124
agent?: Agent125
turn?: number126
readonly signal: AbortSignal127
plainStdoutAsContext?: boolean128
},129
): Promise<MergedHookOutcome> {130
const groups: MatcherGroup[] = parsed[point] ?? []131
const outputs: HookOutput[] = []132
// Run hooks in the agent's session workspace so relative paths address the133
// user's project rather than the server launch directory.134
const workdir = opts.agent?.session.header.cwd135
for (const group of groups) {136
// Codex always interprets matchers as regexes; it has no literal fast path.137
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue138
for (const hook of group.hooks) {139
const handlerId = nextHandlerId(point)140
const session = opts.agent?.session141
if (session && opts.turn !== undefined) {142
appendHookInvoked(session, {143
turn: opts.turn, point, dialect: 'codex', handlerId,144
...group.matcher !== undefined ? { matcher: group.matcher } : {},145
})146
}147
const { output, durationMs } = await runHook(ctx.shell, hook, {148
payload,149
defaultTimeoutMs,150
...workdir !== undefined ? { cwd: workdir } : {},151
signal: opts.signal,152
trailingNewline: false, // Codex writes stdin without a trailing newline.153
// Discard a `hookSpecificOutput` block naming a different event.154
expectedEventName: point,155
}, () => performance.now())156
// Clean plain stdout becomes context only when no structured context157
// exists; nonzero output and raw JSON never leak as prose.158
if (opts.plainStdoutAsContext === true && output.exitCode === 0159
&& output.additionalContext === undefined160
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {161
output.additionalContext = output.stdout162
}163
outputs.push(output)164
// Execution and decision mapping remain in each bridge so dialect165
// differences stay explicit at their owning extension point.166
/* jscpd:ignore-start */167
if (output.systemMessage !== undefined) {168
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)169
}170
if (session && opts.turn !== undefined) {171
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })172
}173
}174
}175
return mergeHookOutputs(outputs)176
}178
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.180
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {181
if (merged.additionalContext.length === 0) return undefined182
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))183
return createUserMessage({ content, source: CONTEXT_SOURCE })184
}186
/** Prepend one context without flattening source fields or other downstream metadata. */187
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {188
return [ours, ...theirs ?? []]189
}191
ctx.on('agent/created', async ({ agent, source, signal }) => {192
const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])193
const run = runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: ownerSignal })194
.then((merged) => {195
const context = contextFrom(merged)196
if (context) agent.inject(context)197
})198
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })199
detached.track(run)200
await run201
/* jscpd:ignore-end */202
})204
// UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask.205
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {206
if (messages.length === 0) return next()207
const payload = {208
...base(agent, 'UserPromptSubmit', model),209
turn_id: String(turn),210
prompt: blocksToText(messages.flatMap(message => message.content)),211
}212
const merged = await runPoint('UserPromptSubmit', '', payload, {213
agent, turn, plainStdoutAsContext: true, signal,214
})215
/* jscpd:ignore-start */216
if (merged.decision === 'deny') {217
return { kind: 'reject' }218
}219
// Context alone is not a veto: DELEGATE so a later pre-step listener can220
// still reject/rewrite, then fold our context onto its decision.221
const downstream = await next()222
const ours = contextFrom(merged)223
if (!ours || downstream.kind !== 'enter') return downstream224
return {225
...downstream,226
messages: [...downstream.messages, ours],227
}228
})230
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).231
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {232
const turn = lastTurn(ctx, exec.agent)233
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })234
/* jscpd:ignore-end */235
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }236
return next()237
})239
// PostToolUse → PostToolDecision (block with feedback, or attach context).240
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {241
const turn = lastTurn(ctx, exec.agent)242
/* jscpd:ignore-start */243
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })244
const context = contextFrom(merged)245
if (merged.decision === 'deny') {246
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }247
}248
// Context alone is not a veto: DELEGATE, then fold our context onto the249
// downstream decision (a downstream block carries it too).250
const downstream = await next()251
if (!context) return downstream252
if (downstream.kind === 'block') {253
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }254
}255
return {256
...downstream,257
additionalContexts: prependContext(context, downstream.additionalContexts),258
}259
})261
// A blocking Stop hook steers at the stopping boundary, which makes the262
// machine observe pending input and run another step.263
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can264
// avoid continuing the same turn indefinitely. It is always false here, so an265
// unconditionally blocking hook force-continues every step until it self-limits.266
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {267
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })268
/* jscpd:ignore-end */269
if (merged.decision === 'deny') {270
// A blocking Stop hook forces continuation; a block with no reason (exit 2,271
// empty stderr) still forces it — fall back to a generic steering line272
// rather than letting the turn stop.273
const text = merged.reason ?? 'continue: blocked by Stop hook'274
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE }))275
}276
})277
}279
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on280
// turn-scoped events. ---282
// These small payload helpers intentionally remain next to the dialect shape;283
// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.284
/* jscpd:ignore-start */285
function lastTurn(ctx: Context, agent: Agent | undefined): number {286
if (!agent) return 0287
/* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */288
return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0289
}291
function blocksToText(content: ContentBlock[]): string {292
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')293
}294
/* jscpd:ignore-end */296
/** Base fields on every Codex payload (no turn_id). */297
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {298
return {299
session_id: agent?.session.header.id ?? '',300
// The persistence seam exposes no artifact path; the field stays null301
// (a durable consumer gap recorded in this package's README).302
transcript_path: null,303
cwd: agent?.session.header.cwd ?? process.cwd(),304
hook_event_name: event,305
model,306
permission_mode: 'default',307
}308
}310
/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */311
function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {312
return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) }313
}315
/** Extract a `command` string from a tool call's parsed arguments, else ''. */316
function commandOf(args: unknown): string {317
if (typeof args === 'object' && args !== null && 'command' in args) {318
const command: unknown = args.command319
if (typeof command === 'string') return command320
}321
return ''322
}324
function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record<string, unknown> {325
// `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);326
// a hardcoded constant would disagree with what the matcher tests and make a327
// config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`328
// shape (its shell payload), derived from the call's `command` arg when present.329
return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }330
}332
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {333
return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }334
}