Skip to content

feat: add a Sessions screen showing running Claude Code sessions - #134

Closed
Fr3d wants to merge 6 commits into
HermannBjorgvin:mainfrom
Fr3d:feat/sessions-screen
Closed

feat: add a Sessions screen showing running Claude Code sessions#134
Fr3d wants to merge 6 commits into
HermannBjorgvin:mainfrom
Fr3d:feat/sessions-screen

Conversation

@Fr3d

@Fr3d Fr3d commented Jul 30, 2026

Copy link
Copy Markdown

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.

Sessions

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_port in 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 Notification event reports it directly. And hooks arrive in milliseconds rather than on a poll interval.

Liveness and naming come from ~/.claude/sessions/<pid>.json rather 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 where basename(cwd) is not: two sessions in one directory read as clawdmeter-36 and clawdmeter-2c instead of clawdmeter twice.

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 from client.mtu_size per 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.py is 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-2c would 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 record message.model with 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's context_window.used_percentage, which hooks don't provide.

Security

The listener binds 127.0.0.1 only and rejects non-loopback peers, because hook payloads contain prompt and response text. The subscribed hooks are read-only observers: the endpoint answers 204 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

  • Windows — verified end to end on an AMOLED-2.16.
  • macOS — code paths present, untested.
  • Linux — the bash daemon has no listener path, so Linux users would run claude_usage_daemon.py instead. That should work (its macOS-specific pieces are all behind platform guards or lazy imports, and Linux credential/address handling is already deliberate), but note discover_target() never scans by name off darwin, so it needs an address already pinned in ~/.config/claude-usage-monitor/ble-address and 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.

Fr3d added 6 commits July 30, 2026 12:57
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
Fr3d marked this pull request as ready for review July 30, 2026 12:36
@Fr3d
Fr3d marked this pull request as draft July 30, 2026 12:42
@HermannBjorgvin

Copy link
Copy Markdown
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.

@HermannBjorgvin

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants