Show a Claude Code agent's status in the terminal tab title — so you can glance across several windows and see which is 🟢 working, 🟡 waiting for you, or 🔴 blocked, without focusing each one.
🟢 api-gateway: writing migration <- working
🟡 api-gateway: your move <- idle, waiting for you
🔴 api-gateway: which db to migrate? <- blocked, needs an answer
Pure Python 3.8+ standard library. No dependencies, no daemon manager, no config file to edit.
Getting a title onto a Claude Code tab on Windows is harder than it looks. Three things fight you:
- Tool stdout is piped, not on the terminal. Escape sequences printed by a Bash/PowerShell tool call are captured, never reaching the terminal. (This is also why the model can't just print an OSC title.)
- Hooks have no controlling terminal, and
terminalSequenceloses the race. Hooks can return aterminalSequencefield, but they fire once per event while the harness re-paints the title continuously (the braille progress spinner per thinking-frame, a glyph on completion). Any one-shot write is overwritten on the next frame. - A manually-renamed Windows Terminal tab overrides EVERYTHING. If you've right-click →
Rename Tab'd a tab, Windows Terminal pins that name and silently ignores every programmatic
title change — OSC, SetConsoleTitle,
terminalSequence, all of it. (This cost us an evening. If nothing you do changes the title, check for a manual tab name first.)
The design that actually works splits the two jobs:
hook.py (fires on SessionStart/UserPromptSubmit/Stop/SessionEnd; NO console needed)
-> writes state to ~/.claude/window-status/<key>.json
title_daemon.py (background loop, launched from a shell that shares Claude's console)
-> reads that file every ~400ms and re-asserts the tab title via SetConsoleTitleW
- Hooks manage state, because they can write a file even without a terminal.
- The daemon renders the title, because only a process attached to Claude's console can set it, and only continuous re-assertion beats the harness's animation. While the agent is working the title does a blinkenlights dance between the harness spinner and ours (harmless — nobody watches a working window); when the agent goes idle, the harness stops painting and the daemon holds the title — exactly the "which window needs me?" moment.
- Console matters: the daemon inherits the console of whatever launches it, so launch it from a
shell attached to Claude's terminal (
launch.ps1/launch.shhandle it). If the title never updates, relaunch from the session's own shell.
blinkenlights (n.) — from the mock-German hacker sign "ACHTUNG! ALLES LOOKENSPEEPERS!" about blinking console lights. Here: the tab title flipping back and forth between the harness's spinner and the daemon's status while work is in progress. It settles the moment the agent idles.
SetConsoleTitleW is a wide/Unicode API, so emoji render fine. On POSIX the daemon writes
ESC]1;…BEL + ESC]2;…BEL to /dev/tty instead.
| state | glyph | set by |
|---|---|---|
working |
🟢 | UserPromptSubmit hook; status_set working |
awaiting |
🟡 | Stop hook (idle); status_set awaiting |
blocked |
🔴 | agent: status_set blocked "why" |
done |
✅ | agent: status_set done |
Hooks only flip working↔awaiting; they never stomp a manual blocked/done.
Requires Python 3.8+ and Claude Code. Clone anywhere — the checkout location is the install location.
git clone https://github.com/postmaxin/claude-window-status
cd claude-window-status
python install.py # merge hooks into ~/.claude/settings.json + install the skill
python install.py --dry-run # show what would change, write nothing
python install.py --statusline # also install the in-window status line (optional)
python install.py --settings ./.claude/settings.json # scope it to one project instead
python install.py --uninstall # remove our hooks + the skill
Flags describe the desired end state, so re-running install.py without --statusline takes the
status line back out again.
No paths to hand-edit: the installer derives them from this checkout and from sys.executable
(which also sidesteps python vs python3). It's idempotent — re-running replaces our entries
rather than duplicating them — preserves the rest of your settings, and backs the file up first.
Then restart Claude Code (hooks and skills load at session start) and launch the daemon:
.\launch.ps1 # Windows (PowerShell)
./launch.sh # macOS / Linux
Auto-launch from a hook is NOT possible (hooks have no controlling terminal), so the agent starts it per session — the SessionStart hook injects a reminder telling it to — or you run it yourself. Re-running a launcher is safe: a fresh daemon claims the console and any older daemon in that same console stands down. Daemons in other windows are left alone, so the same project open in two terminals works.
Set state/phase anytime (writes the status file; the daemon picks it up within ~400ms). Run by
absolute path, no cd, so it keys the project cwd:
python /path/to/status_set.py working "writing migration"
python /path/to/status_set.py blocked "need you: which db?"
python /path/to/status_set.py done "migration merged"
The installed skill teaches the agent when to call this; you can also just ask it to.
Title is <glyph> <label>[: <text>]. Label = $CLAUDE_WINDOW_LABEL, else a .window-label file
in the project, else the directory name.
| variable | effect |
|---|---|
CLAUDE_WINDOW_LABEL |
override the window label for this shell |
WINDOW_STATUS_DIR |
state directory (default ~/.claude/window-status) |
WINDOW_STATUS_PYTHON |
interpreter the launchers should use, if PATH discovery picks wrong |
WINDOW_STATUS_TTY |
POSIX: terminal device to paint, e.g. /dev/ttys004. launch.sh sets it; override only if it picks wrong |
WINDOW_STATUS_DEBUG |
set to 1 to write hook.log / daemon.log into the state dir |
Debug logging is off by default on purpose: the daemon ticks ~2.5×/second forever, and an always-on log grows without bound.
- Title never changes. In order: is the tab manually renamed? (that silently overrides
everything — rename it back); is the daemon running (
launch.ps1from this window's shell); were the hooks installed and Claude Code restarted since? - Title changes but flickers while working. Expected — that's the blinkenlights race with the harness's spinner. It settles when the agent goes idle, which is the state you actually read.
no Python 3.8+ found. The launchers try$WINDOW_STATUS_PYTHON, the interpreter recorded byinstall.py, thenpy -3/python3/python. SetWINDOW_STATUS_PYTHONto a full path.- Nothing works and you want a fallback.
python install.py --statuslinerenders the same status inside the Claude window, which doesn't depend on titles at all. - Stop the daemon.
.\launch.ps1 -Stop/./launch.sh --stop, or just close the window.
| file | role |
|---|---|
window_status.py |
shared plumbing: state dir, project key, label resolution, atomic writes |
status_set.py |
agent/CLI: write state+text to the status file |
hook.py |
Claude Code hook: flip state on turn boundaries; --event end stops the daemon |
title_daemon.py |
background loop: render the title from the status file (the renderer) |
launch.ps1 |
start the daemon on Claude's console (Windows / PowerShell) |
launch.sh |
start the daemon on Claude's terminal (macOS / Linux) |
statusline.py |
optional in-window status line (a separate, always-reliable fallback) |
install.py |
installer: merge hooks into settings + render the skill (self-locating) |
selftest.py |
end-to-end smoke test against a throwaway state dir (python selftest.py) |
skill/SKILL.md |
source of the Claude Code skill the installer renders |
Proven end-to-end on Windows (Windows Terminal and Git Bash, Claude Code 2.1.220), on macOS 26.5.2 / iTerm2 3.6.9 (Claude Code 2.1.221, Python 3.14.6), on WSL2 (Debian 13, kernel 6.6.114.1-microsoft-standard-WSL2, Windows Terminal, Claude Code 2.1.229, Python 3.13.5), and on native Linux (Ubuntu 24.04, XFCE, Python 3.12). Reports from other terminals are welcome.
What the macOS pass settled, since some of it applies to every POSIX platform:
- iTerm2 honours OSC 1 + OSC 2 in the tab. No OSC 0 fallback needed there.
- The harness repaints the title on macOS too, so the daemon is genuinely required — a one-shot write from a hook is overwritten exactly as on Windows.
open("/dev/tty")needs a controlling terminal, and nothing the agent spawns has one. It fails withENODEV(macOS) orENXIO(Linux) — and the write was inside a bareexcept OSError, so a daemon launched that way ran forever and painted nothing, with no error anywhere./dev/ttycannot identify a window.os.stat("/dev/tty").st_rdevis one shared device node (0x2000000on macOS, char 5:0 on Linux), identical in every terminal, so the singleton guard saw every window as the same console and daemons evicted each other. Both are now resolved to a real device path (/dev/ttys004,/dev/pts/4) once at startup, used for writing and identity.
Because of those last two, the agent can now start the daemon itself on POSIX, as it always could
on Windows by inheriting the console — launch.sh resolves the terminal by walking up to the Claude
process and passes it down in WINDOW_STATUS_TTY, since nohup reparents the daemon to init before
it could look for itself.
The WSL2 pass then measured the Linux half of that analysis, which until now came from documented device numbers rather than from running it:
open("/dev/tty")really does fail withENXIOfrom a process the agent spawns, confirming the errno the macOS section predicts for Linux./dev/ttyreally is char 5:0 (st_rdev0x500) — one shared node, so it cannot tell windows apart, exactly as the singleton-guard bug required.- Distinct ptys really do yield distinct keys:
/dev/pts/7→tty-8807,/dev/pts/8→tty-8808(char 136:7 and 136:8). One daemon per window, not one in total. - The ancestor walk is load-bearing here, not a fallback.
ttyreports "not a tty" for anything the agent runs, solaunch.shresolved the terminal by walking up to the Claude process every time — the normal path, never the exception.
Note what WSL2 does and doesn't settle. It exercises the whole POSIX code path — pty resolution,
ancestor walk, OSC writes, device identity — on a real Linux kernel, but the terminal rendering the
result is still Windows Terminal. That last link was closed separately, on a native Ubuntu 24.04 /
XFCE desktop driven over VNC: five daemons at once on tty-8800 through tty-8804, each painting
its own tab or window, none evicting another. Two tabs of the same window get separate ptys, so
they get separate console keys and coexist — which is the guarantee the whole design rests on.
| terminal | out of the box | what it needs |
|---|---|---|
| Windows Terminal | works | — |
| Git Bash (mintty) | works | — |
| iTerm2 | works | — |
| GNOME Terminal | works | — |
| Xfce Terminal | works | — |
| xterm | works (window title; it has no tabs) | — |
| Konsole | shows ~ : bash |
tab title format %w |
| tmux | shows bash |
set-titles on |
| GNU screen | untested | — |
Neither failure is a bug in this tool, and both were verified in each direction — reproduced with the default config, then fixed with the change:
-
Konsole receives the title, then hides it. Its default tab title format is
%d : %n(directory : program name), which has no place for%w, the title the program set. Settings → Edit Profile → Tabs → Tab title format →%w, or in a profile file:LocalTabTitleFormat=%w RemoteTabTitleFormat=%w
-
tmux captures the title, then keeps it to itself.
#{pane_title}holds our exact string, butset-titlesis off by default, so nothing reaches the outer terminal. In~/.tmux.conf:set -g set-titles on set -g set-titles-string "#T"
Restart the tmux server afterwards (
tmux kill-server) — a server already running will not re-read its config, which makes the fix look like it did nothing.
Two traps that mimic a broken daemon exactly, if you go testing this yourself:
- Xfce Terminal's
--titleflag pins a static title and suppresses the dynamic one entirely, so a terminal launched that way never shows a status no matter what the daemon does. - Xfce Terminal prepends
Terminal -to the window title. The tab label stays clean, and the tab is the point, but the window title and taskbar entry both carry the prefix.
GNU screen is listed as untested rather than broken, deliberately. Detached sessions (screen -D -m)
start and create sockets normally, but no attached session would establish one under either xterm
or Xfce Terminal — with the screen terminfo entry present and /run/screen writable — so nothing
honest could be measured. That is a gap in the test harness, not a verdict on screen.
Still untested: GNU screen, Terminal.app, Ghostty, WezTerm, kitty.
Doing the macOS pass? docs/PORTING-TO-MACOS.md is written for you:
the traps that cost us evenings on Windows, which of them have macOS analogues, exactly what's
unverified, and the order to test it in.
python selftest.py exercises the whole thing — status file, hooks, status line, daemon lifecycle,
installer dry-run — against a throwaway state directory, touching none of your real config. It's the
quickest way to say something useful in a bug report.
Issues and PRs welcome, especially terminal-compatibility reports (selftest.py output plus your
terminal and OS is ideal). Keep it dependency-free and standard-library-only — a status tool
shouldn't need a virtualenv.
MIT — see LICENSE.