Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions assets/icon_list-todo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon_list-todo_16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon_list-todo_24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions assets/icon_users-round.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon_users-round_16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon_users-round_24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions daemon/SESSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Live session awareness — host setup

Shows your open Claude Code chats on the device: what each one is doing, how
full its context window is, todo/subagent counts — and, most importantly,
whether any of them is waiting on you. Design and rationale live in issue
[#135](https://github.com/HermannBjorgvin/Clawdmeter/issues/135).

**Off by default.** The session data comes from a Claude Code hook integration
you have to install; without it the device behaves exactly as it does today.

## How it works

```
Claude Code sessions ──HTTP hooks (127.0.0.1)──▶ clawdmeter_sessions.py
transcripts *.jsonl ─────context tokens─────▶ (state machine + sort)
<config-dir>/sessions/<pid>.json ─liveness──▶ │
▼ atomic write on change
~/.clawdmeter/sessions.json
│ 5 s tick, on change
claude-usage-daemon.sh ──BLE GATT SS …0005─────────────▶ Clawdmeter firmware
```

The sidecar (`daemon/clawdmeter_sessions.py`, Python 3 stdlib only) listens on
loopback for hook POSTs, keeps a table of live sessions, sorts it
attention-first, fits it to a byte budget, and writes the finished wire payload
to `~/.clawdmeter/sessions.json`. The bash daemon ships that payload to the SS
GATT characteristic whenever the file's content changes. The listener is a
read-only observer: it answers `204 No Content` and can never block a tool call
or approve a permission.

## Setup (Linux)

`./install.sh` prompts for all of this (default yes). Manually:

1. **Config** — in `~/.config/claude-usage-monitor/config`:

```ini
hook_port = 45999
# context_window_k = # optional: pin the context window (kilotokens)
# sessions_budget_bytes = 180 # optional: payload byte budget
```

2. **Hook block** — merge into `~/.claude/settings.json` (and any other Claude
config dir you use). The helper is idempotent and backs up the file first:

```bash
python3 daemon/clawdmeter_sessions.py --install-hooks ~/.claude/settings.json http://127.0.0.1:45999/
```

Or add it by hand — this is the exact block:

```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PreToolUse": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PostToolUse": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PostToolUseFailure": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PermissionRequest": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PermissionDenied": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"Notification": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"MessageDisplay": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"Stop": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"StopFailure": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PreCompact": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"PostCompact": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"SubagentStart": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"SubagentStop": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }],
"SessionEnd": [{ "hooks": [{ "type": "http", "url": "http://127.0.0.1:45999/", "async": true, "timeout": 5 }] }]
}
}
```

If your settings.json already has hooks, keep them — Clawdmeter's entries
are appended alongside, not instead. New sessions pick hooks up on start;
already-running sessions keep their old hook set.

3. **Run the sidecar** — `./install.sh` installs and enables the
`clawdmeter-sessions` systemd user unit:

```bash
systemctl --user start clawdmeter-sessions
journalctl --user -u clawdmeter-sessions -f
```

Or run it in a terminal: `python3 daemon/clawdmeter_sessions.py`

4. **Verify** — open a Claude Code session, then:

```bash
curl -s http://127.0.0.1:45999/ # current wire payload (loopback debug)
cat ~/.clawdmeter/sessions.json # what the daemon will ship
```

## Wire format

The payload is `{"ss":[...]}` with one positional row per session, already
sorted attention-first (waiting, working, idle; most recent first within each):

```
[sid, label, state, ctx, elapsed_s, model, tool, ntools, nagents, tdone, ttotal, tok]
```

| # | Field | Meaning |
| - | --- | --- |
| 0 | `sid` | 2 hex chars, stable for the session's life (keys the reorder animation) |
| 1 | `label` | Display name, already middle-elided to fit the budget |
| 2 | `state` | State code 0–10 (issue #135 §3; append-only) |
| 3 | `ctx` | Context window used, percent; `-1` = unknown (firmware hides the bar) |
| 4 | `elapsed_s` | Seconds in the current state at write time |
| 5 | `model` | `0` unknown, `1` opus, `2` sonnet, `3` haiku, `4` fable |
| 6 | `tool` | `0` other/none, `1` Bash, `2` Read, `3` Edit, `4` Write, `5` Grep, `6` Glob, `7` Task, `8` WebFetch, `9` WebSearch |
| 7 | `ntools` | OPEN tool calls (concurrent, not cumulative) |
| 8 | `nagents` | Subagents currently in flight |
| 9–10 | `tdone` / `ttotal` | Todo counts; badge hidden when `ttotal` is 0 |
| 11 | `tok` | Context tokens used, in 1k units (rounded to nearest) — the absolute number behind `ctx`, from the same transcript read. `-1` exactly when `ctx` is `-1` |

Fields are append-only: firmware ignores indices it doesn't know, and new
fields only ever go on the end.

## Config reference

| Key | Default | Meaning |
| --- | --- | --- |
| `hook_port` | unset | Loopback port for the hook listener. **Unset = feature off** — the sidecar exits, the daemon sends nothing. |
| `context_window_k` | unset | Pin the context window in kilotokens (e.g. `200`, `1000`). Blank = heuristic: 200k default, 1M on a `[1m]` model marker, snap up to the next 1M multiple when observed usage exceeds the assumption. A pinned value disables the snap-up. |
| `sessions_budget_bytes` | `180` | Byte budget for the fitted payload. Labels middle-elide down to an 8-char floor first, then the least-urgent rows drop from the tail. Keep below the BLE MTU the device negotiates. |

The sidecar also honors `config_dirs` (shared with the daemons) to find session
rosters and transcripts across several Claude config dirs.

## Notes

- **Privacy/security.** Hook payloads contain prompt and response text, so the
listener binds `127.0.0.1` only and rejects non-loopback peers. Nothing from
the payload text ever reaches the device — only names, states, and counts.
- **Liveness.** Sessions are considered alive while their roster entry
(`<config-dir>/sessions/<pid>.json`) points at a running process — not on an
activity timeout, so a chat parked on a permission prompt survives
indefinitely. Roster absence is acted on after a 30 s grace; a 6 h staleness
sweep backstops an unreadable roster.
- **Context % is a heuristic** read from the transcript tail
(`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`),
re-read on SessionStart/Stop/PostCompact. Good for a glanceable bar, not for
quoting numbers.
- **macOS / Windows** — the Python daemons don't ship session data yet. The
sidecar is importable as a library (`SessionTable`, `fit_payload`, …) for
that integration; this round wires up Linux only.
- **Firmware support** — the device needs firmware with the SS characteristic
(`…0005`) and a board with the session-views capability. Older firmware just
never sees the data; the daemon stays silent about it.
66 changes: 66 additions & 0 deletions daemon/claude-usage-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
SERVICE_UUID="4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID="4c41555a-4465-7669-6365-000000000004"
SS_CHAR_UUID="4c41555a-4465-7669-6365-000000000005" # live session rows (issue #135)
SESSIONS_FILE="$HOME/.clawdmeter/sessions.json"
POLL_INTERVAL=60
TICK=5
SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address"
CONFIG_FILE="$HOME/.config/claude-usage-monitor/config"
REFRESH_FLAG="/tmp/claude-usage-refresh-$$"
DBUS_DEST="org.bluez"
NOTIFY_PID=""
SS_CHAR_PATH=""
LAST_SESSIONS_SIG=""

log() {
echo "[$(date '+%H:%M:%S')] $1"
Expand Down Expand Up @@ -275,6 +279,62 @@ write_gatt() {
WriteValue "aya{sv}" "$count" $bytes 0 2>/dev/null
}

# UTF-8-safe GATT write. write_gatt() above converts per CHARACTER, which is
# fine for the all-ASCII usage payload but corrupts multi-byte UTF-8 (e.g. the
# "…" in middle-elided session labels: printf "'…" yields the code point 0x2026,
# not bytes). od emits the actual byte values, whatever the locale.
write_gatt_bytes() {
local char_path="$1"
local data="$2"
local bytes count
bytes=$(printf '%s' "$data" | od -An -v -tu1 | tr -s ' \n' ' ')
count=$(printf '%s' "$data" | wc -c)
# shellcheck disable=SC2086 # $bytes is a deliberate word list
busctl call "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 \
WriteValue "aya{sv}" "$count" $bytes 0 2>/dev/null
}

# --- Live session awareness (issue #135) -----------------------------------
# The clawdmeter-sessions sidecar (see SESSIONS.md) listens for Claude Code
# hook events and writes an already-fitted wire payload to
# ~/.clawdmeter/sessions.json on every session state change. On the existing
# 5s tick, ship that payload to the SS characteristic whenever the file's
# content changed. Fully inert when the file doesn't exist (feature off /
# sidecar not running) and when the firmware doesn't expose the characteristic
# (older firmware) — no errors, no log spam.
maybe_send_sessions() {
[ -f "$SESSIONS_FILE" ] || return 0
local sig
sig=$(md5sum "$SESSIONS_FILE" 2>/dev/null | awk '{print $1}')
[ -z "$sig" ] && return 0
[ "$sig" = "$LAST_SESSIONS_SIG" ] && return 0
# Resolve the SS char lazily, at most once per changed payload, so a
# sidecar started after connect gets picked up without running busctl
# tree on every idle tick.
if [ -z "$SS_CHAR_PATH" ]; then
SS_CHAR_PATH=$(find_char_path_by_uuid "$SS_CHAR_UUID")
if [ -z "$SS_CHAR_PATH" ]; then
LAST_SESSIONS_SIG="$sig" # firmware without SS: retry on next change
return 0
fi
log "GATT SS path: $SS_CHAR_PATH"
fi
# sessions.json holds {"ts":..., "payload":"<wire string>"}. The payload is
# stored as a string so the exact bytes the sidecar fitted to the budget
# are what goes over the air.
local payload
payload=$(PYTHONIOENCODING=utf-8 python3 -c 'import json,sys
try:
print(json.load(open(sys.argv[1]))["payload"])
except Exception:
pass' "$SESSIONS_FILE" 2>/dev/null)
[ -z "$payload" ] && { LAST_SESSIONS_SIG="$sig"; return 0; }
if write_gatt_bytes "$SS_CHAR_PATH" "$payload"; then
LAST_SESSIONS_SIG="$sig"
fi
return 0
}

# Build the device payload for one OAuth token. Echoes the JSON payload on
# success (empty + non-zero return on failure). Pure: no logging, no GATT write
# — poll() owns picking the active plan and sending it.
Expand Down Expand Up @@ -487,6 +547,11 @@ while true; do
fi
log "GATT RX path: $RX_CHAR_PATH"

# Characteristic paths change across reconnects; re-resolve SS lazily and
# resend the current session payload to the freshly connected device.
SS_CHAR_PATH=""
LAST_SESSIONS_SIG=""

BACKOFF=1 # reset backoff on successful connection

start_notify_subscriber
Expand All @@ -503,6 +568,7 @@ while true; do
fi
poll && LAST_POLL=$NOW
fi
maybe_send_sessions
sleep "$TICK"
done

Expand Down
11 changes: 11 additions & 0 deletions daemon/clawdmeter-sessions.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[Unit]
Description=Clawdmeter Session Awareness Sidecar (Claude Code hook listener)

[Service]
Type=simple
ExecStart="DAEMON_PATH"
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
Loading