openai / openai/codex

Cancelled Code Mode nested tool calls can leave PreToolUse hooks permanently “Running” in TUI

Open
#42,511 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug CLI hooks tool-calls TUI
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

What version of Codex CLI is running?

codex-cli 0.152.1

What subscription do you have?

Pro

Which model were you using?

gpt-5.6-luna

What platform is your computer?

Linux 7.0.11-76070011-generic x86_64 x86_64 (Pop!_OS 24.04)

What terminal emulator and version are you using (if applicable)?

TUI with TERM=xterm-256color.

Codex doctor report

codex doctor --json reports overallStatus: ok. Relevant details: npm installation, Linux x86_64, hooks and code-mode host enabled, ephemeral app-server mode.

What issue are you seeing?

Cancelling a nested Code Mode tool call can emit hook/started for a synchronous PreToolUse hook without ever emitting the matching hook/completed event. The TUI retains the unmatched run and can continue displaying:

Running N PreToolUse hooks: checking command policy

after the hook subprocesses have already exited. The displayed count represents stale TUI lifecycle records, not live hook processes.

In one captured batch of five nested calls, app-server tracing contained five hook/started notifications but only four hook/completed notifications. Across the full burst it contained 34 starts and 33 completions. The missing completion occurred when one invocation was denied by the hook and Promise.all rejected, cancelling a sibling invocation. The hook command itself normally completes in about 20 ms, and no hook child processes remained alive.

What steps can reproduce the bug?
  1. Configure a synchronous PreToolUse command hook for Bash with a visible statusMessage. Have it finish immediately and deny commands containing a pipe:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Shell|exec_command",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /absolute/path/to/guard.py",
            "statusMessage": "checking command policy"
          }
        ]
      }
    ]
  }
}
  1. From Code Mode, launch several nested shell calls concurrently, with at least one command that the hook denies:
const commands = [
  "printf one",
  "printf two",
  "printf denied | cat",
  "printf four",
  "printf five",
];
await Promise.all(
  commands.map((cmd) => tools.exec_command({ cmd }))
);
  1. The denied promise causes the Code Mode cell to fail/cancel outstanding nested calls.
  2. Compare app-server hook/started and hook/completed notifications, or observe the TUI hook activity after the commands are gone.

The race can require repeating the batch because the fast sibling hooks may complete before cancellation reaches them.

What is the expected behavior?

Every emitted HookStarted must have a terminal lifecycle event, including when its owning tool call is cancelled. Alternatively, cancellation must explicitly remove the corresponding active TUI run by tool-use ID. No hook should remain displayed as running after its subprocess has exited or been killed.

Additional information

The source appears to have a cancellation hole:

  • run_pre_tool_use_hooks emits HookStarted, awaits the hook, and only then emits HookCompleted, without a cancellation-safe finalizer.
  • ToolCallRuntime::handle_tool_call_with_source aborts the dispatch task when a nested invocation's cancellation token fires. If that task is inside run_pre_tool_use_hooks, the code after .await never emits the completion.
  • HookCell::complete_run removes/transitions an active run only when a completion with the exact run ID arrives.
  • Turn-finalization clears orphaned hook rows, but that does not help while the outer turn continues after a failed Code Mode cell.

I could not find a test covering cancellation between HookStarted and HookCompleted. The same abort path is still present on current main; the newer TUI hook-activity rendering changes do not appear to close the lifecycle gap.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with codex-rs/core/src/hook_runtime.rs and codex-rs/core/src/tools/parallel.rs to trace cancellation between HookStarted and HookCompleted, then inspect codex-rs/tui/src/history_cell/hook_cell.rs for run cleanup. Reproduce with concurrent nested Code Mode calls and a denying PreToolUse hook, and add coverage for cancellation during hook execution. Done means every started hook reaches a terminal lifecycle state or is explicitly removed from the active TUI runs.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
cli, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.