* claude-companion: v1.3.0 — headless aggregator, user settings, sessions panel Catalog-side update for lowcache/claude-companion, from 1.0.1 to 1.3.0. Architecture: the pulse aggregator moved out of the bar widget into a headless [[service]] (pulse-svc.luau). Capture no longer depends on the bar dot being placed — the service starts with the shell and listens regardless of surfaces, retiring the plugin's old "pulse must sit on a bar" deployment invariant. The bar widget and desktop orb are now independent subscribers of the claude.pulse rollup, rendering only. Noctalia 5 beta also fixed the older limitation where bar widgets did not receive state.watch callbacks, so the bar dot is event-driven like the orb; both docs are updated accordingly. New: a `sessions` panel on right-click of the pulse (left-click still opens the answer panel) — one row per live session with state, model and token burn, plus a Retire control for a session whose SessionEnd hook never fired and which would otherwise sit at idle indefinitely. It introduces no new IPC verb: only the trailing `session` payload field is read for routing, so `session_end` with `,,,,,<sid>` is already a well-formed single-session retire. PROTOCOL.md now documents that property so any adapter can use it. Session ids are allowlisted before reaching a shell command. New: three animation settings (breath_speed, pulse_glow_floor, orb_swell), each declaring an explicit `step` — an omitted step defaults to 1.0 in the manifest parser, which collapses a fractional range to a couple of preset stops instead of a slider. plugin_api stays at 3. Everything used here is ungated at that level, and the contributing guidance is to raise it only when adopting a capability from a newer level. Verified against the installed build rather than assumed. Also ships tests/manifest_spec.py, which pins the settings contract: every numeric setting must declare an explicit step finer than its range, defaults must land on a step boundary, label/description must use the *_key form, and every key must resolve in translations/en.json. Neither the linter nor a widget spec can catch a bad step, which is how the slider bug shipped in the first place. Validated: catalog validator 54/54 exit 0, its own 54 self-tests pass, and the plugin's suite (5 luau + shim + manifest) is green. Live-tested on niri against Noctalia 5 beta; the compositor shim is unchanged in this update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * claude-companion: tint the sessions retire control, fix singular header Follow-up on the v1.3.0 submission from live review: the retire button carried no variant and rendered at the background colour; a single session read "1 sessions". The panel root stays unfilled by design — noctalia panels are translucent under the glass style, so the backdrop is the shell's, not the plugin's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: lowcache <drawpdeadredd@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.1 KiB
Python
Executable File
150 lines
5.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Pulse hook dispatcher (lowcache/claude-companion plugin).
|
|
|
|
Bridges a Claude Code lifecycle hook to the pulse aggregator service, enriching the
|
|
event with live model + token-burn telemetry parsed from the session transcript.
|
|
Invoked by the hooks in settings.snippet.json as:
|
|
|
|
pulse.py <event> # event name, e.g. turn_start / tool_start / ...
|
|
|
|
Hook JSON arrives on stdin (transcript_path, session_id). The widget is driven via
|
|
noctalia's documented plugin IPC (`noctalia msg --help`):
|
|
|
|
noctalia msg plugin lowcache/claude-companion:pulse-svc all <event> [payload]
|
|
|
|
`[payload]` is a single positional token, so the payload is a SPACE-FREE CSV the
|
|
aggregator service (pulse-svc.luau) parses:
|
|
|
|
model,in,out,cacheCreate,cacheRead,session
|
|
|
|
The `session` (short id) tags EVERY event, so the service can track each concurrent
|
|
session separately. The matching SessionEnd hook fires `session_end`, which retires
|
|
the session in the service and drops its token cache here.
|
|
|
|
Token accounting is incremental: a per-session cache in $XDG_RUNTIME_DIR stores the
|
|
last byte offset + running sums, so each hook reads only newly-appended transcript
|
|
lines (O(delta), not O(whole transcript)). Transcript JSONL only appends; if it
|
|
ever shrinks (context compaction rewrites it), the cache resets.
|
|
|
|
Fail-open by contract: ANY error (no stdin, malformed transcript, noctalia offline)
|
|
still fires the bare event with no payload and never exits non-zero — a hook must
|
|
never block Claude or surface an error.
|
|
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
PLUGIN = "lowcache/claude-companion:pulse-svc"
|
|
TARGET = "all"
|
|
|
|
|
|
def _cache_path(session):
|
|
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
|
safe = "".join(c for c in session if c.isalnum() or c in "-_") or "nosession"
|
|
return os.path.join(base, f"noctalia-pulse-{safe}.json")
|
|
|
|
|
|
def _accumulate(transcript, session):
|
|
"""Sum usage over newly-appended transcript lines since the last call."""
|
|
cache = _cache_path(session)
|
|
st = {"offset": 0, "in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""}
|
|
try:
|
|
with open(cache) as f:
|
|
st.update(json.load(f))
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
if os.path.getsize(transcript) < st["offset"]: # shrank (compaction) → reset
|
|
st = {"offset": 0, "in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""}
|
|
|
|
with open(transcript) as f:
|
|
f.seek(st["offset"])
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = (json.loads(line).get("message") or {})
|
|
except ValueError:
|
|
continue
|
|
u = msg.get("usage")
|
|
if not u:
|
|
continue
|
|
st["in"] += u.get("input_tokens", 0) or 0
|
|
st["out"] += u.get("output_tokens", 0) or 0
|
|
st["cc"] += u.get("cache_creation_input_tokens", 0) or 0
|
|
st["cr"] += u.get("cache_read_input_tokens", 0) or 0
|
|
if msg.get("model"):
|
|
st["model"] = msg["model"]
|
|
st["offset"] = f.tell()
|
|
|
|
tmp = cache + ".tmp"
|
|
try:
|
|
with open(tmp, "w") as f:
|
|
json.dump(st, f)
|
|
os.replace(tmp, cache)
|
|
except OSError:
|
|
pass
|
|
return st
|
|
|
|
|
|
def _payload(data, event):
|
|
"""Build the CSV payload, or None when the event can't be attributed.
|
|
|
|
Requires only a session id — every tagged event carries it so the widget can
|
|
track sessions individually. Token figures are best-effort (zeros when the
|
|
transcript is unreadable or empty); the widget decides whether to render them.
|
|
`session_end` skips the transcript parse (the id alone retires the session).
|
|
"""
|
|
session = data.get("session_id") or ""
|
|
if not session:
|
|
return None
|
|
st = {"in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""}
|
|
transcript = data.get("transcript_path") or ""
|
|
if event != "session_end" and transcript and os.path.isfile(transcript):
|
|
try:
|
|
st = _accumulate(transcript, session)
|
|
except OSError:
|
|
pass
|
|
model = st["model"].replace("claude-", "") if st["model"] else "?"
|
|
short = session.split("-")[0]
|
|
return f"{model},{st['in']},{st['out']},{st['cc']},{st['cr']},{short}"
|
|
|
|
|
|
def _cleanup(session):
|
|
"""Drop a finished session's token cache (best-effort)."""
|
|
if not session:
|
|
return
|
|
try:
|
|
os.unlink(_cache_path(session))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def main():
|
|
event = sys.argv[1] if len(sys.argv) > 1 else "idle"
|
|
try:
|
|
raw = sys.stdin.read()
|
|
data = json.loads(raw) if raw.strip() else {}
|
|
except (ValueError, OSError):
|
|
data = {}
|
|
payload = _payload(data, event)
|
|
argv = ["noctalia", "msg", "plugin", PLUGIN, TARGET, event]
|
|
if payload:
|
|
argv.append(payload)
|
|
if os.environ.get("NOCTALIA_PULSE_DRYRUN"):
|
|
print(" ".join(argv))
|
|
else:
|
|
try:
|
|
subprocess.run(argv, capture_output=True, timeout=3)
|
|
except Exception: # noqa: BLE001 — noctalia offline/missing must stay silent
|
|
pass
|
|
if event == "session_end":
|
|
_cleanup(data.get("session_id") or "")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|