Know when a coding agent needs you: hooks, not watching the terminal
Claude Code and Codex run your commands when they start work, ask for approval and finish. Map them to four states and stop checking terminals.
With three agents running, most of the waiting is yours: an agent asked for approval ten minutes ago and you were looking at another tab. The reliable way to know is to let the agent tell you. Claude Code and Codex both run a command of your choice at fixed points in a session, with a JSON description of the event on stdin. A handful of those points are enough to know whether each agent is working, waiting for you, or done.
Why not read the terminal?
The obvious approach is to watch each agent's output for something that looks like a question. It breaks in ordinary ways. Both CLIs are full-screen terminal apps that redraw regions of the screen with escape sequences, so the text you match may never appear as a clean line. The wording changes between releases. A spinner looks the same whether the model is thinking or the network has stalled. And the agent can print the same phrase while quoting a file.
Hooks are the documented interface. Each event has a name, a schema and a place in the lifecycle, and both vendors publish them.
Four states, and the events that set them
| State | Claude Code | Codex |
|---|---|---|
| Working | UserPromptSubmit, PostToolUse | UserPromptSubmit, PostToolUse |
| Needs input | PermissionRequest; Notification for sandbox network prompts, forms and URL requests | PermissionRequest |
| Done | Stop | Stop |
| Gone | SessionEnd | SessionEnd |
Stop means the turn ended, not that the work is right. "Done" is a prompt to go and read what the agent did.
Why PostToolUse and not PreToolUse
The order of events around a tool call matters. PreToolUse runs before the permission check, PermissionRequest runs as the approval prompt is about to appear, and PostToolUse runs after the tool has finished. If "working" hangs off PreToolUse, every approval flips the state to working and then straight back to needs input. Hang it off PostToolUse instead, and the state returns to working only once you have answered and the tool has run.
PermissionRequest or Notification?
Claude Code has two ways to tell you it is waiting, with different timing:
PermissionRequestfires the moment Claude asks to use a tool.Notificationwith thepermission_prompttype fires only after the prompt has waited about six seconds, and each keystroke in that terminal restarts the timer.idle_promptfires about 60 seconds after Claude finishes, if you have not typed since.
The delays are deliberate. Notifications share their timing with Claude Code's own desktop alerts, so they reach you when you look away. For a status display you want the immediate signal, so use PermissionRequest. Keep Notification for the cases that have no other event. Claude Code does not run PermissionRequest hooks for a sandboxed command's network request, so the permission_prompt notification is the only signal for that prompt, and it arrives after the delay above (in terminal sessions this needs Claude Code 2.1.246 or later). The same goes for elicitation_dialog and elicitation_url_dialog, when a tool asks you to fill in a form or open a URL.
Codex's PermissionRequest fires when it is about to ask for approval, such as a shell escalation. It does not run for commands that need no approval.
The hook script
One small script serves both CLIs. It writes one line per session (state, time, folder) to a file named after the session id, and removes the file when the session ends. It needs jq and nothing else. Save it as agent-state on your PATH and make it executable.
#!/bin/sh
# agent-state <state>: record what one agent session is doing.
# Claude Code and Codex hooks call it with the event's JSON on stdin.
state=$1
input=$(cat)
id=$(printf '%s' "$input" | jq -r '.session_id')
cwd=$(printf '%s' "$input" | jq -r '.cwd')
dir="${XDG_STATE_HOME:-$HOME/.local/state}/agent-state"
mkdir -p "$dir"
if [ "$state" = ended ]; then
rm -f "$dir/$id"
else
printf '%s\t%s\t%s\n' "$state" "$(date +%H:%M)" "$cwd" > "$dir/$id"
fi
# Codex wants JSON from a Stop hook; an empty object decides nothing anywhere.
echo '{}'The script ends by printing {}. Codex expects JSON on stdout from a Stop hook that exits 0, and treats plain text there as invalid. Claude Code adds plain-text stdout from a UserPromptSubmit hook to the model's context. An empty object is valid JSON for both and asks for nothing.
Wiring it into Claude Code
In ~/.claude/settings.json, so it applies to every project:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "agent-state working" }] }
],
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": "agent-state working" }] }
],
"PermissionRequest": [
{ "hooks": [{ "type": "command", "command": "agent-state needs-input" }] }
],
"Notification": [
{
"matcher": "permission_prompt|elicitation_dialog|elicitation_url_dialog",
"hooks": [{ "type": "command", "command": "agent-state needs-input" }]
}
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "agent-state done" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "agent-state ended" }] }
]
}
}Wiring it into Codex
In ~/.codex/hooks.json. Codex asks you to review and trust a new or changed hook before it runs it; open /hooks in the CLI to do that, or the hooks are skipped.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "agent-state working" }] }
],
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": "agent-state working" }] }
],
"PermissionRequest": [
{ "hooks": [{ "type": "command", "command": "agent-state needs-input" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "agent-state done" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "agent-state ended" }] }
]
}
}Reading the states
With three sessions running, the state folder reads like this:
$ sort "${XDG_STATE_HOME:-$HOME/.local/state}"/agent-state/* | column -t
done 09:41 /work/app-docs
needs-input 09:44 /work/app-auth
working 09:44 /work/app-billingPut that in a status bar, a watch loop or a tmux status line. The useful property is that the agents' own hooks write the files, so the view is right even for an agent in a tab you have not opened all morning.
Four ways to get this wrong
- Exiting with code 2 from a Stop hook. In both CLIs that tells the agent to keep going, with your stderr as the reason. A status hook should always exit 0.
- Slow hooks. Codex starts every matching hook for an event at the same time, and gives a hook 600 seconds by default before giving up on it.
PostToolUsefires on every tool call, so keep the script to a few milliseconds. - Writing to the terminal. Claude Code runs hooks without a controlling terminal, so writing to
/dev/ttyfails. For a desktop alert, return aterminalSequencefield in the hook's JSON, an allowlisted escape sequence such as OSC 9 or OSC 777, and Claude Code writes it for you. Codex has its own:tui.notifications, withtui.notification_methodset toosc9orbel. - Trusting session ids across subagents. Codex passes the parent's session id to subagent hooks, so a subagent that asks for approval shows up as its parent needing input. That is usually what you want, but it means one file can stand for several agents.
Where this leaves you
The same four states work for any agent that exposes lifecycle events, and the mapping above is a few lines of JSON per CLI. This is also how Tenslo Code knows which agent needs you: it reads the agents' hooks and never parses their terminal output.
Sources
- Hooks reference, Claude Code docs
- Hooks, Codex docs
- Advanced configuration: notifications, Codex docs