feat: add a Sessions screen showing running Claude Code sessions - #134
Closed
Fr3d wants to merge 6 commits into
Closed
feat: add a Sessions screen showing running Claude Code sessions#134Fr3d wants to merge 6 commits into
Fr3d wants to merge 6 commits into
Conversation
Claude Code can POST hook events to an HTTP endpoint. Receive them on a loopback-only listener and maintain a table of what every running session is doing, so the device can show which ones need attention. Hooks answer questions the transcript cannot. A session waiting on a permission prompt writes nothing to disk until the prompt is resolved, so that state is invisible to any file-watching approach; the Notification event reports it directly. They also arrive in milliseconds rather than on a poll interval. The table deliberately stores far more per session than the BLE payload can carry — pending tool, notification text, error type, subagent and task counters, an event ring — so extending the display later needs no changes to the ingest path. Unknown events are recorded and ignored so a future Claude Code release cannot break it. Liveness and identity come from ~/.claude/sessions/<pid>.json rather than being guessed. Absence from that roster retires a session within seconds whatever the cause, where a timeout alone was wrong in both directions: it evicted sessions left idle overnight while they were alive, and let crashed ones linger for hours showing a stale "idle". The roster also supplies Claude Code's own per-session name, which is unique where basename(cwd) is not — two sessions in one directory read as "netmap-57" and "netmap-2c" instead of "netmap" twice. Bind 127.0.0.1 only and reject non-loopback peers: hook payloads contain prompt and response text. Disabled by default. Set hook_port in the daemon config to enable, matching the existing chime/clock convention so an upgrade never opens a socket by itself.
…doing Third screen alongside the splash and usage views, reached by touch: usage -> sessions -> splash. Touch is already the screen-switching gesture, so the new screen joins that cycle rather than the PWR button, which keeps its existing jobs (brightness on a data view, next animation on the splash) and leaves no gesture changing meaning for existing users. Each row is a state dot, the session name, its status with elapsed time, and a context-window bar. Colour is rationed: terra-cotta marks only the four states that want you (needs permission, asking you, needs input, error), active work is plain text colour and idle is dim, so "does anything need me" reads at a glance without being read. The context bar is hidden rather than drawn empty when usage is unknown, as an empty bar reads as 0% used. It also overrides make_bar()'s green indicator, which is the usage screen's headroom language and would compete with terra-cotta for attention here. Rows are laid out from compute_layout() at all three breakpoints. Sizing notes: the tail label sits below the last possible row rather than against the bottom edge, where it overlapped row five by 3px at 480x480 and 12px at 240x240; and the name is width-bounded with LV_LABEL_LONG_DOT, because the daemon caps labels by character count which says nothing about pixel width. Renders "Waiting for host" until data arrives, which is deliberately worded differently from "No sessions running" — they mean different things.
Add SS_CHAR (...0005) and parse its positional-JSON rows into SessionList. Sessions get their own characteristic rather than riding the usage write because that payload already reaches 121 bytes worst case, which would leave only ~79 bytes of the MTU budget for rows — about two. A separate characteristic gets a full budget while keeping one wire format for both. It deliberately does not set has_received_data: that flag gates the device-initiated refresh request, which is about usage data. Rows are positional to keep JSON overhead down, and every field is range checked on the way in. A total below the row count would render "-1 more running", so it is clamped. Extract writer_is_owner() and copy_write() out of RxCallbacks so the new characteristic shares the single-owner check rather than becoming an unguarded back door. copy_write() also fixes pre-existing behaviour: an oversized write was silently clamped, producing truncated JSON that failed to parse with no clue why. It is now rejected with a log line, so an over-MTU payload announces itself.
Wire the hook listener into the macOS and Windows daemons: start it from main(), write the session fragment on SS_CHAR, and wake the poll loop on hook events so a permission prompt reaches the device in milliseconds rather than on the next 60s boundary. Derive the row budget from the link's actual ATT_MTU rather than a constant. Measured 256 (253 usable) on Windows/WinRT, which affords 20-character labels instead of 14, but CoreBluetooth and BlueZ are unmeasured and are known to report differently for write-without-response, so each connection reads its own value and falls back to a conservative default when the backend will not report a usable one. Session writes are best-effort: a failure is logged and swallowed so that a device running firmware without SS_CHAR keeps reporting usage normally. Fix two assumptions in test_windows_reconnect.py that this broke. Its capturing_Event helper kept the LAST asyncio.Event created and assumed that was main()'s stop_event; because the patch targets mod.asyncio.Event and mod.asyncio is the global asyncio module, every Event anywhere was captured, so once main() also built a HookListener the captured event was the listener's. Setting it no longer stopped main(), which looped forever — consuming memory through the test's own recorded_timeouts list and flooding the real daemon.log, since the test suite shares the production file logger. Capture the FIRST Event instead, which main() creates as its opening statement. Also teach the connect_and_run doubles about the new listener parameter.
The session payload budget was an assumption. The only prior evidence was indirect: the usage payload is 121 bytes worst case and works everywhere, so ATT_MTU must exceed ~124 — a floor, not a value. Report client.mtu_size and sanity-check it, since a reading at or below the 23-byte BLE default means the backend is guessing rather than that the link is genuinely that small. Optionally escalate write sizes to find the real ceiling, using write requests rather than commands, because write-without-response drops an oversized payload silently — the exact failure mode this exists to avoid. Needs no firmware change and no reflash: MTU is negotiated at connection setup, independent of which characteristics exist.
README gains a Sessions screen section with the two pieces of setup it needs — hook_port in the daemon config, and the ~/.claude/settings.json hook block — because enabling the listener alone does nothing until Claude Code is told to post to it. Explains why each event is subscribed, notes that the entries merge alongside existing hooks rather than replacing them, and records that these are read-only observers which answer 204 and so cannot block a tool call or approve a permission. Correct four places the README had gone stale: the touch gesture is a three-way cycle rather than a toggle, the screenshot table was missing Sessions, the GATT table was missing the REQ and SS characteristics, and the data flow did not mention the session path. Correct CLAUDE.md's daemon section, which described a single bash daemon when there are three implementations. Only the two Python daemons carry the listener, but that does not make the feature macOS/Windows only: claude_usage_daemon.py calls itself a macOS port while keeping every macOS-specific piece behind a platform guard or a lazy import, and handles Linux credentials and address formats deliberately. Linux users run it instead of the .sh. State the Linux caveats as facts rather than leaving them implied: discover_target() never scans by name off darwin, so it needs an address already pinned in ~/.config/claude-usage-monitor/ble-address and cannot populate that file itself; there is no bluetoothctl-remove equivalent and the encryption-error recovery is darwin-gated, so a stale bond needs a manual re-pair; and none of it has been run against BlueZ. Document how to run the test suite, and warn that it shares the production file logger on Windows, which once made a hung test look like a daemon spin-loop. Record the session-monitor traps worth knowing: append-only wire codes, an alphanumeric roster discriminator rather than a counter, transcripts stripping the [1m] model suffix so context percentage has to infer the window size, and a row budget derived from the measured MTU.
Fr3d
marked this pull request as ready for review
July 30, 2026 12:36
Fr3d
marked this pull request as draft
July 30, 2026 12:42
Owner
|
Take a look at #129 and the feedback I had on that PR. This is tackling the same topic but with useful additional info like the user prompt feedback which looks particularly useful. |
This was referenced Jul 31, 2026
Owner
|
Closing in favor of #136 which consolidates this and a couple of other pull requests and feature requests I've gotten on this topic. Would appreciate any help on testing that one since it's a large change so any help validating it would be appreciated. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What it does
A third screen alongside the splash and usage views, showing every running Claude Code session and what it's doing — thinking, running a tool, needing permission, asking you a question, or dead on a rate limit — with its context-window usage.
Reached by touch: usage → sessions → splash. Touch was already the screen-switching gesture, so the new screen joins that cycle rather than the PWR button, which keeps its existing jobs (brightness, next animation) — no existing gesture changes meaning.
Off by default. Set
hook_portin the daemon config and add a hook block to~/.claude/settings.json; both are documented in the README.How it gets the data
Claude Code can POST hook events to an HTTP endpoint (
"type": "http"hooks). The daemon listens on loopback and maintains a session table, then writes it to a new GATT characteristic.Hooks rather than watching the transcript files, for two reasons. A session blocked on a permission prompt writes nothing to its transcript until you answer, so that state is invisible to any file-watching approach — the
Notificationevent reports it directly. And hooks arrive in milliseconds rather than on a poll interval.Liveness and naming come from
~/.claude/sessions/<pid>.jsonrather than being inferred. That file exists only while a process runs, so a vanished session retires within seconds whatever the cause — a timeout alone was wrong in both directions, evicting sessions left idle overnight while they were alive, and letting crashed ones linger for hours showing a stale "idle". It also supplies Claude Code's own per-session name, which is unique wherebasename(cwd)is not: two sessions in one directory read asclawdmeter-36andclawdmeter-2cinstead ofclawdmetertwice.Design decisions you may want to push back on
Colour is rationed. Only the four states that want you get terra-cotta; active work is plain text colour and idle is dim. The screen's job is answering "does anything need me" without being read, so more colour than that defeats it. This also meant overriding
make_bar()'s green indicator, which is the usage screen's headroom language and competed for attention here.Sessions get their own characteristic (
…0005) rather than sharing the usage write. That payload already reaches 121 bytes worst case, which would leave only ~79 bytes of the MTU budget for rows — about two.The row budget is measured, not assumed.
budget_from_mtu()derives it fromclient.mtu_sizeper connection. Windows/WinRT negotiates 256 (253 usable), which affords 20-character labels; CoreBluetooth and BlueZ are unmeasured, so each falls back to a conservative default rather than trusting the Windows figure.daemon/probe_mtu.pyis included for measuring.Labels shrink before rows are dropped. Hiding a session is the one thing this screen must not do, so a tight budget costs label characters first. Truncation middle-elides to preserve Claude Code's trailing discriminator — tail-truncating
somelongprojectname-2cwould put the collision straight back.Context % is a heuristic and I'd like a second opinion. Tokens come from the newest non-sidechain assistant record's
input_tokens + cache_read + cache_creation, but transcripts recordmessage.modelwith any[1m]suffix stripped, so window size can't be read from the name — a 1M session displayed 100% until the token count itself was used as evidence of the limit. The authoritative figure is the statusline'scontext_window.used_percentage, which hooks don't provide.Security
The listener binds
127.0.0.1only and rejects non-loopback peers, because hook payloads contain prompt and response text. The subscribed hooks are read-only observers: the endpoint answers204 No Content, so they cannot block a tool call or auto-approve a permission. On the device side, the new characteristic goes through the same single-owner check as the usage write rather than becoming an unguarded back door.Platform coverage
claude_usage_daemon.pyinstead. That should work (its macOS-specific pieces are all behind platform guards or lazy imports, and Linux credential/address handling is already deliberate), but notediscover_target()never scans by name off darwin, so it needs an address already pinned in~/.config/claude-usage-monitor/ble-addressand can't populate that itself. Untested against BlueZ. Adding a file-drop bridge to the bash daemon would fit its existing dbus-monitor→flag-file idiom, and I'm happy to do that if you'd prefer it in scope.Verified
238 tests pass; firmware builds for
waveshare_amoled_216(RAM 31.9%, flash 54.8%). Screenshot above is from real hardware.Not verified: the other four board ports. The changes are mostly in shared code, and the two C6 boards have no PSRAM, so the five pre-built row groups land in internal RAM there — worth a look from someone with that hardware. A 20-character label has also never been rendered, so whether it collides with the context bar at any breakpoint is untested.