* 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
7.0 KiB
The Pulse Protocol
An agent-agnostic contract for driving the pulse bar widget (and everything
downstream of it: the presence orb, tooltips, claude.pulse subscribers). The
widget knows nothing about Claude Code — it consumes events and an optional
telemetry payload over noctalia's plugin IPC. Any coding agent that can run
a shell command on its lifecycle hooks (gemini-cli, codex, opencode, aider, a
CI job, a cron script) can light up the same bar dot.
Two adapters ship in hooks/:
| Adapter | For | Telemetry |
|---|---|---|
pulse.py |
Claude Code (reads hook JSON on stdin, parses the session transcript) | live token burn, O(delta) |
pulse-emit |
anything else (plain POSIX sh, args only) | whatever you pass, or none |
Transport
noctalia msg plugin <target> all <event> [payload]
<target>is the plugin dispatch id:<plugin-id>:<widget-entry>—lowcache/claude-companion:pulsefor this install. Adapters must treat it as configurable (pulse-emitreads$PULSE_TARGET).alladdresses every monitor's widget instance. (focusedor a bare connector errors when the widget sits on multiple bars.)[payload]is a single positional token — noctalia's msg CLI splits on whitespace, so the payload must be space-free. That's why it's a CSV, not JSON.- Fire-and-forget. The dispatch returns
ok: dispatched Nor an error string; adapters ignore both (see the fail-open contract below).
Event vocabulary
Eight events. Priority decides which session the bar shows when several are active; "resting" matters for the default-slot rule below.
| Event | Meaning | Priority | Resting |
|---|---|---|---|
needs_attention |
agent is blocked on the human (permission prompt, question) | 6 | no |
error |
hard failure | 5 | yes |
tool_start |
executing a tool / command | 4 | no |
turn_start |
thinking — a turn has begun | 3 | no |
text |
streaming a response | 3 | no |
turn_end |
turn finished — output ready for the human | 2 | yes |
idle |
session alive, nothing happening | 1 | yes |
session_end |
session is over — retires its slot | — | — |
Unknown events render as idle-with-the-event-kept-as-state-word; stick to the
vocabulary. Glyph, accent color, and breath tempo are widget-side concerns
(see VISUAL in pulse.luau) — the protocol only fixes the semantics.
Payload
model,in,out,cacheCreate,cacheRead,session
session(field 6) is the only field that changes behavior: it keys the per-session slot, so every event from the same agent session must carry the same short id (Claude's adapter uses the first-segment of the session UUID; any stable[A-Za-z0-9_-]+token works).modelis a display string; use?when unknown.- Token fields are lifetime-cumulative for the session, not per-turn deltas.
The widget displays input as
in + cacheCreate(full-rate work) and showscacheReadseparately. All-zero telemetry is fine — the burn line is simply omitted (modelof?or zero in+out hides it). - No commas or whitespace inside fields.
Minimum viable adapter: fire bare events with just a session id —
?,0,0,0,0,<sid>. State tracking, urgency priority, multi-session tooltip all
work; you only lose the burn readout.
Session semantics (what the widget guarantees)
- One slot per
sessionid; re-sending updates the slot in place. - The bar renders the most urgent state across all live slots (priority table above); the tooltip lists every session, most recent first, with a Σ burn total.
session_endretires the slot. Nothing else does — a real session may sit atidleorturn_endindefinitely and stays listed.- Payload-less events (no CSV at all — e.g. a manual
noctalia msg plugin … all needs_attentionpoke from a terminal) land in a single shareddefaultslot. To keep CLI pokes from leaving a phantom session, any resting event (idle,turn_end,error) retires thedefaultslot instead of updating it. Consequence for adapters: always send a session id; the default slot is a test surface, not a home.
Adapter contract
- Fail-open, always. Exit 0 no matter what — noctalia offline, binary missing, malformed input. An adapter runs inside an agent's hook path and must never block or error the agent. Swallow stdout/stderr, cap the dispatch with a timeout (~3 s).
- Tag everything with the session id (see above).
- Send cumulative telemetry or none — don't send per-turn deltas.
- Don't invent events; map your agent's lifecycle onto the eight above.
Lifecycle mapping guide
The Claude Code mapping (from hooks/settings.snippet.json) doubles as the
template for any agent:
| Agent moment | Event |
|---|---|
| session starts / process launches | idle |
| prompt submitted / turn begins | turn_start |
| about to run a tool or shell command | tool_start |
| tool finished, agent resumes thinking | turn_start |
| response streaming to the user | text |
| waiting on permission / a question for the human | needs_attention |
| turn complete, output delivered | turn_end |
| unrecoverable failure | error |
| session exits (however it exits) | session_end |
If your agent only exposes a subset (say, just "done" notifications), map what
you have — a session that only ever sends turn_end/session_end still
renders correctly.
The generic emitter
hooks/pulse-emit <event> [session] [model] [in] [out] [cacheCreate] [cacheRead]
POSIX sh, no dependencies beyond noctalia on PATH. Omitted fields default to
?/0; omitting session sends a bare (default-slot) event. Env:
PULSE_TARGET overrides the dispatch id, PULSE_DRYRUN=1 prints the command
instead of running it. Examples:
pulse-emit turn_start mysess # state only
pulse-emit turn_end mysess gpt-5 12000 800 # with burn figures
pulse-emit session_end mysess # retire the slot
long_build && pulse-emit needs_attention ci # non-agent uses work too
Downstream: the claude.pulse state mirror
The widget is the single aggregator; subscribers (the orb, or any future
surface) never parse events themselves. On every event — never from the
animation timer — it publishes a rollup snapshot to noctalia shared state under
claude.pulse:
{ state = <most-urgent event name>, -- "idle" when no sessions
count = <live session count>,
model = <model or "?">, -- single-session only
tin = <in + cacheCreate>, -- one session's, or the Σ across all
tout = <output tokens>,
cr = <cacheRead; 0 when count > 1> }
Desktop widgets receive it via noctalia.state.watch("claude.pulse", cb); bar
widgets must poll state.get (watch doesn't fire on bars in noctalia 5.0.0).
Deployment invariant
The aggregator lives in the pulse bar widget — bar widgets only run when
placed on a bar. If pulse isn't in a bar layout, every event is silently
dropped and all subscribers freeze. Noctalia 5.0.0 has no headless plugin
entry kind, so "pulse on a bar" is a hard install requirement.