Files
community-plugins/claude-companion/PROTOCOL.md
T
d2aa9b9751 Update claude-companion to v1.3.0 (what its got: a lot) (#167)
* 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>
2026-07-30 21:34:52 -04:00

175 lines
8.1 KiB
Markdown

# The Pulse Protocol
An agent-agnostic contract for driving the `pulse-svc` service aggregator (and everything
downstream of it: the pulse bar widget, the presence orb, tooltips, `claude.pulse` subscribers). The
service 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>:<entry>` —
`lowcache/claude-companion:pulse-svc` (the headless aggregator service) for this
install. Adapters must treat it as configurable (`pulse-emit` reads `$PULSE_TARGET`).
- `all` addresses every monitor's widget instance. (`focused` or 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 N` or 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 animation are widget-side concerns
(see `VISUAL` in `pulse.luau`, and the `breath_speed`, `pulse_glow_floor`, and
`orb_swell` user settings in `README.md`) — 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).
- `model` is 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 shows
`cacheRead` separately. All-zero telemetry is fine — the burn line is simply
omitted (`model` of `?` 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 service guarantees)
- One slot per `session` id; re-sending updates the slot in place.
- The service aggregates the **most urgent** state across all live slots (priority
table above) into `claude.pulse`; widgets render this rollup and the tooltip lists
every session, most recent first, with a Σ burn total.
- `session_end` retires the slot. Nothing else does — a real session may sit
at `idle` or `turn_end` indefinitely and stays listed.
- Because only the trailing `session` field is read for routing, a `session_end`
whose payload populates *only* that field is a well-formed retire for one
session and nothing else: `,,,,,<session>`. The `sessions` panel's Retire
control emits exactly that, which is why manual retirement needs no new verb —
anything that can send `session_end` can already clear a stuck slot.
- **Payload-less events** (no CSV at all — e.g. a manual
`noctalia msg plugin … all needs_attention` poke from a terminal) land in a
single shared `default` slot. To keep CLI pokes from leaving a phantom
session, any **resting** event (`idle`, `turn_end`, `error`) retires the
`default` slot instead of updating it. Consequence for adapters: *always
send a session id*; the default slot is a test surface, not a home.
## Adapter contract
1. **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).
2. **Tag everything with the session id** (see above).
3. **Send cumulative telemetry or none** — don't send per-turn deltas.
4. 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:
```sh
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 headless `pulse-svc` **service** is the **single aggregator**; subscribers (the
bar dot, the orb, or any future surface) never parse events themselves. On every
event — never from a timer — it publishes a rollup snapshot to noctalia shared state
under `claude.pulse` (top-level fields below, plus a `sessions` array of per-session
`{sid,state,model,tin,tout,cr}` for multi-session tooltips):
```lua
{ 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> }
```
Both desktop and bar widgets receive it via `noctalia.state.watch("claude.pulse",
cb)` — state.watch fires across all of a plugin's runtimes as of the Noctalia 5 beta
(the earlier "bars must poll" limitation is gone).
## Deployment (retired invariant)
The aggregator is the headless `pulse-svc` **`[[service]]`** — it starts at shell
launch and runs with no surface, so event capture never depends on any widget being
placed. (Historically the aggregator lived in the `pulse` bar widget: if that widget
wasn't on a bar, every event was silently dropped and all subscribers froze — the
**D10** fragility. Retired by the `[[service]]` entry kind added in the Noctalia 5
beta; requires `plugin_api >= 3` on a service-capable build.) The bar dot and orb are
now pure subscribers of `claude.pulse`, so placing them is purely cosmetic.