I usually have four or five Claude Code sessions open at once, and my problem was never the agents. It was me: a session would sit on a permission prompt for twenty minutes in a tab I wasn't looking at, or two sessions would edit the same file and one would quietly undo the other.

So I built a VS Code / Cursor extension that runs each session as a terminal in a grid and shows, at all times, whether it is working, waiting for a permission or waiting for me. Early on I tried reading the terminal output to guess the state. It was fragile, so everything below comes from Claude Code's hooks instead.

These are the notes I wish I'd had at the start. They were checked against Claude Code 2.1.x; hook behavior changes between versions, so test against yours.

1. Give each session its own hooks with --settings

You don't have to touch ~/.claude/settings.json. Start a session with:

claude --settings /path/to/session-1.json 

Hooks from --settings are added to the user's and the project's hooks, not swapped in for them. So each session can report to your tool, and whatever hooks the user already has keep working.

2. Use command hooks that run curl, with an args array

Each hook in my settings file is a command hook that POSTs the hook's JSON input (which arrives on stdin) to a local server:

{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "/usr/bin/curl", "args": ["--silent", "--output", "/dev/null", "--connect-timeout", "1", "--max-time", "3", "--request", "POST", "--header", "X-My-Token: <per-window token>", "--header", "Content-Type: application/json", "--data-binary", "@-", "http://127.0.0.1:7777/hook/session-1/Stop"], "timeout": 23 }]}] } } 

Passing args as an array means no shell is involved, so the same file works under Git Bash, PowerShell and sh. That saved me a whole class of Windows quoting bugs. I also tried type: "http" hooks; in my testing they did not fire for SessionStart, so I stayed with command hooks.

3. Put the routing in the URL, not the body

The URL says which session and which event: /hook/<session>/<event>. The server never has to parse the body to know who is talking. It only listens on 127.0.0.1 and rejects requests without the per-window token.

4. Be careful what reaches stdout

For some events, SessionStart and UserPromptSubmit among them, the hook's stdout becomes part of Claude's context. Picture this: the editor is closed, something else now owns your port, and its error page gets injected into the prompt.

So hooks that don't make decisions send curl's output to /dev/null (NUL on Windows). Hooks that do make decisions (PreToolUse, PermissionRequest) use --fail, so an error page never reaches Claude. The server answers everything else with an empty 204 right away.

5. Hooks are synchronous, and that is useful

Claude waits for each hook, so events reach your server in the order Claude emits them, and you never have to reorder. The cost is that slow hooks slow Claude down. Keep the connect timeout at a second and the total time at a few seconds for everything except permission decisions.

6. Waiting on PermissionRequest blocks nothing

This one surprised me. While a PermissionRequest hook is waiting, Claude is already showing its own permission dialog in the terminal. So the hook can wait a long time (mine waits up to an hour) for an answer from somewhere else, such as a sidebar inbox or a phone, and the user can still answer in the terminal. Whichever answer comes first wins.

One catch: when the prompt is answered outside the terminal, no hook event arrives until the tool finishes. Mark the session as working yourself, or it looks stuck on "needs permission".

7. Map events to states, and read the Notification type

This is the mapping that ended up working:

Event State
UserPromptSubmit, PreToolUse, PostToolUse working
PermissionRequest, or Notification with permission_prompt needs permission
Notification with elicitation_dialog / agent_needs_input asking a question
Stop your turn
SessionEnd (except reason clear) exited

idle_prompt deserves a note: it arrives about 60 seconds after a finished turn. Use it only as a fallback when you missed the Stop. Otherwise you alert the user a second time about something they already saw.

8. SessionStart fires before the prompt is ready

If your tool types a first prompt into a new session right after SessionStart, the Enter can get swallowed and the text just sits in the input box. No hook tells you "the prompt is ready now."

What works for me is to wait about two seconds after SessionStart. If no UserPromptSubmit follows within a couple of seconds, press Enter again, up to four times. Enter on an empty prompt does nothing, so retrying is safe.

9. Your usage limits only reach the status line

Claude Code passes rate_limits (five_hour and seven_day, each with used_percentage and resets_at) to your status line command on stdin. As far as I can tell, that is the only place they are available locally. Post the status line input to your server the same way as the hooks, and you have live limit data with no API calls.

Two more things about the status line:

  • It is the only place inside the terminal you can draw. Use plain ANSI colors so the user's theme decides how they look, light or dark.
  • Unlike exec-form hooks, the status line command runs through a shell (Git Bash, cmd or PowerShell on Windows), so quote it to mean the same in all of them.

10. Sessions outlive your tool

After a window reload or an extension update, the Claude processes are still running and still posting to the old address. So my server comes back on the same port with the same token, and per-session settings files are deleted only once the process is gone, not when the extension stops.

11. A file-conflict guard from PostToolUse + PreToolUse

To stop two sessions from silently overwriting each other:

  • On PostToolUse of Edit, MultiEdit and Write, record which session wrote which file. Using the post event means only edits that actually ran are counted.
  • On PreToolUse of a write, check whether another live session wrote the same file in the last ten minutes, after this session's own last write. If so, return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "..."}}, and Claude asks the user before writing.

The honest limitation: files changed through Bash are invisible to this. Git worktrees per session solve most of the problem anyway. The guard is for what's left.

All of this ships in Paneherd, the extension I built along the way. It's free for unlimited sessions, notifications and usage limits, and a one-time $2.99 Pro adds a shared permission inbox, the file-conflict guard and approving from your phone. It runs locally, with no telemetry.

If you're building on Claude Code hooks too, I'd like to hear what tripped you up. What did you have to learn the hard way?