feat: add claude-companion (#33)

* Add lowcache/claude-companion plugin for PR

* claude-companion: address review feedback

...

* Added tr & timeout to dependency list, and reduced thumbnail description

* added missing thumbnail.webp
This commit is contained in:
Jarred Robinson
2026-07-19 08:46:30 -04:00
committed by GitHub
parent 4008bf15f2
commit 14215c8cd4
20 changed files with 2339 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# pulse-emit — generic, agent-agnostic emitter for the pulse protocol.
# The whole contract lives in PROTOCOL.md; this is the reference adapter for
# any agent that can run a shell command on its lifecycle hooks (gemini-cli,
# codex, opencode, a CI job). Claude Code uses pulse.py instead (it enriches
# events with transcript-derived token telemetry; this one just relays args).
#
# pulse-emit <event> [session] [model] [in] [out] [cacheCreate] [cacheRead]
#
# With a session id the event carries the space-free CSV payload
# "model,in,out,cacheCreate,cacheRead,session" (omitted fields -> ?/0).
# Without one it sends a bare event, which lands in the widget's shared
# "default" test slot — fine for a poke, wrong for a real integration.
#
# Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse)
# PULSE_DRYRUN non-empty -> print the command instead of dispatching
#
# Fail-open by contract: exits 0 no matter what, output swallowed, dispatch
# capped at 3 s. A hook must never block or error the agent driving it.
TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse}"
event="${1:-idle}"
session="$2"
# CSV fields must stay space- and comma-free (payload is one positional token).
clean() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._?-'; }
payload=""
if [ -n "$session" ]; then
payload="$(clean "${3:-?}"),$(clean "${4:-0}"),$(clean "${5:-0}"),$(clean "${6:-0}"),$(clean "${7:-0}"),$(clean "$session")"
fi
if [ -n "$PULSE_DRYRUN" ]; then
echo "noctalia msg plugin $TARGET all $event${payload:+ $payload}"
exit 0
fi
if command -v timeout >/dev/null 2>&1; then
timeout 3 noctalia msg plugin "$TARGET" all "$event" ${payload:+"$payload"} >/dev/null 2>&1
else
noctalia msg plugin "$TARGET" all "$event" ${payload:+"$payload"} >/dev/null 2>&1
fi
exit 0
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Pulse hook dispatcher (lowcache/claude-companion plugin).
Bridges a Claude Code lifecycle hook to the pulse bar widget, 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 all <event> [payload]
`[payload]` is a single positional token, so the payload is a SPACE-FREE CSV the
widget (pulse.luau) parses:
model,in,out,cacheCreate,cacheRead,session
The `session` (short id) tags EVERY event, so the widget can track each concurrent
session separately. The matching SessionEnd hook fires `session_end`, which retires
the session in the widget 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"
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()
@@ -0,0 +1,26 @@
{
"_comment": "Merge into ~/.claude/settings.json. The attention reflex: Claude Code lifecycle hooks invoke hooks/pulse.py <event>, which reads the hook JSON on stdin, computes live model + token-burn telemetry from the session transcript, and dispatches into the pulse bar widget via `noctalia msg plugin lowcache/claude-companion:pulse all <event> [payload]` (target `all` = every monitor's instance; `focused`/bare connector error when the widget is on multiple bars). payload is a space-free CSV `model,in,out,cacheCreate,cacheRead,session` whose trailing `session` (short id) tags every event, so the widget tracks each concurrent session separately and renders the most urgent state + a per-session token-burn tooltip. SessionEnd fires `session_end`, retiring that session in the widget and dropping its token cache. The dispatcher is fail-open: if noctalia is offline or the transcript is unreadable it fires the bare event (or nothing) and never errors. Path assumes the plugin is installed/symlinked at ~/.local/share/noctalia/plugins/claude-companion. Verified against noctalia 5.0.0 (`noctalia msg --help`). SessionStart registers the session at idle; the lifecycle drives turn_start -> tool_start -> turn_end; SessionEnd removes it. For the MCP shim (senses/hands), wire it separately via mcpServers/--mcp-config once shim/noctalia-mcp.py is in use.",
"hooks": {
"SessionStart": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py idle" } ] }
],
"UserPromptSubmit": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_start" } ] }
],
"PreToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py tool_start" } ] }
],
"PostToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_start" } ] }
],
"Notification": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py needs_attention" } ] }
],
"Stop": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_end" } ] }
],
"SessionEnd": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py session_end" } ] }
]
}
}