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.
This commit is contained in:
Tianyi Cui
2026-07-01 10:48:23 +08:00
parent 8adcbceeed
commit 8870da4313
7 changed files with 174 additions and 20 deletions

View File

@@ -161,6 +161,14 @@ export function apply(ctx: Context, config: Config): void {
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 (a
// Decision can block/deny/steer a single point, not stop the run). Honoring it
// needs that primitive; deferred with the loop-guard work. Until then a
// `continue:false` hook still has its per-point effect (its decision/context),
// and the halt request is recorded in the `hook/result` log but not acted on.
/** Build a HookContext from accumulated additionalContext strings, or undefined when none. */
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
@@ -224,9 +232,13 @@ export function apply(ctx: Context, config: Config): void {
// step — a hook author must self-limit until the guard lands. ---
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
if (merged.decision === 'deny' && merged.reason !== undefined) {
// A blocking Stop hook forces continuation, feeding its reason as next-step steering.
return { action: 'continue', reason: { content: [{ type: 'text', text: merged.reason }], source: PLUGIN_SOURCE } }
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation. It carries its reason as
// next-step steering; a blocking hook that emitted no reason (exit 2, empty
// stderr) still forces the turn to continue — the block is what matters, so
// 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()
})