← all projects

cc-status

Claude/Codex status
repo

cc-status

cc-status shows one traffic light per AI agent session โ€” Claude Code and Codex โ€” in a minimal Vala + GTK4 window. Each agent's hooks feed it over a Unix domain socket at /tmp/cc-status.sock.

    Session            Status                 Where
๐Ÿ”ด  claude/7a11ffff    error in Edit          pts/4 ยท hairness
๐ŸŸก  claude/9f3c1d2e    working (after Bash)   pts/12 ยท wikimediafoundation-org
๐ŸŸข  codex/t9           idle                   pts/9 ยท shiro

This project started as an embedded libghostty-vt terminal; that's gone now (see git history if you want a VT surface back). What survives from it is the OSC 2 transport below, which still uses libghostty-vt's parser through the hand-written binding in vapi/.

The status feed

Status lines use one format, <agent>/<session_id><TAB><status text>[<TAB><where>]. Any number of sessions of either agent can send them, over either of two transports.

Traffic lights

Each session is independent, so it gets its own light on its own row in a Gtk.ListBox. Rows are sorted by key, so they keep their place. Each row shows a shortened ID (hover for the full one), and the light's tooltip explains what its colour means.

Double-click a row to focus that session's terminal window (a single click selects the row). Only rows that resolve to an open window are clickable: they hover, take Enter, and carry a โ†—; the rest are inert, and their tooltip explains why. The match walks the session's process ancestry (ps -eo pid=,ppid=) up to whichever pid wmctrl -lp lists as owning a window, so it reaches a session in any tab, not just the one whose title currently shows. If that fails โ€” an older hook that never reported a pid โ€” it falls back to matching a window whose title contains the session key, which is there because the hook wrote it over OSC 2. Either way, wmctrl -ia raises the window. Without wmctrl or ps on PATH, no row is clickable.

This still can't select a specific tab: X exposes windows, not tabs, and switching to one would mean synthesising keystrokes and watching the title change, which isn't worth it. It also can't distinguish separate windows of a terminal that runs every window through one shared daemon process โ€” there, every window resolves to the same pid.

Which terminal tab is that?

(Tested on elementary Terminal 8.0: it honours OSC 2 in the window title, but labels tabs with the working directory regardless โ€” so the title identifies the active tab, and for background tabs, match the directory in the tab label against the project half of the Where column.)

Two ways, both from hooks/where.sh:

Road-sign semantics โ€” red means something is broken, not merely that you are wanted:

| Light | Meaning | Status text | |---|---|---| | ๐Ÿ”ด | something went wrong | error in <tool> | | ๐ŸŸก | still working | thinking, running <tool>, working (after <tool>) | | ๐ŸŸข | done โ€” your turn | idle, idle (notified), idle (permission), idle (approval) |

Green means your input is possible, and only that. Four events keep it honest:

PostToolUse fires between tool calls with the agent still going, which is why it reads working (after โ€ฆ) and stays amber.

The window title carries one light for the lot, so the taskbar/window list answers "is there anything for me to do?" without focusing the app. That's not severity โ€” green wins: one session ready for input turns the title green even while others work. Only with nothing green does a failure show (๐Ÿ”ด), and ๐ŸŸก means every session is busy. Until a session reports, the title stays plain.

The two agents

| | Claude Code | Codex | |---|---|---| | mechanism | hooks in ~/.claude/settings.json | notify in ~/.codex/config.toml | | entry point | hooks/cc-status.sh (JSON on stdin) | hooks/codex-notify.sh (JSON in argv[1]) | | derivation | hooks/derive.jq | hooks/derive-codex.jq | | states seen | prompt submitted, per-tool start/end, failures, permission prompts, notifications, turn end | turn end (and approval requests, version depending) |

Codex's notify only fires at those few points, so a Codex row never shows tool-by-tool progress โ€” that would need a Codex plugin with real hooks, a much bigger lift than one notify script. Failure detection is Claude-Code-only for the same reason: its PostToolUse payload carries the tool result.

Transports

libghostty-vt status

cc-status builds against libghostty-vt from upstream main, which scripts/build-libghostty.sh installs into ./.local. The distro package (0.1.0) isn't enough even for the OSC parser alone: its headers still name the opaque structs struct GhosttyOscParser rather than ...Impl, so the binding won't compile against it.

vapi/libghostty-vt.vapi is hand-written because the API isn't GObject-based (opaque handles, plain enums), so vapigen can't generate it. It binds the OSC parser only. The API is explicitly unstable: if ./build/osc-check starts failing after you rebuild libghostty, update the binding.

Build

make            # build-libghostty.sh + meson setup + ninja, as needed
make test       # every check below
make run        # build, then launch

Or by hand:

scripts/build-libghostty.sh    # once, builds libghostty-vt into ./.local
PKG_CONFIG_PATH=$PWD/.local/share/pkgconfig meson setup build
ninja -C build

--vapidir is opaque to meson, so changing vapi/ doesn't trigger a rebuild on its own: touch the .vala sources, or use a fresh build directory.

Run

./build/cc-status

A window opens, empty until the next hook event. Each hook event updates the matching row. A second launch just raises the running window โ€” the socket belongs to the first instance.

Wiring up the agents

On startup, cc-status looks for claude and codex on PATH. For whichever is installed but not yet reporting, it asks once before touching anything โ€” these are edits to your own config, so it never happens silently. Choose "Not now" and it writes ~/.config/cc-status/hooks-declined; it won't ask again.

Run the same steps by hand, or to redo them after declining:

scripts/install-hooks.sh "$PWD/hooks/cc-status.sh"              # ~/.claude/settings.json
scripts/install-codex-notify.sh "$PWD/hooks/codex-notify.sh"    # ~/.codex/config.toml

install-hooks.sh is idempotent per event: it leaves an event that already runs the hook alone and adds one that doesn't, so re-running it on a config from an older version picks up any newly needed event. install-codex-notify.sh is idempotent on the hook path. Both keep a .bak and leave the rest of the file alone. The Codex installer refuses to replace a notify that's already set to something else. Install socat and jq first, and restart any running session โ€” both agents read their config only at session start.

For per-project Claude Code hooks instead of user-wide ones, merge examples/claude-settings.json into that project's .claude/settings.json.

Set CC_STATUS_OSC=1 in the hook's environment to send status as an OSC 2 sequence instead of a plain line.

Acceptance tests

Run all the binding checks via meson: meson test -C build.

Check both agents' jq derivations for regressions โ€” this runs the real derive.jq / derive-codex.jq the hook scripts use, not a copy (no GTK app needed):

./hooks/test-derive.sh

Check that the hand-written binding still matches libghostty-vt's ABI: it parses OSC 0/2 titles (BEL- and ST-terminated), OSC 7 pwd, and invalid input.

./build/osc-check

Check the pure logic in src/status.vala: the status-to-light mapping, how session keys become rows, and window matching by process ancestry.

./build/status-check

Check both installers: settings merge correctly, a second run doesn't duplicate anything, and the Codex installer refuses to clobber an existing notify. Both write only to a temp directory.

./tests/install-hooks-check.sh
./tests/install-codex-check.sh

Run an end-to-end check of the hook script over a real socket (no GTK app needed โ€” useful in headless environments):

rm -f /tmp/cc-status.sock
socat UNIX-LISTEN:/tmp/cc-status.sock,fork - &
echo '{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"Bash"}' | hooks/cc-status.sh
# the listener's stdout should print: claude/s1<TAB>running Bash<TAB>pts/N ยท <project>

Notes