DEV Community

Cover image for How an IDE Knows Your Coding Agent Is Waiting on You: Hooks, Bells and Guesses
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

How an IDE Knows Your Coding Agent Is Waiting on You: Hooks, Bells and Guesses


Two terminals, two agents, both quiet for four minutes. One is reading forty files before it writes a line. The other has been sitting on a permission prompt since minute one, waiting for a key you haven't pressed. From another window, or from across the room, they look exactly the same.

Nothing outside the agent can tell those two apart for certain. The agent has to say which one it is, on purpose. Coding agents have a few ways to do that today, and they differ in how much they tell you. A hook can say exactly what happened. A terminal bell says "look at me" and nothing more. And when an agent sends nothing, the only thing left is to read the screen and guess.

One script every agent can call

Both Claude Code and Codex can run a command when something happens, so start with one small script they can share. It shows a desktop notification on macOS or Linux and prints nothing. Some Codex hooks read stdout as a decision or reject plain text, so a notifier should stay silent.

#!/bin/sh
# agent-ping: desktop notification on macOS or Linux
# usage: agent-ping "Title" "Message"
exec >/dev/null 2>&1
title=${1:-Agent}
msg=${2:-Needs your attention}
case "$(uname -s)" in
  Darwin)
    osascript -e 'on run argv' \
      -e 'set t to item 1 of argv' \
      -e 'set m to item 2 of argv' \
      -e 'display notification m with title t' \
      -e 'end run' "$title" "$msg"
    ;;
  Linux)
    notify-send "$title" "$msg"
    ;;
esac
Enter fullscreen mode Exit fullscreen mode

Save it as ~/bin/agent-ping, run chmod +x ~/bin/agent-ping, and test it with ~/bin/agent-ping Test Hello before wiring anything. Two traps from Anthropic's hooks guide: on macOS, osascript notifications go through Script Editor, and if Script Editor has no notification permission the command "fails silently". On Linux, notify-send needs a notification daemon, which headless servers, SSH sessions and most containers don't have. The same guide has a PowerShell version for Windows.

Two identical terminal windows side by side, one with a small padlock hidden behind its cursor, the other with a quiet gear turning
A blocked agent and a busy agent look the same from outside. Only the agent knows which one it is.

Claude Code: two hooks, two different questions

Claude Code has 33 hook events. Two of them answer "should I look at this terminal?"

Notification fires "when Claude Code sends a notification," and its matcher filters on the notification type. The two you want, per the hooks guide: permission_prompt fires when Claude needs you to approve a tool use "and the prompt has waited about six seconds", and idle_prompt fires when "Claude finished responding about 60 seconds ago and you haven't typed since." Notification hooks only watch. The reference says they can't block or modify notifications.

Stop fires "when Claude finishes responding", with no delay and no matcher, so it runs on every turn. Its input includes last_assistant_message, the text of Claude's final reply. Add both to ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|elicitation_dialog",
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME/bin/agent-ping\" Claude 'Needs you'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME/bin/agent-ping\" Claude 'Turn done'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The matcher is a |-separated list of exact notification types, so permission_prompt|elicitation_dialog also catches an MCP server opening a form for you, after the same six-second wait.

Notification with permission_prompt means "blocked on you", but about six seconds late. Stop is instant, but it only says the turn ended. Whether Claude finished the job or ended its reply with a question for you is in last_assistant_message, and telling those apart means reading that text. That's already a guess.

These hooks run alongside Claude Code's built-in alerts. By default Claude Code sends a desktop notification only in Ghostty, Kitty and iTerm2, and the terminal guide shows how to switch other terminals to a plain bell with "preferredNotifChannel": "terminal_bell".

Codex: notify for done, a hook for approvals

Codex splits the job across three settings. The oldest is notify: a command Codex runs with "a single JSON argument" whenever it emits a supported event, and the docs say that is "currently only agent-turn-complete".

So notify tells you a turn ended. For approvals, Codex now has hooks, "enabled by default", and PermissionRequest "runs when Codex is about to ask for approval". A PermissionRequest hook can also allow or deny the request, which is exactly what a notifier must not do. The docs cover that: "If no matching hook decides, Codex uses the normal approval flow," and plain text on stdout is ignored. agent-ping prints nothing, so the prompt stays yours to answer.

In ~/.codex/config.toml:

notify = [
  "/bin/sh", "-c",
  "\"$HOME/bin/agent-ping\" Codex 'Turn done'",
]
Enter fullscreen mode Exit fullscreen mode

Codex appends its JSON as one more argument. With sh -c, that argument lands in $0, which the command ignores. In ~/.codex/hooks.json:

