Clarify or fire Stop hook when a turn is interrupted with Esc

Open 💬 2 comments Opened May 15, 2026 by microHoffman

What variant of Codex are you using?

CLI / TUI

Which version are you using?

codex-cli 0.130.0

What operating system are you using?

Linux Fedora 42, kernel 6.17.8-200.fc42.x86_64

Bug description

When an active Codex turn is interrupted or aborted mid-run, for example by pressing Esc, the Stop hook does not appear to run.

I am not sure whether this is intended behavior. The hooks documentation lists Stop as a supported hook event, and the app-server interrupt docs describe interrupted turns as completing with status interrupted. From an integration point of view, it is unclear whether an interrupted turn should be considered a stop/completion for hook purposes.

This matters for integrations that use hooks as the source of truth for external session state. In https://github.com/njbrake/agent-of-empires/issues/1126 , for example, a Codex Stop hook writes the session status back to idle. If UserPromptSubmit, PreToolUse, or PostToolUse previously marked the session as running, then pressing Esc can leave the external status stuck on Running even though Codex is no longer doing work.

Expected behavior

One of these would be reasonable:

  1. Stop fires when a running turn is interrupted with Esc.
  2. Codex documents that Stop only fires for normally completed turns, not interrupted turns.
  3. Codex exposes a separate hook/event for interrupted or cancelled turns, so integrations can reliably clear running state.

Actual behavior

After interrupting an active turn with Esc, the configured Stop hook does not appear to run. External hook-driven state can remain stuck in a running state until another hook event fires or the session is restarted.

Steps to reproduce

  1. Configure a simple Stop hook in ~/.codex/hooks.json that appends a timestamp to a log file.
  2. Start Codex CLI/TUI.
  3. Submit a prompt that runs long enough to interrupt.
  4. Press Esc while Codex is actively working.
  5. Check the hook log file.

Expected: the Stop hook either runs, or there is a documented separate interrupt/cancel event.

Actual: no Stop hook entry appears to be written for the interrupted turn.

Impact

This makes hook-based lifecycle integrations hard to keep correct. A tool can observe that a turn started, but cannot reliably observe that it ended if the user interrupts it.

If skipping Stop on interrupt is intentional, documenting that explicitly would help. If not intentional, firing Stop or adding an interrupt-specific hook would make the lifecycle contract much clearer.

Related context

Possibly related but not the same failure mode:

  • #21160 reports hooks not firing after rate-limit stops or live hook config edits.

View original on GitHub ↗

2 Comments

zdtango · 2 months ago

Root-cause analysis

run_turn in codex-rs/core/src/session/turn.rs has a sampling loop where run_turn_stop_hooks is only called on the normal completion path (!needs_follow_up, line 325). All five error/abort exit paths break or return without calling it:

| Exit path | How it exits | Stop hook called? |
|---|---|---|
| Normal completion (!needs_follow_up) | break after run_turn_stop_hooks | Yes |
| Err(CodexErr::TurnAborted) (Esc interrupt) | break | No |
| Err(CodexErr::InvalidImageRequest()) | break | No |
| Err(e) (UsageLimitExceeded, API errors) | break | No |
| Mid-turn auto-compact failure | return None | No |
| run_hooks_and_record_inputs blocks mid-loop | break | No |

Minimal fix

A \stop_hooks_ran\ flag tracks whether the in-loop call happened. After the loop, a fallback fires stop hooks if the flag is still false. The mid-turn compact failure is changed from \return None\ to \break\ so it also flows through the fallback. The post-loop call passes \stop_hook_active: false\ and ignores the \StopOutcome\ since the turn is already over.

The diff is 4 additions and 1 line change in \turn.rs\, plus an integration test: zdtango/codex@64436198b

For the abort path specifically: the hooks run inside the task before \done.notify_waiters()\, within the existing graceful interruption timeout. This is best-effort, consistent with the abort shutdown model.

AlexisLeite · 1 month ago

There is an additional limitation beyond whether Stop fires on interruption:

The Stop hook payload does not expose the final turn status, so integrations cannot reliably distinguish a successfully completed turn from other ways in which execution may stop.

The App Server protocol already exposes this distinction through terminal turn statuses such as:

completed
interrupted
failed

This information should also be available to hook-based integrations.

A possible solution would be either:

Add a status field to the Stop hook payload:
{
"turn_id": "...",
"status": "completed",
"last_assistant_message": "..."
}
Expose separate lifecycle hooks such as:
TurnCompleted
TurnInterrupted
TurnFailed

This is particularly important for notifications. With the current hook contract, an integration cannot reliably emit a “Task completed” notification only when the turn actually completed successfully.

Using the session transcript as a workaround is not appropriate because its format is not documented as a stable hook API. Likewise, using App Server events requires the integration to manage the conversation through App Server; there is no documented way to attach an external observer to an already-open IDE or desktop conversation.

Therefore, current IDE/Desktop sessions have no reliable hook-level mechanism for detecting successful task completion.