{
  "hooks": {
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME/bin/agent-ping\" Codex 'Needs you'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Codex won't run it straight away: "Before a non-managed hook can run, Codex requires you to review and trust the exact hook definition." Run /hooks inside Codex and trust it once.

If you'd rather not write any scripts, the TUI has its own alerts. In the config reference, tui.notifications takes true or a list of event types, such as agent-turn-complete and approval-requested, and fires only while the terminal is unfocused unless you set notification_condition = "always":

[tui]
notifications = ["approval-requested"]
notification_method = "auto"
Enter fullscreen mode Exit fullscreen mode

With auto, Codex "prefers OSC 9 notifications" and "falls back to BEL (\x07) otherwise," which are the same bytes any terminal program can send.

Bells and escape codes: the signal every terminal gets

Before hooks, a program that wanted your attention wrote special bytes to its own output and let the terminal deal with them. Four still matter:

printf '\a'
printf '\033]9;%s\033\\' 'Codex needs you'
printf '\033]777;notify;%s;%s\033\\' 'Codex' 'Needs you'
printf '\033]99;;%s\033\\' 'Codex needs you'
Enter fullscreen mode Exit fullscreen mode

The first is BEL, a single 0x07 byte. It carries no message at all, and plenty of other things ring it too, such as a shell beeping at a failed tab completion. The second is OSC 9, which iTerm2 defines as "Post a notification" with one message string. The third is OSC 777, the rxvt-unicode notify extension with a title and a body; WezTerm supports both 9 and 777, and Ghostty shows them as desktop notifications by default. The fourth is OSC 99, kitty's own protocol, which also accepts the legacy OSC 9.

If you run Claude Code inside tmux, its desktop notifications never reach your outer terminal until you add set -g allow-passthrough on, per the Claude Code terminal guide. A sequence you printf by hand inside tmux also needs tmux's own passthrough wrapper.

Any app hosting a terminal sees these bytes go by, with no setup. The catch is that all they say is "something in this tab wants attention." A bell can't tell "done" from "blocked". An OSC 9 message can, but only if a person reads the text.

A fix is in progress. On October 6, 2026, Mitchell Hashimoto proposed OSC 7501, a Program Status Protocol, because, in his words, "I'd like us all to stop guessing what programs are doing by reading their screens or process trees." A report names its state outright:

ESC ] 7501 ; state=blocked:kind=permission ESC \
Enter fullscreen mode Exit fullscreen mode

The spec is at draft revision 0.4, dated October 10, with states idle, working, done, blocked and error, and kind set to permission, question or auth when blocked. Claude Code's terminal docs already say that from v2.1.295, a terminal that implements it can show whether each session "is working, waiting on you, or done", with nothing to turn on. The spec is still a draft, so check whether your terminal supports it before you count on it.

When the agent says nothing, you read the screen

Some CLI agents send none of this, or a bell and nothing else. A tool that wants to show their state has one input left: the stream of text the agent prints. Mitchell's post names that approach plainly: "guess by reading the screen or the window title and matching it against known patterns."

A screen guess looks something like this toy:

// a guess, not a signal
function guess(lastLines, quietMs) {
  if (/\[y\/n\]|\(y\/n\)/i.test(lastLines)) return "asking?";
  if (quietMs > 30_000) return "idle?";
  return "working?";
}
Enter fullscreen mode Exit fullscreen mode

Every branch can be wrong. A model can sit silent for thirty seconds while it works through a long request. A file the agent prints can contain (y/n). The vendor can reword its prompt in the next release. The question marks in those return values belong in the UI too.

A magnifying glass held over a terminal window full of abstract lines, with a small question-mark-shaped shadow under the lens
Reading the screen is the last resort: it works on any agent and can be wrong about all of them.

Reading the wrong signal as "blocked" bites too. Claude Code's idle_prompt fires when Claude "finished responding about 60 seconds ago and you haven't typed since," per the hooks guide. An agent that finished a minute ago is done. Treat that notification as "blocked on you" and every finished turn becomes a false alarm.

Why Hermes shows one status, and says when it's guessing

I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent's own interface in a real terminal, side by side, so it meets every signal above at once, plus agents that send nothing.

Wire all of the above by hand and one Codex approval could reach you twice: once from the PermissionRequest hook, once as the TUI's OSC 9 banner. Hermes folds what it hears into one status per session, which the README lists as "working, needs approval, asked you, done, error or idle," and the status "says when it is a guess."

Per the same README, Hermes "observes what agents report on purpose and what git shows." It "never answers a permission prompt for you and never types into a terminal on its own," which is the same line agent-ping holds above. ADR 003 in the repo explains why it is terminal first and vendor-neutral.

System notifications fire when an agent is blocked on you or done, never for the session you're already looking at. ⌘I (Ctrl+Shift+I on Windows and Linux) jumps to the agent that has waited longest, and you answer in that agent's own terminal.

When a status says it's a guess, check the terminal yourself. Hermes is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and is at 2.3.3 as of October 13. It is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor.


If this was useful

Agent status is observability on a small scale. You can trust a signal the agent sends on purpose, and you should treat anything inferred from its output as a guess. The LLM Observability Pocket Guide applies the same rule to tracing and evals for LLM apps. To watch several agents at once without wiring hooks by hand, download Hermes IDE, and if it saves you a stalled prompt, star it on GitHub.

LLM Observability Pocket Guide: Picking the Right Tracing & Evals Tools for Your Team

Top comments (0)