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>
This commit is contained in:
J.B. Robinson
2026-07-30 21:34:52 -04:00
committed by GitHub
co-authored by lowcache Claude Opus 5
parent d113b03439
commit d2aa9b9751
13 changed files with 807 additions and 264 deletions
+34 -23
View File
@@ -1,8 +1,8 @@
# The Pulse Protocol # The Pulse Protocol
An agent-agnostic contract for driving the pulse bar widget (and everything An agent-agnostic contract for driving the `pulse-svc` service aggregator (and everything
downstream of it: the presence orb, tooltips, `claude.pulse` subscribers). The downstream of it: the pulse bar widget, the presence orb, tooltips, `claude.pulse` subscribers). The
widget knows nothing about Claude Code — it consumes **events** and an optional 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 **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 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. CI job, a cron script) can light up the same bar dot.
@@ -20,9 +20,9 @@ Two adapters ship in `hooks/`:
noctalia msg plugin <target> all <event> [payload] noctalia msg plugin <target> all <event> [payload]
``` ```
- `<target>` is the plugin dispatch id: `<plugin-id>:<widget-entry>` — - `<target>` is the plugin dispatch id: `<plugin-id>:<entry>` —
`lowcache/claude-companion:pulse` for this install. Adapters must treat it as `lowcache/claude-companion:pulse-svc` (the headless aggregator service) for this
configurable (`pulse-emit` reads `$PULSE_TARGET`). install. Adapters must treat it as configurable (`pulse-emit` reads `$PULSE_TARGET`).
- `all` addresses every monitor's widget instance. (`focused` or a bare - `all` addresses every monitor's widget instance. (`focused` or a bare
connector errors when the widget sits on multiple bars.) connector errors when the widget sits on multiple bars.)
- `[payload]` is a **single positional token** — noctalia's msg CLI splits on - `[payload]` is a **single positional token** — noctalia's msg CLI splits on
@@ -48,8 +48,9 @@ active; "resting" matters for the default-slot rule below.
| `session_end` | session is over — **retires** its slot | — | — | | `session_end` | session is over — **retires** its slot | — | — |
Unknown events render as idle-with-the-event-kept-as-state-word; stick to the 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 vocabulary. Glyph, accent color, and breath animation are widget-side concerns
(see `VISUAL` in `pulse.luau`) — the protocol only fixes the *semantics*. (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 ## Payload
@@ -72,14 +73,19 @@ model,in,out,cacheCreate,cacheRead,session
`?,0,0,0,0,<sid>`. State tracking, urgency priority, multi-session tooltip all `?,0,0,0,0,<sid>`. State tracking, urgency priority, multi-session tooltip all
work; you only lose the burn readout. work; you only lose the burn readout.
## Session semantics (what the widget guarantees) ## Session semantics (what the service guarantees)
- One slot per `session` id; re-sending updates the slot in place. - One slot per `session` id; re-sending updates the slot in place.
- The bar renders the **most urgent** state across all live slots (priority - The service aggregates the **most urgent** state across all live slots (priority
table above); the tooltip lists every session, most recent first, with a table above) into `claude.pulse`; widgets render this rollup and the tooltip lists
Σ burn total. every session, most recent first, with a Σ burn total.
- `session_end` retires the slot. Nothing else does — a real session may sit - `session_end` retires the slot. Nothing else does — a real session may sit
at `idle` or `turn_end` indefinitely and stays listed. 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 - **Payload-less events** (no CSV at all — e.g. a manual
`noctalia msg plugin … all needs_attention` poke from a terminal) land in a `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 single shared `default` slot. To keep CLI pokes from leaving a phantom
@@ -138,10 +144,11 @@ long_build && pulse-emit needs_attention ci # non-agent uses work too
## Downstream: the `claude.pulse` state mirror ## Downstream: the `claude.pulse` state mirror
The widget is the **single aggregator**; subscribers (the orb, or any future The headless `pulse-svc` **service** is the **single aggregator**; subscribers (the
surface) never parse events themselves. On every event — never from the bar dot, the orb, or any future surface) never parse events themselves. On every
animation timer — it publishes a rollup snapshot to noctalia shared state under event — never from a timer — it publishes a rollup snapshot to noctalia shared state
`claude.pulse`: 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 ```lua
{ state = <most-urgent event name>, -- "idle" when no sessions { state = <most-urgent event name>, -- "idle" when no sessions
@@ -152,12 +159,16 @@ animation timer — it publishes a rollup snapshot to noctalia shared state unde
cr = <cacheRead; 0 when count > 1> } cr = <cacheRead; 0 when count > 1> }
``` ```
Desktop widgets receive it via `noctalia.state.watch("claude.pulse", cb)`; bar Both desktop and bar widgets receive it via `noctalia.state.watch("claude.pulse",
widgets must poll `state.get` (watch doesn't fire on bars in noctalia 5.0.0). 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 invariant ## Deployment (retired invariant)
The aggregator lives in the `pulse` **bar widget** — bar widgets only run when The aggregator is the headless `pulse-svc` **`[[service]]`** — it starts at shell
placed on a bar. If `pulse` isn't in a bar layout, every event is silently launch and runs with no surface, so event capture never depends on any widget being
dropped and all subscribers freeze. Noctalia 5.0.0 has no headless plugin placed. (Historically the aggregator lived in the `pulse` bar widget: if that widget
entry kind, so "pulse on a bar" is a hard install requirement. 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.
+32 -12
View File
@@ -4,7 +4,7 @@
A Noctalia v5 plugin that puts [Claude Code](https://claude.com/claude-code)'s live status on your desktop — a **pulse** on the bar, a breathing **orb** on the desktop, and an **answer panel** for quick questions. A Noctalia v5 plugin that puts [Claude Code](https://claude.com/claude-code)'s live status on your desktop — a **pulse** on the bar, a breathing **orb** on the desktop, and an **answer panel** for quick questions.
![version](https://img.shields.io/badge/version-1.0.0-blue) ![license](https://img.shields.io/badge/license-MIT-informational) ![noctalia](https://img.shields.io/badge/noctalia-5.0.0-blueviolet) ![version](https://img.shields.io/badge/version-1.3.0-blue) ![license](https://img.shields.io/badge/license-MIT-informational) ![noctalia](https://img.shields.io/badge/noctalia-5.0.0-blueviolet)
Claude Code is a brilliant agent trapped in a text box. It can't see the windows you have open, can't tap you on the shoulder when it hits a wall, and gives you nothing to glance at while it churns. So you sit there watching a terminal, or you wander off and miss the moment it needed you. Claude Code is a brilliant agent trapped in a text box. It can't see the windows you have open, can't tap you on the shoulder when it hits a wall, and gives you nothing to glance at while it churns. So you sit there watching a terminal, or you wander off and miss the moment it needed you.
@@ -17,10 +17,10 @@ Don't run Claude Code? The signal bus is agent-agnostic — any agent, CI job, o
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| ID | `lowcache/claude-companion` | | ID | `lowcache/claude-companion` |
| Entries | Bar widget: `pulse`; desktop widget: `orb`; panel: `answer`; launcher: `claude` | | Entries | Service: `pulse-svc`; bar widget: `pulse`; desktop widget: `orb`; panels: `answer`, `sessions`; launcher: `claude` |
| Launcher Prefix | `/claude` | | Launcher Prefix | `/claude` |
Built and live-tested against Noctalia 5.0.0 (build `623210223c`), with an offline spec suite (`tests/shim_spec.py`) pinning the shim's compositor seam and its injection guards. Built and live-tested against Noctalia 5.0.0 (build `623210223c`), with an offline widget spec suite keeping the state machine honest.
## See it ## See it
@@ -34,13 +34,13 @@ Ask something quick with `/claude ?` and the whole answer waits for you in the p
## How it works ## How it works
**Perceive.** `shim/noctalia-mcp.py` is a stdio MCP shim that hands Claude a live read on your machine: your compositor's IPC for the windows you have open (it detects and speaks niri, Hyprland, or Sway), `playerctl` for what's playing, `noctalia msg status` for the state of the shell itself. Nothing to wire up by hand. Launch through `/claude` and it attaches itself. **Perceive.** `shim/noctalia-mcp.py` is a stdio MCP shim that hands Claude a live read on your machine: `niri msg -j` for the windows you have open, `playerctl` for what's playing, `noctalia msg status` for the state of the shell itself. Nothing to wire up by hand. Launch through `/claude` and it attaches itself.
**Practice.** Everything on the backend funnels through `claude.luau`, the `/claude` launcher and the one door in. It normalizes the event vocabulary, throws `notify-send` toasts, and calls `noctalia msg` to move panels around. One chokepoint on purpose — so when something acts up, there's exactly one place to go look. **Practice.** Everything on the backend funnels through `claude.luau`, the `/claude` launcher and the one door in. It normalizes the event vocabulary, throws `notify-send` toasts, and calls `noctalia msg` to move panels around. One chokepoint on purpose — so when something acts up, there's exactly one place to go look.
**Pulse.** `pulse.luau` sits on your bar and runs the show. Hook events land here over IPC, and from there it does the rest: tracks every session at once, surfaces whichever one's most urgent, breathes in your accent color, and mirrors the rollup into `noctalia.state` under `claude.pulse` for anyone downstream to read. **Pulse.** `pulse-svc.luau` is a headless `[[service]]` that runs the show. Hook events land here over IPC at `lowcache/claude-companion:pulse-svc`, and from there it does the rest: tracks every session at once, surfaces whichever one's most urgent, and publishes a rollup to shared state under `claude.pulse` for subscribers to read.
And downstream is where the quiet parts live. `orb.luau` is pure view. It subscribes to `claude.pulse` and breathes the same state frame by frame, glyph and opacity riding a sine wave, tempo picking up as things get urgent — no hooks, no logic of its own, just a reflection. `answer.luau` is the `answer` panel that catches a `/claude ?` reply and holds the whole thing: wrapped, scrollable, all the parts a toast lops off the end. And downstream is where the surfaces live. `pulse.luau` on the bar and `orb.luau` on the desktop are independent subscribers that only render. Both watch `claude.pulse` — `pulse.luau` breathes the accent color and shows per-session tooltips, while `orb.luau` breathes the same state frame by frame, glyph and opacity riding a sine wave, tempo picking up as things get urgent. Neither holds hooks or logic of its own. `answer.luau` is the `answer` panel that catches a `/claude ?` reply and holds the whole thing: wrapped, scrollable, all the parts a toast lops off the end.
## Requirements ## Requirements
@@ -62,7 +62,7 @@ noctalia msg plugins enable lowcache/claude-companion
Then, in order: Then, in order:
1. **Put the `pulse` widget on a bar** (Settings → Bar). Read the warning below first — this one isn't optional. 1. **(Optional) Put the `pulse` widget on a bar** (Settings → Bar) for the glanceable dot — capture no longer depends on it; the headless `pulse-svc` service does the listening.
2. Add the `orb` desktop widget if you want the ambient presence. 2. Add the `orb` desktop widget if you want the ambient presence.
3. Merge `hooks/settings.snippet.json` into `~/.claude/settings.json` so Claude's lifecycle hooks actually drive the pulse. 3. Merge `hooks/settings.snippet.json` into `~/.claude/settings.json` so Claude's lifecycle hooks actually drive the pulse.
4. Point Claude at `shim/noctalia-mcp.py` with `--mcp-config` to hand it the senses and hands. (Sessions you launch through `/claude` do this for you.) 4. Point Claude at `shim/noctalia-mcp.py` with `--mcp-config` to hand it the senses and hands. (Sessions you launch through `/claude` do this for you.)
@@ -70,16 +70,16 @@ Then, in order:
Prove it works: Prove it works:
```sh ```sh
noctalia msg plugin lowcache/claude-companion:pulse all needs_attention # bar icon → red bell noctalia msg plugin lowcache/claude-companion:pulse-svc all needs_attention # bar icon → red bell
noctalia msg plugin lowcache/claude-companion:pulse all idle # back to robot noctalia msg plugin lowcache/claude-companion:pulse-svc all idle # back to robot
``` ```
> [!WARNING] > [!WARNING]
> **`pulse` has to stay on a bar.** It's the sole aggregator — the one piece that hears the hooks and publishes the state everything else reads. Noctalia only runs bar widgets while they're placed on a bar, so the moment you pull `pulse` off, the plugin goes dark. The hooks keep firing into the void, the orb freezes on its last breath, and IPC pokes do nothing. If that happens, the fix is always the same: put `pulse` back on a bar. > **`pulse` no longer has to stay on a bar.** The sole aggregator is now the headless `pulse-svc` service, which starts with the shell and listens whether or not any widget is placed — so pulling the `pulse` dot off a bar just hides the glanceable icon; the orb keeps updating and hooks/IPC still land. This retires the old **D10** requirement, made possible by the `[[service]]` entry kind added in the Noctalia 5 beta.
## Usage ## Usage
`/claude <task>` opens a real Claude Code session in your terminal, shim already wired in. Bare `/claude` picks up where you left off (`claude --continue`). And `/claude ? <question>` is the quick one — a read-only ask that comes back as a toast and lands, in full, in the answer panel. Read-only is enforced, not assumed: the ask launches with no built-in tools, no MCP servers, and none of your Claude settings (so no hooks, plugins, or pre-authorized permissions), leaving it nothing but the model and your question. `/claude <task>` opens a real Claude Code session in your terminal, shim already wired in. Bare `/claude` picks up where you left off (`claude --continue`). And `/claude ? <question>` is the quick one — a read-only ask that comes back as a toast and lands, in full, in the answer panel.
That panel opens however you like it: click the pulse, use the "Show last answer" row under `/claude`, or toggle it from the CLI: That panel opens however you like it: click the pulse, use the "Show last answer" row under `/claude`, or toggle it from the CLI:
@@ -91,6 +91,26 @@ Leave it open and it refreshes live while suppressing the toast, so you're never
Hover the bar and the tooltip tells you where each session stands and what it's burning — input, output, cache reads. Run a few at once and you get a line per session plus a Σ total, with the icon always showing whichever one needs you most. Hover the bar and the tooltip tells you where each session stands and what it's burning — input, output, cache reads. Run a few at once and you get a line per session plus a Σ total, with the icon always showing whichever one needs you most.
**Right-click the pulse** for the sessions panel — the same rollup, but you can act on it. A tooltip disappears on the way to it; this doesn't. One row per live session with its state, model and burn, and a **Retire** button on each.
Retire is there for the one failure mode you'll actually hit: a session whose `SessionEnd` hook never fired — terminal killed, hook interrupted mid-distill — sits at idle forever and keeps inflating the count. Retiring it corrects the tally from the shell, without going back to a terminal. It adds no new protocol: a retire is the ordinary `session_end` event carrying that session's id, exactly what `hooks/pulse.py` sends.
```sh
noctalia msg panel-toggle lowcache/claude-companion:sessions
```
## Settings
The plugin declares three user settings, read via `noctalia.getConfig(<key>)`:
| Setting | Type | Range | Step | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `breath_speed` | double | 0.25–3.0 | 0.05 | 1.0 | Phase-rate multiplier for the breathing animation on both the bar dot and the desktop orb. Higher = faster. |
| `pulse_glow_floor` | double | 0.0–0.9 | 0.05 | 0.45 | How dim the bar dot gets at the trough of its breath. 0 = dims to black, higher = stays brighter. |
| `orb_swell` | double | 0.0–3.0 | 0.05 | 1.0 | How far the desktop orb glyph magnifies as it breathes. 0 = static size, higher = a bigger swing. |
There are no color settings — both surfaces follow the active theme palette via accent role names (`secondary`, `primary`, `error`).
## Wiring up other agents ## Wiring up other agents
None of this is Claude-specific under the hood. The pulse speaks a plain event format and doesn't care who's talking — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. [PROTOCOL.md](PROTOCOL.md) has the full eight-event vocabulary, the CSV payload, session semantics, and the adapter contract. The reference emitter, `hooks/pulse-emit`, is plain POSIX sh and needs nothing but `noctalia` on your PATH: None of this is Claude-specific under the hood. The pulse speaks a plain event format and doesn't care who's talking — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. [PROTOCOL.md](PROTOCOL.md) has the full eight-event vocabulary, the CSV payload, session semantics, and the adapter contract. The reference emitter, `hooks/pulse-emit`, is plain POSIX sh and needs nothing but `noctalia` on your PATH:
@@ -106,7 +126,7 @@ hooks/pulse-emit session_end mysess
A few things worth knowing before they surprise you: A few things worth knowing before they surprise you:
- Plugin panels render at `Layer::Top`, so an overlay window — a notification, a quake terminal, a polkit prompt — can sit on top of the answer panel. The answer's still there; clear the overlay and you'll see it. There's an upstream ask in for panel layer control. - Plugin panels render at `Layer::Top`, so an overlay window — a notification, a quake terminal, a polkit prompt — can sit on top of the answer panel. The answer's still there; clear the overlay and you'll see it. There's an upstream ask in for panel layer control.
- Bar widgets don't fire `state.watch` callbacks in Noctalia 5.0.0, so the plugin polls instead. Eight-digit hex alpha is ignored too — brightness is done by scaling RGB. - Eight-digit hex alpha is ignored by bar widgets — brightness is done by scaling RGB toward black. (Earlier builds didn't fire `state.watch` on bars, so the pulse polled; the Noctalia 5 beta fires it, so the bar dot is now event-driven like the orb.)
- Builtin and wallpaper-generated palettes have no on-disk JSON, so those fall back to fixed accent colors. Custom and community palettes are followed live, rechecked every ~8 s. - Builtin and wallpaper-generated palettes have no on-disk JSON, so those fall back to fixed accent colors. Custom and community palettes are followed live, rechecked every ~8 s.
- Quick-ask rides headless `claude -p`, which doesn't refresh an expired OAuth login token — only an interactive session does ([upstream](https://github.com/anthropics/claude-code/issues/53063)). The plugin checks the token's expiry before launching and, instead of burning the request on a guaranteed 401, tells you to open a terminal Claude session first; a failure it couldn't predict gets the same message in place of the raw API error. - Quick-ask rides headless `claude -p`, which doesn't refresh an expired OAuth login token — only an interactive session does ([upstream](https://github.com/anthropics/claude-code/issues/53063)). The plugin checks the token's expiry before launching and, instead of burning the request on a guaranteed 401, tells you to open a terminal Claude session first; a failure it couldn't predict gets the same message in place of the raw API error.
- The MCP shim is a Python prototype. A compiled port is the intended endgame. - The MCP shim is a Python prototype. A compiled port is the intended endgame.
+6 -5
View File
@@ -156,7 +156,9 @@ end
-- 401 whenever no terminal session has run recently. Two layers, both fail-open: -- 401 whenever no terminal session has run recently. Two layers, both fail-open:
-- a pre-flight that skips the launch when the token is positively expired, and -- a pre-flight that skips the launch when the token is positively expired, and
-- an error-text match that swaps the raw API error for the remedy. -- an error-text match that swaps the raw API error for the remedy.
-- (remedy text: tr("notify.auth_remedy")) -- Resolved per call, not once at load: a language change should be reflected in the
-- remedy text without a plugin reload.
local function auth_remedy() return tr("notify.auth_remedy") end
local CREDS_PATH = "~/.claude/.credentials.json" local CREDS_PATH = "~/.claude/.credentials.json"
@@ -284,9 +286,8 @@ function onActivate(id)
-- and the remedy needs a terminal anyway — so say so now and skip the -- and the remedy needs a terminal anyway — so say so now and skip the
-- launch. No claude.state touch: no ask session ever starts. -- launch. No claude.state touch: no ask session ever starts.
if token_expired() then if token_expired() then
local remedy = tr("notify.auth_remedy") publish_answer(ask, auth_remedy(), true)
publish_answer(ask, remedy, true) if not panel_showing() then noctalia.notifyError(tr("notify.title"), auth_remedy()) end
if not panel_showing() then noctalia.notifyError(tr("notify.title"), remedy) end
return return
end end
set_state(EVENT.turn_start) set_state(EVENT.turn_start)
@@ -316,7 +317,7 @@ function onActivate(id)
if final == "" then final = tr("notify.ask_failed") end if final == "" then final = tr("notify.ask_failed") end
-- a 401 that slipped past the pre-flight: deliver the remedy, not the -- a 401 that slipped past the pre-flight: deliver the remedy, not the
-- raw API error blob -- raw API error blob
if is_auth_error(final) then final = tr("notify.auth_remedy") end if is_auth_error(final) then final = auth_remedy() end
publish_answer(ask, final, true) publish_answer(ask, final, true)
if not panel_showing() then if not panel_showing() then
local body, clipped = preview(final) local body, clipped = preview(final)
+2 -2
View File
@@ -12,13 +12,13 @@
# Without one it sends a bare event, which lands in the widget's shared # 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. # "default" test slot — fine for a poke, wrong for a real integration.
# #
# Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse) # Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse-svc)
# PULSE_DRYRUN non-empty -> print the command instead of dispatching # PULSE_DRYRUN non-empty -> print the command instead of dispatching
# #
# Fail-open by contract: exits 0 no matter what, output swallowed, dispatch # 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. # capped at 3 s. A hook must never block or error the agent driving it.
TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse}" TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse-svc}"
event="${1:-idle}" event="${1:-idle}"
session="$2" session="$2"
+6 -6
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Pulse hook dispatcher (lowcache/claude-companion plugin). """Pulse hook dispatcher (lowcache/claude-companion plugin).
Bridges a Claude Code lifecycle hook to the pulse bar widget, enriching the 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. event with live model + token-burn telemetry parsed from the session transcript.
Invoked by the hooks in settings.snippet.json as: Invoked by the hooks in settings.snippet.json as:
@@ -10,16 +10,16 @@ Invoked by the hooks in settings.snippet.json as:
Hook JSON arrives on stdin (transcript_path, session_id). The widget is driven via Hook JSON arrives on stdin (transcript_path, session_id). The widget is driven via
noctalia's documented plugin IPC (`noctalia msg --help`): noctalia's documented plugin IPC (`noctalia msg --help`):
noctalia msg plugin lowcache/claude-companion:pulse all <event> [payload] 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 `[payload]` is a single positional token, so the payload is a SPACE-FREE CSV the
widget (pulse.luau) parses: aggregator service (pulse-svc.luau) parses:
model,in,out,cacheCreate,cacheRead,session model,in,out,cacheCreate,cacheRead,session
The `session` (short id) tags EVERY event, so the widget can track each concurrent 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 session separately. The matching SessionEnd hook fires `session_end`, which retires
the session in the widget and drops its token cache here. 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 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 last byte offset + running sums, so each hook reads only newly-appended transcript
@@ -36,7 +36,7 @@ import os
import subprocess import subprocess
import sys import sys
PLUGIN = "lowcache/claude-companion:pulse" PLUGIN = "lowcache/claude-companion:pulse-svc"
TARGET = "all" TARGET = "all"
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"_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.", "_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 headless aggregator service via `noctalia msg plugin lowcache/claude-companion:pulse-svc 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 service tracks each concurrent session separately and publishes the most urgent state + token-burn telemetry for the widget to render as a tooltip. SessionEnd fires `session_end`, retiring that session in the service 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": { "hooks": {
"SessionStart": [ "SessionStart": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py idle" } ] } { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py idle" } ] }
+29 -20
View File
@@ -34,21 +34,14 @@
-- ~4s), opacity floors stay high so the icon glows down rather than blinking to dark, -- ~4s), opacity floors stay high so the icon glows down rather than blinking to dark,
-- and the size swing (below) is gentle. Urgency reads as a *faster, deeper* breath, -- and the size swing (below) is gentle. Urgency reads as a *faster, deeper* breath,
-- but even "needs you" stays a breath, never a flicker. -- but even "needs you" stays a breath, never a flicker.
-- User-facing display strings live in translations/<lang>.json; the per-state
-- status word is keyed by state name ("state.orb.<state>") and resolved at render
-- time. cur.state is always a known state (the watch below maps anything else to
-- "idle"), so no fallback guard is needed here.
local function tr(key, args) return noctalia.tr(key, args) end
local function orb_word(state) return tr("state.orb." .. state) end
local VISUAL = { local VISUAL = {
idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80 }, idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80, word = "state.orb.idle" },
turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00 }, turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00, word = "state.orb.turn_start" },
text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00 }, text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00, word = "state.orb.text" },
tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00 }, tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00, word = "state.orb.tool_start" },
needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00 }, needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00, word = "state.orb.needs_attention" },
turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95 }, turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95, word = "state.orb.turn_end" },
error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00 }, error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00, word = "state.orb.error" },
} }
-- The orb IS the bar icon, swelling gently in and out — a desktop "bat signal". The -- The orb IS the bar icon, swelling gently in and out — a desktop "bat signal". The
@@ -61,6 +54,19 @@ local GLYPH_BOX = 84
-- Live status mirrored from the bar pulse. `count` 0 means no active sessions. -- Live status mirrored from the bar pulse. `count` 0 means no active sessions.
local cur = { state = "idle", count = 0, model = "?", tin = 0, tout = 0 } local cur = { state = "idle", count = 0, model = "?", tin = 0, tout = 0 }
local phase = 0.0 -- breath phase accumulator (seconds), wrapped per period local phase = 0.0 -- breath phase accumulator (seconds), wrapped per period
local breath_speed = 1.0 -- user setting (breath_speed): phase-rate multiplier
local orb_swell = 1.0 -- user setting (orb_swell): magnification swing multiplier
local frames = 0
local function tr(key, args) return noctalia.tr(key, args) end
-- Re-read user settings (cheap; called at load + every ~120 frames from onFrameTick).
local function read_settings()
local v = noctalia.getConfig and noctalia.getConfig("breath_speed")
if type(v) == "number" and v > 0 then breath_speed = v end
local sw = noctalia.getConfig and noctalia.getConfig("orb_swell")
if type(sw) == "number" and sw >= 0 then orb_swell = sw end
end
local function kfmt(n) local function kfmt(n)
n = tonumber(n) or 0 n = tonumber(n) or 0
@@ -79,20 +85,20 @@ end
-- One compact status line under the orb. Kept short so it doesn't get clipped by a -- One compact status line under the orb. Kept short so it doesn't get clipped by a
-- narrow widget box: just the state word, or a session count, plus burn when there's -- narrow widget box: just the state word, or a session count, plus burn when there's
-- a single attributable session. -- a single attributable session.
local function subtitle() local function subtitle(v)
if cur.count > 1 then if cur.count > 1 then
return tr("orb.sessions", { count = cur.count }) return tr("orb.sessions", { count = cur.count })
end end
if cur.count == 1 and cur.model ~= "?" and (cur.tin + cur.tout) > 0 then if cur.count == 1 and cur.model ~= "?" and (cur.tin + cur.tout) > 0 then
return orb_word(cur.state) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout) return tr(v.word) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout)
end end
return orb_word(cur.state) return tr(v.word)
end end
local function render() local function render()
local v, s = breath() local v, s = breath()
local opacity = v.omin + (v.omax - v.omin) * s local opacity = v.omin + (v.omax - v.omin) * s
local size = GLYPH_MIN + (GLYPH_MAX - GLYPH_MIN) * s -- the magnification swing local size = GLYPH_MIN + (GLYPH_MAX - GLYPH_MIN) * s * orb_swell -- swing scaled by orb_swell
desktopWidget.render(ui.column({ gap = 6, padding = 12, align = "center" }, { desktopWidget.render(ui.column({ gap = 6, padding = 12, align = "center" }, {
-- The "bat signal": the state icon itself, magnifying larger/smaller and breathing -- The "bat signal": the state icon itself, magnifying larger/smaller and breathing
@@ -105,7 +111,7 @@ local function render()
width = GLYPH_BOX, height = GLYPH_BOX, width = GLYPH_BOX, height = GLYPH_BOX,
}), }),
ui.label({ ui.label({
text = subtitle(), fontSize = 12, color = "on_surface_variant", text = subtitle(v), fontSize = 12, color = "on_surface_variant",
maxWidth = 180, maxLines = 1, textAlign = "center", maxWidth = 180, maxLines = 1, textAlign = "center",
}), }),
})) }))
@@ -118,7 +124,9 @@ end
-- unbounded. -- unbounded.
function onFrameTick(dt) function onFrameTick(dt)
local v = VISUAL[cur.state] or VISUAL.idle local v = VISUAL[cur.state] or VISUAL.idle
phase = (phase + (tonumber(dt) or 0) / 1000) % v.period phase = (phase + (tonumber(dt) or 0) / 1000 * breath_speed) % v.period
frames = frames + 1
if frames % 30 == 0 then read_settings() end
render() render()
end end
@@ -135,5 +143,6 @@ noctalia.state.watch("claude.pulse", function(snap)
end) end)
-- Breathe from the first frame, even before any session reports in (idle presence). -- Breathe from the first frame, even before any session reports in (idle presence).
read_settings()
desktopWidget.setNeedsFrameTick(true) desktopWidget.setNeedsFrameTick(true)
render() render()
+79 -12
View File
@@ -4,24 +4,80 @@
id = "lowcache/claude-companion" id = "lowcache/claude-companion"
name = "Claude Companion" name = "Claude Companion"
version = "1.0.1" version = "1.3.0"
# Oldest plugin API level this plugin needs (the [[panel]] kind, ui controls, # Plugin API level this manifest targets — mandatory as of the Noctalia 5 beta
# plugin IPC dispatch, the barWidget/onIpc surface). Bump when adopting a newer API. # manifest parser (replaces the older min_noctalia gate). 3 is the oldest-supported
# level and covers every feature this plugin uses ([[panel]], ui controls, plugin IPC
# dispatch, barWidget/onIpc). The [[service]] entry additionally needs a build that
# ships the Service entry kind (Noctalia 5 beta, upstream rev da014f72 or newer).
plugin_api = 3 plugin_api = 3
author = "lowcache" author = "lowcache"
license = "MIT" license = "MIT"
icon = "robot" icon = "robot"
description = "Claude Code companion: /claude launch + a telemetry-driven attention pulse." description = "Claude Code companion: /claude launch + a telemetry-driven attention pulse."
# The compositor CLI is one-of: the shim detects and speaks whichever of niri / # The compositor CLI (niri / hyprctl / swaymsg) is not listed: the shim detects and
# Hyprland / Sway is running Noctalia (so exactly one of niri/hyprctl/swaymsg is # speaks whichever of the three supported compositors is running Noctalia, so exactly
# present). See README "Requirements". # one is inherently present. See README "Requirements".
dependencies = ["claude", "python3", "niri", "hyprctl", "swaymsg", "playerctl", "notify-send", "nmcli", "ps", "tr", "timeout"] dependencies = ["claude", "python3", "playerctl", "notify-send", "nmcli", "ps", "tr", "timeout"]
tags = ["ai", "productivity", "bar", "desktop", "panel", "launcher", "niri", "hyprland", "sway"] tags = ["ai", "productivity", "bar", "desktop", "panel", "launcher"]
# The attention pulse — the visual centerpiece. Reads agent state from # ── User settings ─────────────────────────────────────────────────────────────
# noctalia.state ("claude.state") and from hook signals (onIpc), reflects it in the # Breath-speed multiplier for the pulse dot + orb animation (plugin-level, so both
# bar as a glyph + a sine-breath brightness glow (folded in from the barpulse # surfaces read the same value via noctalia.getConfig). 1.0 is the tuned default;
# A/B prototype; barpulse.luau stays in-repo as the experiment record, unregistered). # higher breathes faster, lower slower. Rendered as a slider; `step` MUST be set
# explicitly on every double — the manifest parser defaults step to 1.0
# (plugin_manifest.h: `double step = 1.0`), which snaps a fractional range to a
# handful of preset stops instead of sliding.
[[setting]]
key = "breath_speed"
type = "double"
label_key = "settings.breath_speed.label"
description_key = "settings.breath_speed.description"
default = 1.0
min = 0.25
max = 3.0
step = 0.05
# Minimum brightness the bar dot dims to at the trough of its breath (0 = fully dark,
# 0.9 = barely dims). 0.45 is the tuned default. (Plugin-level so it sits with the
# other look controls; pulse.luau reads it via getConfig.)
[[setting]]
key = "pulse_glow_floor"
type = "double"
label_key = "settings.pulse_glow_floor.label"
description_key = "settings.pulse_glow_floor.description"
default = 0.45
min = 0.0
max = 0.9
step = 0.05
# How far the orb glyph magnifies as it breathes (0 = static size, 1.0 = the tuned
# default, higher = a bigger swing). orb.luau reads it via getConfig.
[[setting]]
key = "orb_swell"
type = "double"
label_key = "settings.orb_swell.label"
description_key = "settings.orb_swell.description"
default = 1.0
min = 0.0
max = 3.0
step = 0.05
# The pulse aggregator (headless) — the single source of truth for Claude session
# state. Receives hook events via onIpc (noctalia msg plugin …:pulse-svc all <event>)
# and the launcher quick-ask via state.watch("claude.state"), rolls all sessions up,
# and publishes the snapshot to noctalia.state ("claude.pulse"). Runs at shell launch
# with no surface, so capture never depends on the bar widget being placed — this
# retires the old "pulse must sit on a bar" invariant (see PROTOCOL.md). Needs the
# [[service]] entry kind (Noctalia 5 beta / post-5.0.0).
[[service]]
id = "pulse-svc"
entry = "pulse-svc.luau"
# The attention pulse (bar) — the visual centerpiece. A pure subscriber: watches
# noctalia.state ("claude.pulse") published by pulse-svc and reflects it in the bar as
# a glyph + a sine-breath brightness glow (folded in from the barpulse A/B prototype;
# barpulse.luau stays in-repo as the experiment record, unregistered).
[[widget]] [[widget]]
id = "pulse" id = "pulse"
entry = "pulse.luau" entry = "pulse.luau"
@@ -43,6 +99,17 @@ entry = "answer.luau"
width = 460 width = 460
height = 420 height = 420
# The sessions panel — the actionable form of the bar tooltip. One row per live
# session (state, model, token burn) plus a retire control for a session whose
# SessionEnd hook never fired. Opens on a RIGHT-click of the bar pulse (left-click
# stays the answer panel), or via
# `noctalia msg panel-toggle lowcache/claude-companion:sessions`.
[[panel]]
id = "sessions"
entry = "sessions.luau"
width = 440
height = 360
# /claude — launch a real Claude Code session in the terminal, or a one-shot ask. # /claude — launch a real Claude Code session in the terminal, or a one-shot ask.
# The single backend chokepoint (invoke/parse) is inlined here: v5 has no plugin # The single backend chokepoint (invoke/parse) is inlined here: v5 has no plugin
# module system, and this is the only entry that talks to a model. # module system, and this is the only entry that talks to a model.
+170
View File
@@ -0,0 +1,170 @@
-- The pulse aggregator (headless [[service]]) — the single source of truth for
-- Claude session state across ALL active sessions. This is the reflex half of the
-- attention pulse, split out of the bar widget (pulse.luau) so capture no longer
-- depends on the bar dot being placed: a [[service]] runtime starts at shell launch
-- and stays alive regardless of surfaces, retiring the old "pulse must sit on a bar"
-- invariant (D10 / PROTOCOL.md "Deployment invariant").
--
-- Two feeds converge on the session table, both event-driven (no polling):
-- • Claude Code hooks → onIpc (the reflex):
-- noctalia msg plugin lowcache/claude-companion:pulse-svc all <event> [payload]
-- payload = "model,in,out,cacheCreate,cacheRead,session" (hooks/pulse.py).
-- Each real session is tracked by its id; session_end removes it.
-- • claude.luau writes "claude.state" (the launcher quick-ask) → watched here as
-- one ephemeral pseudo-session ("ask"), removed when the ask completes.
--
-- On every change it republishes a rollup to noctalia.state ("claude.pulse"); the
-- bar dot (pulse.luau) and the desktop orb (orb.luau) are pure subscribers of that
-- key — one source of truth, two surfaces. The service defines NO update(): it is
-- purely event-driven, so the host's per-service timer tick is a cheap no-op.
-- ── priority (aggregation only) ──────────────────────────────────────────────
-- With several sessions in different states, the rollup reports the most urgent:
-- a session that needs you outranks one merely working, which outranks one idle.
local STATE_PRIO = {
needs_attention = 6, error = 5, tool_start = 4,
turn_start = 3, text = 3, turn_end = 2, idle = 1,
}
-- sid -> { sid, state, model, tin, tout, cr, seq }. `seq` is a monotonic counter
-- (no os.time dependency in the sandbox) used to order sessions by recency.
local sessions = {}
local seq = 0
local last_ask = nil -- last claude.state value folded into the "ask" session
-- ── payload plumbing ─────────────────────────────────────────────────────────
local function split(s, sep)
local out = {}
for part in (s .. sep):gmatch("(.-)" .. sep) do out[#out + 1] = part end
return out
end
-- "model,in,out,cacheCreate,cacheRead,session" -> a session delta, or nil. "in"
-- (fresh prompt tokens) is tiny next to cache reads, so the displayed input is
-- fresh + cache-create (full-rate work); cache reads are tracked separately.
local function parse_payload(tel)
if not tel or tel == "" then return nil end
local f = split(tel, ",")
local sid = f[6]
if not sid or sid == "" then return nil end
return {
sid = sid,
model = (f[1] and f[1] ~= "") and f[1] or "?",
tin = (tonumber(f[2]) or 0) + (tonumber(f[4]) or 0),
tout = tonumber(f[3]) or 0,
cr = tonumber(f[5]) or 0,
}
end
local function has_burn(s)
return s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0
end
local function ordered()
local arr = {}
for _, s in pairs(sessions) do arr[#arr + 1] = s end
table.sort(arr, function(a, b) return (a.seq or 0) > (b.seq or 0) end)
return arr
end
local function touch(sid, state, p)
seq = seq + 1
local s = sessions[sid] or { sid = sid }
s.state = state
s.seq = seq
if p then
s.model, s.tin, s.tout, s.cr = p.model, p.tin, p.tout, p.cr
end
sessions[sid] = s
end
-- ── publish ──────────────────────────────────────────────────────────────────
-- Roll every live session up to the most-urgent state + burn totals and mirror it
-- to shared state. Both surfaces subscribe to "claude.pulse" and never re-derive it.
-- Schema (v2): top-level fields (state/count/model/tin/tout/cr) are the single-glance
-- rollup the orb reads — single-session values when count==1, the Σ when >1. The
-- `sessions` array (most-recent first) carries per-session detail for the bar's
-- multi-session tooltip; the orb ignores it, so the top-level shape stays backward
-- compatible. Published only on events (never a timer), so subscribers aren't spammed.
local function publish()
local arr = ordered()
local count = #arr
local best, bestp = "idle", 0
for _, s in ipairs(arr) do
local p = STATE_PRIO[s.state] or 0
if p > bestp then bestp = p; best = s.state end
end
local snap = { state = best, count = count, model = "?", tin = 0, tout = 0, cr = 0, sessions = {} }
for i, s in ipairs(arr) do
snap.sessions[i] = {
sid = s.sid, state = s.state, model = s.model or "?",
tin = s.tin or 0, tout = s.tout or 0, cr = s.cr or 0,
}
end
if count == 1 and has_burn(arr[1]) then
local s = arr[1]
snap.model, snap.tin, snap.tout, snap.cr = s.model, s.tin, s.tout, s.cr
elseif count > 1 then
local Tin, Tout = 0, 0
for _, s in ipairs(arr) do
if has_burn(s) then Tin = Tin + s.tin; Tout = Tout + s.tout end
end
snap.tin, snap.tout = Tin, Tout
end
noctalia.state.set("claude.pulse", snap)
end
-- ── quick-ask feed (launcher) ────────────────────────────────────────────────
-- claude.luau publishes the /claude ? stream state to "claude.state" (no session id,
-- no telemetry). Shown while streaming, dropped when it finishes — the answer is
-- delivered via notify, so a lingering "done" would only inflate the session count.
-- Event-driven via state.watch (cross-runtime in Noctalia 5 beta), so the service
-- holds no timer for it. Only a *change* touches the session table + republishes.
local function fold_ask(v)
local s = (type(v) == "string" and v ~= "") and v or nil
if s == last_ask then return end
last_ask = s
if s == nil or s == "turn_end" or s == "error" then
sessions["ask"] = nil
else
touch("ask", s, nil)
end
publish()
end
-- ── hook reflex ──────────────────────────────────────────────────────────────
-- Per-session state + token telemetry, full lifecycle. The dispatcher always tags
-- the event with a session id; session_end (the Claude Code SessionEnd hook) retires
-- the session so stale entries never accumulate.
--
-- A payload-less event carries no session id (real hook events always do) — only a
-- manual `noctalia msg … :pulse-svc all <event>` poke from the CLI does. Those land
-- in a single "default" test slot. To keep such a poke from leaving a sticky phantom
-- session, any RESTING state (idle / turn_end / error) retires "default" too — so
-- `… all idle` cleanly clears the orb after a manual test, without a plugin reload.
local MANUAL_REST = { idle = true, turn_end = true, error = true }
function onIpc(event, payload)
if type(event) ~= "string" then return end
local p = parse_payload(payload)
local sid = (p and p.sid) or "default"
if event == "session_end" or (sid == "default" and MANUAL_REST[event]) then
sessions[sid] = nil
else
touch(sid, event, p)
end
publish()
end
-- ── init ─────────────────────────────────────────────────────────────────────
-- Watch the quick-ask channel, then fold any value already present (a stream in
-- flight when the service (re)loads) and publish an initial idle rollup so a
-- subscriber that reads "claude.pulse" before the first event sees a valid state.
noctalia.state.watch("claude.state", fold_ask)
local init = noctalia.state.get and noctalia.state.get("claude.state")
if type(init) == "string" and init ~= "" then
last_ask = init
if not (init == "turn_end" or init == "error") then touch("ask", init, nil) end
end
publish()
+120 -181
View File
@@ -1,26 +1,19 @@
-- The attention pulse (bar widget) — a glanceable live readout of Claude across -- The attention pulse (bar widget) — a glanceable live readout of Claude across all
-- ALL active sessions. Two feeds converge on the session table: -- active sessions. This is the VIEW half of the pulse: a pure subscriber. The
-- • Claude Code hooks fire the plugin-dispatch IPC → onIpc here (the reflex): -- aggregator (pulse-svc.luau, a headless [[service]]) owns all session bookkeeping and
-- noctalia msg plugin lowcache/claude-companion:pulse all <event> [payload] -- publishes a rollup to noctalia.state ("claude.pulse"); this widget watches that key
-- payload = "model,in,out,cacheCreate,cacheRead,session" (hooks/pulse.py). -- and reflects it in the bar — a glyph (Tabler icon name; an unknown name renders the
-- Each real session is tracked by its id; SessionEnd removes it. -- skull fallback) whose accent color breathes a raised-cosine BRIGHTNESS glow (the bar
-- • claude.luau writes "claude.state" (the launcher quick-ask) → POLLED here (bar -- ignores 8-digit #RRGGBBAA alpha, but a 6-digit #RRGGBB scaled toward black reads as a
-- widgets don't fire state.watch in v5 — D8 / README) as one ephemeral pseudo- -- glow). Discrete state (glyph/tooltip) renders on each snapshot; the 60 ms timer only
-- session ("ask"), removed when the ask completes. -- advances the breath. A state change snaps the breath to its peak, so transitions read
-- as a bright flash before settling into the rhythm.
-- --
-- Bar plugin widgets have no per-frame tick (that's desktop-only), so the breath -- The companion presence orb (orb.luau, a [[desktop_widget]]) subscribes to the same
-- runs on noctalia.setUpdateInterval(ms) + the global update(): a raised-cosine -- "claude.pulse" key: one source of truth (the service), the bar dot and the orb are
-- BRIGHTNESS breath (the winner of the barpulse A/B prototype — the bar ignores -- two views of it. Bar widgets DO receive noctalia.state.watch callbacks as of the
-- 8-digit #RRGGBBAA alpha, but a 6-digit #RRGGBB scaled toward black reads as a -- Noctalia 5 beta (the old "bars must poll" workaround is gone). The bar API is the
-- glow). Discrete state (glyph/tooltip/session rollup) renders on events; the -- `barWidget.*` table (NOT `widget`).
-- timer only advances the breath. A state change snaps the breath to its peak,
-- so transitions read as a bright flash before settling into the rhythm.
--
-- The bar API is the `barWidget.*` table (NOT `widget`). Glyph names are Tabler
-- icon names; an unknown name renders the skull fallback.
-- The companion presence orb (orb.luau, a [[desktop_widget]]) is a pure view:
-- this bar dot mirrors its session rollup to noctalia.state ("claude.pulse", at the
-- end of render below) and the orb subscribes — same state map, two surfaces.
-- ── live palette ───────────────────────────────────────────────────────────── -- ── live palette ─────────────────────────────────────────────────────────────
-- Accent roles follow the global scheme so the dot matches the other bar -- Accent roles follow the global scheme so the dot matches the other bar
@@ -43,6 +36,8 @@ local function xdg(env, fallback)
return noctalia.expandPath(fallback) return noctalia.expandPath(fallback)
end end
local function tr(key, args) return noctalia.tr(key, args) end
-- Minimal [theme] reader: walk lines, track the section, keep quoted k/v pairs. -- Minimal [theme] reader: walk lines, track the section, keep quoted k/v pairs.
-- (Indented subsections like [theme.templates] end the block; their keys are -- (Indented subsections like [theme.templates] end the block; their keys are
-- arrays/bools and would not match the quoted-string pattern anyway.) -- arrays/bools and would not match the quoted-string pattern anyway.)
@@ -87,58 +82,54 @@ local function resolve_accents()
ACCENTS.error = hex(m.mError) or ACCENTS.error ACCENTS.error = hex(m.mError) or ACCENTS.error
end end
-- User-facing display strings live in translations/<lang>.json; resolve at call
-- time. Per-state tips ("state.tip.<state>") and compact words ("state.word.
-- <state>") are keyed by state name. KNOWN_STATE guards a bogus state from a
-- manual CLI poke so it falls back to raw text / idle exactly as the old tables did.
local function tr(key, args) return noctalia.tr(key, args) end
local KNOWN_STATE = {
idle = true, turn_start = true, text = true, tool_start = true,
needs_attention = true, turn_end = true, error = true,
}
local function state_tip(s) return tr("state.tip." .. (KNOWN_STATE[s] and s or "idle")) end
local function state_word(s) return KNOWN_STATE[s] and tr("state.word." .. s) or s end
-- ── state map ──────────────────────────────────────────────────────────────── -- ── state map ────────────────────────────────────────────────────────────────
-- `color` names an ACCENTS role; `period` is the breath cycle in seconds — -- `color` names an ACCENTS role; `period` is the breath cycle in seconds —
-- urgency reads as tempo (needs-you breathes fast, idle slow). Tooltip text is -- urgency reads as tempo (needs-you breathes fast, idle slow).
-- resolved from translations by state name (state_tip/state_word above).
local VISUAL = { local VISUAL = {
idle = { glyph = "robot", color = "secondary", period = 8.0 }, idle = { glyph = "robot", color = "secondary", tip = "state.tip.idle", period = 8.0 },
turn_start = { glyph = "brain", color = "primary", period = 5.0 }, turn_start = { glyph = "brain", color = "primary", tip = "state.tip.turn_start", period = 5.0 },
text = { glyph = "message-dots", color = "primary", period = 4.5 }, text = { glyph = "message-dots", color = "primary", tip = "state.tip.text", period = 4.5 },
tool_start = { glyph = "tool", color = "secondary", period = 5.0 }, tool_start = { glyph = "tool", color = "secondary", tip = "state.tip.tool_start", period = 5.0 },
needs_attention = { glyph = "bell-ringing", color = "error", period = 3.0 }, needs_attention = { glyph = "bell-ringing", color = "error", tip = "state.tip.needs_attention", period = 3.0 },
turn_end = { glyph = "bell", color = "primary", period = 5.5 }, turn_end = { glyph = "bell", color = "primary", tip = "state.tip.turn_end", period = 5.5 },
error = { glyph = "alert-triangle", color = "error", period = 3.5 }, error = { glyph = "alert-triangle", color = "error", tip = "state.tip.error", period = 3.5 },
} }
-- With several sessions in different states, the bar shows the most urgent one: -- Compact per-session words for the multi-session tooltip (Stage 2, once the
-- a session that needs you outranks one merely working, which outranks one idle. -- rollup carries a per-session list).
local STATE_PRIO = { local STATE_WORD = {
needs_attention = 6, error = 5, tool_start = 4, idle = "state.word.idle", turn_start = "state.word.turn_start", text = "state.word.text",
turn_start = 3, text = 3, turn_end = 2, idle = 1, tool_start = "state.word.tool_start", needs_attention = "state.word.needs_attention",
turn_end = "state.word.turn_end", error = "state.word.error",
} }
-- sid -> { sid, state, model, tin, tout, cr, seq }. `seq` is a monotonic counter
-- (no os.time dependency in the widget sandbox) used to order by recency.
local sessions = {}
local seq = 0
-- ── breath ─────────────────────────────────────────────────────────────────── -- ── breath ───────────────────────────────────────────────────────────────────
local INTERVAL_MS = 60 -- ~16 fps re-render (bars can't do 60 fps) local INTERVAL_MS = 60 -- ~16 fps re-render (bars can't do 60 fps)
local BMIN, BMAX = 0.45, 1.00 -- brightness floor/ceiling the glyph breathes between local BMIN, BMAX = 0.45, 1.00 -- brightness floor/ceiling the glyph breathes between
local PALETTE_EVERY = 128 -- re-resolve accents every ~7.7 s so theme changes follow local PALETTE_EVERY = 128 -- re-resolve accents every ~7.7 s so theme changes follow
local SETTINGS_EVERY = 16 -- re-read user settings ~1 s so slider drags apply promptly
local cur = "idle" -- most-urgent state across sessions (drives glyph + tempo) -- Latest rollup from the aggregator (pulse-svc), received via state.watch. `cur`
-- tracks the state currently driving the glyph + tempo so a change can snap the breath.
local snap = { state = "idle", count = 0, model = "?", tin = 0, tout = 0, cr = 0 }
local cur = "idle"
local phase = VISUAL.idle.period / 2 -- breath clock, seconds; born at peak brightness local phase = VISUAL.idle.period / 2 -- breath clock, seconds; born at peak brightness
local ticks = 0 local ticks = 0
local last_ask = nil -- last claude.state seen by the quick-ask poll (below) local breath_speed = 1.0 -- user setting (breath_speed): phase-rate multiplier
local glow_floor = BMIN -- user setting (pulse_glow_floor): brightness floor at the breath trough
local function level_for(period) -- raised-cosine brightness in [BMIN, BMAX] -- Re-read user settings (cheap; called at load + periodically from update()).
local function read_settings()
local v = noctalia.getConfig and noctalia.getConfig("breath_speed")
if type(v) == "number" and v > 0 then breath_speed = v end
local g = noctalia.getConfig and noctalia.getConfig("pulse_glow_floor")
if type(g) == "number" and g >= 0 and g < BMAX then glow_floor = g end
end
local function level_for(period) -- raised-cosine brightness in [glow_floor, BMAX]
local t = phase % period local t = phase % period
local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi) local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi)
return BMIN + (BMAX - BMIN) * s return glow_floor + (BMAX - glow_floor) * s
end end
-- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB". -- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB".
@@ -156,13 +147,7 @@ local function paint()
barWidget.setGlyphColor(dimmed(ACCENTS[v.color] or ACCENTS.secondary, level_for(v.period))) barWidget.setGlyphColor(dimmed(ACCENTS[v.color] or ACCENTS.secondary, level_for(v.period)))
end end
-- ── session plumbing ───────────────────────────────────────────────────────── -- ── tooltip helpers ──────────────────────────────────────────────────────────
local function split(s, sep)
local out = {}
for part in (s .. sep):gmatch("(.-)" .. sep) do out[#out + 1] = part end
return out
end
local function kfmt(s) local function kfmt(s)
local n = tonumber(s) or 0 local n = tonumber(s) or 0
if n >= 1e6 then return string.format("%.1fM", n / 1e6) end if n >= 1e6 then return string.format("%.1fM", n / 1e6) end
@@ -170,23 +155,6 @@ local function kfmt(s)
return tostring(math.floor(n)) return tostring(math.floor(n))
end end
-- "model,in,out,cacheCreate,cacheRead,session" -> a session delta, or nil. "in"
-- (fresh prompt tokens) is tiny next to cache reads, so the displayed input is
-- fresh + cache-create (full-rate work); cache reads are tracked separately.
local function parse_payload(tel)
if not tel or tel == "" then return nil end
local f = split(tel, ",")
local sid = f[6]
if not sid or sid == "" then return nil end
return {
sid = sid,
model = (f[1] and f[1] ~= "") and f[1] or "?",
tin = (tonumber(f[2]) or 0) + (tonumber(f[4]) or 0),
tout = tonumber(f[3]) or 0,
cr = tonumber(f[5]) or 0,
}
end
local function has_burn(s) local function has_burn(s)
return s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0 return s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0
end end
@@ -197,128 +165,82 @@ local function burn_line(s)
return l return l
end end
local function ordered() -- ── render (pure view of the rollup) ─────────────────────────────────────────
local arr = {} -- Glyph + accent for the most-urgent state, a tooltip, and a breath snapped to peak
for _, s in pairs(sessions) do arr[#arr + 1] = s end -- on a state change. The service is the single aggregator; this only views its rollup.
table.sort(arr, function(a, b) return (a.seq or 0) > (b.seq or 0) end) -- The multi-session tooltip currently shows count + Σ burn; per-session lines return
return arr -- in Stage 2 when the rollup carries a `sessions` list (see STATE_WORD).
end
local function render() local function render()
local arr = ordered() if snap.state ~= cur then
local count = #arr cur = snap.state
phase = (VISUAL[cur] or VISUAL.idle).period / 2 -- snap the breath to its peak (bright flash)
local best, bestp = "idle", 0
for _, s in ipairs(arr) do
local p = STATE_PRIO[s.state] or 0
if p > bestp then bestp = p; best = s.state end
end
if best ~= cur then
cur = best
-- snap the breath to its peak so a state change reads as a bright flash
phase = (VISUAL[cur] or VISUAL.idle).period / 2
end end
local v = VISUAL[cur] or VISUAL.idle local v = VISUAL[cur] or VISUAL.idle
barWidget.setGlyph(v.glyph) barWidget.setGlyph(v.glyph)
paint() paint()
local tip local tip
if count == 0 then if snap.count == 0 then
tip = state_tip("idle") tip = tr(VISUAL.idle.tip)
elseif count == 1 then elseif snap.count == 1 then
local s = arr[1] tip = has_burn(snap) and (tr(v.tip) .. "\n" .. burn_line(snap)) or tr(v.tip)
local base = state_tip(s.state)
tip = has_burn(s) and (base .. "\n" .. burn_line(s)) or base
else else
local lines = { tr("pulse.sessions_header", { count = count }) } -- A thin rule separates each session so concurrent sessions read as distinct
-- blocks rather than one wall of text (fixed width; tooltip font is proportional
-- so it reads as a divider, not a measured column).
local DIV = "──────────────"
local lines = { tr("pulse.sessions_header", { count = snap.count }) }
local Tin, Tout = 0, 0 local Tin, Tout = 0, 0
for _, s in ipairs(arr) do for i, s in ipairs(snap.sessions) do
local line = s.sid .. " · " .. state_word(s.state) if i > 1 then lines[#lines + 1] = DIV end
local sw = STATE_WORD[s.state] and tr(STATE_WORD[s.state]) or s.state
local line = s.sid .. " · " .. sw
if has_burn(s) then if has_burn(s) then
line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout) line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout)
Tin = Tin + s.tin; Tout = Tout + s.tout Tin = Tin + s.tin; Tout = Tout + s.tout
end end
lines[#lines + 1] = line lines[#lines + 1] = line
end end
if (Tin + Tout) > 0 then -- fallback to the rollup's Σ figures if no per-session list was published
if #snap.sessions == 0 and (snap.tin + snap.tout) > 0 then
lines[#lines + 1] = DIV
lines[#lines + 1] = tr("pulse.total", { tin = kfmt(snap.tin), tout = kfmt(snap.tout) })
elseif (Tin + Tout) > 0 then
lines[#lines + 1] = DIV
lines[#lines + 1] = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) }) lines[#lines + 1] = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) })
end end
tip = table.concat(lines, "\n") tip = table.concat(lines, "\n")
end end
barWidget.setTooltip(tip) barWidget.setTooltip(tip)
-- Mirror the rollup to shared state so the presence orb (orb.luau, a separate
-- desktop widget) renders the same status without re-deriving it. The bar dot
-- stays the single place that aggregates sessions; the orb is a pure subscriber.
-- Published only here (events), never from the breath timer, so orb watchers
-- aren't spammed 16×/s. Snapshot carries only what the orb needs: the most-
-- urgent state, the session count, and burn totals (single-session figures
-- when count==1, the sum when >1).
local snap = { state = best, count = count, model = "?", tin = 0, tout = 0, cr = 0 }
if count == 1 and has_burn(arr[1]) then
local s = arr[1]
snap.model, snap.tin, snap.tout, snap.cr = s.model, s.tin, s.tout, s.cr
elseif count > 1 then
local Tin, Tout = 0, 0
for _, s in ipairs(arr) do
if has_burn(s) then Tin = Tin + s.tin; Tout = Tout + s.tout end
end
snap.tin, snap.tout = Tin, Tout
end
noctalia.state.set("claude.pulse", snap)
end end
local function touch(sid, state, p) -- Normalize + adopt a "claude.pulse" snapshot, then render. Defensive against a
seq = seq + 1 -- malformed or partial payload (state store round-trips values as JSON). The
local s = sessions[sid] or { sid = sid } -- `sessions` array (v2) feeds the multi-session tooltip; older publishers omit it.
s.state = state local function apply(s)
s.seq = seq if type(s) ~= "table" then return end
if p then local sess = {}
s.model, s.tin, s.tout, s.cr = p.model, p.tin, p.tout, p.cr if type(s.sessions) == "table" then
for i, e in ipairs(s.sessions) do
if type(e) == "table" then
sess[i] = {
sid = tostring(e.sid or "?"),
state = type(e.state) == "string" and e.state or "idle",
model = (type(e.model) == "string" and e.model ~= "") and e.model or "?",
tin = tonumber(e.tin) or 0, tout = tonumber(e.tout) or 0, cr = tonumber(e.cr) or 0,
}
end end
sessions[sid] = s
end
-- launcher quick-ask path (no session id, no telemetry). Ephemeral: shown while
-- streaming, dropped when it finishes — the answer is delivered via notify, so a
-- lingering "done" would only inflate the session count.
--
-- POLLED, not watched: bar widgets don't fire noctalia.state.watch callbacks in
-- v5 (D8 / README "Rough edges"), so update() reads claude.state each tick the
-- same way it re-reads the palette. Only a *change* touches the session table and
-- re-renders, so a steady state costs a single state.get and nothing more.
local function poll_ask()
local s = noctalia.state.get and noctalia.state.get("claude.state")
if type(s) ~= "string" then s = nil end
if s == last_ask then return end
last_ask = s
if s == nil or s == "turn_end" or s == "error" then
sessions["ask"] = nil
else
touch("ask", s, nil)
end end
render()
end
-- hook reflex path: per-session state + token telemetry, full lifecycle. The
-- dispatcher always tags the event with a session id; `session_end` (the Claude
-- Code SessionEnd hook) retires the session so stale entries never accumulate.
--
-- A payload-less event carries no session id (real hook events always do) — only a
-- manual `noctalia msg … :pulse all <event>` poke from the CLI does. Those land in a
-- single "default" test slot. To keep such a poke from leaving a sticky phantom
-- session, any RESTING state (idle / turn_end / error) retires "default" too — so
-- `… all idle` cleanly clears the orb after a manual test, without a plugin reload.
local MANUAL_REST = { idle = true, turn_end = true, error = true }
function onIpc(event, payload)
if type(event) ~= "string" then return end
local p = parse_payload(payload)
local sid = (p and p.sid) or "default"
if event == "session_end" or (sid == "default" and MANUAL_REST[event]) then
sessions[sid] = nil
else
touch(sid, event, p)
end end
snap = {
state = (type(s.state) == "string" and VISUAL[s.state]) and s.state or "idle",
count = tonumber(s.count) or 0,
model = (type(s.model) == "string" and s.model ~= "") and s.model or "?",
tin = tonumber(s.tin) or 0,
tout = tonumber(s.tout) or 0,
cr = tonumber(s.cr) or 0,
sessions = sess,
}
render() render()
end end
@@ -334,19 +256,36 @@ function onClick()
end end
end end
-- Right-click opens the sessions panel: the tooltip already lists concurrent
-- sessions, but you cannot act on a tooltip — it disappears on the way to it. Same
-- panel-open (not -toggle) reasoning as onClick above. onRightClick needs no
-- plugin_api bump: the dispatch in plugin_widget.cpp is ungated (the level-14
-- gate covers the DECLARATIVE `actions` manifest table, not these globals).
function onRightClick()
if not noctalia.runAsync("noctalia msg panel-open 'lowcache/claude-companion:sessions'") then
noctalia.notifyError(tr("pulse.title"), tr("pulse.panel_launch_failed"))
end
end
-- Breath timer. Re-arm the interval each tick (the pattern proven live in the -- Breath timer. Re-arm the interval each tick (the pattern proven live in the
-- barpulse prototype), advance the clock, repaint the glyph brightness only — -- barpulse prototype), advance the clock, repaint the glyph brightness only —
-- glyph/tooltip/rollup are event-driven in render(). -- glyph/tooltip are event-driven in render() (fired from the state.watch below).
function update() function update()
noctalia.setUpdateInterval(INTERVAL_MS) noctalia.setUpdateInterval(INTERVAL_MS)
phase = phase + INTERVAL_MS / 1000 phase = phase + INTERVAL_MS / 1000 * breath_speed
if phase > 1e6 then phase = 0 end if phase > 1e6 then phase = 0 end
ticks = ticks + 1 ticks = ticks + 1
if ticks % PALETTE_EVERY == 0 then resolve_accents() end if ticks % PALETTE_EVERY == 0 then resolve_accents() end
poll_ask() -- surface quick-ask state changes (bar widgets can't watch) if ticks % SETTINGS_EVERY == 0 then read_settings() end
paint() paint()
end end
-- Subscribe to the aggregator's rollup, seed from any snapshot already published
-- before we subscribed (the service publishes at launch), then paint an initial
-- frame even if nothing has reported yet (defaults to idle).
noctalia.state.watch("claude.pulse", apply)
resolve_accents() resolve_accents()
read_settings()
noctalia.setUpdateInterval(INTERVAL_MS) noctalia.setUpdateInterval(INTERVAL_MS)
apply(noctalia.state.get and noctalia.state.get("claude.pulse"))
render() render()
+171
View File
@@ -0,0 +1,171 @@
-- The sessions panel — per-session detail and actuation for every live Claude
-- session. Right-click the bar pulse to open it (onClick keeps the answer panel).
--
-- The bar tooltip already lists concurrent sessions, but a tooltip cannot be acted
-- on: it vanishes on the way to it. This panel is the actionable form of the same
-- rollup — one row per session with its state, model and token burn, plus a retire
-- control for the one documented failure mode: a session that ended without its
-- SessionEnd hook firing (terminal killed, hook interrupted mid-distill) sits at
-- idle forever and keeps inflating the count. Retiring it is a local correction
-- that needs no terminal.
--
-- Pure subscriber, same doctrine as the orb and the answer panel: pulse-svc owns the
-- session table, this panel only renders it and speaks the documented IPC vocabulary
-- back. It adds NO new protocol — a retire is the ordinary `session_end` event with
-- a session-tagged payload, exactly what hooks/pulse.py sends (see PROTOCOL.md).
local KEY = "claude.pulse"
local SVC = "lowcache/claude-companion:pulse-svc"
local function tr(key, args) return noctalia.tr(key, args) end
-- Panel surface is 440 logical px (plugin.toml); leave room for the scrollbar.
local WRAP = 388
local PAD = 14
local ERROR_RGB = "#FF4D1F"
-- Compact per-session words, same vocabulary as the bar tooltip.
local STATE_WORD = {
idle = "state.word.idle", turn_start = "state.word.turn_start", text = "state.word.text",
tool_start = "state.word.tool_start", needs_attention = "state.word.needs_attention",
turn_end = "state.word.turn_end", error = "state.word.error",
}
local last = nil -- fingerprint of the last render (an unconditional one would reset scroll)
local function kfmt(n)
n = tonumber(n) or 0
if n >= 1000000 then return string.format("%.1fM", n / 1000000) end
if n >= 1000 then return string.format("%.1fk", n / 1000) end
return tostring(n)
end
-- A session id reaches a shell command below, so it is allowlisted first. Session
-- ids come from our own service, but the shim's injection review (a caller-supplied
-- id concatenated into a command language) is the same shape of risk, so the same
-- discipline applies here: anything outside [A-Za-z0-9._-] is refused rather than
-- quoted, and a refused row simply renders without its retire control.
local function safe_sid(sid)
return type(sid) == "string" and sid ~= "" and sid:match("^[%w%._%-]+$") ~= nil
end
-- Retire one session by speaking the ordinary protocol: session_end carrying a
-- payload whose only populated field is the trailing session id. parse_payload in
-- pulse-svc reads field 6, so ",,,,,<sid>" is a well-formed session-tagged event
-- with no telemetry — no new IPC verb, no service change.
local function retire(sid)
if not safe_sid(sid) then return end
noctalia.runAsync("noctalia msg plugin '" .. SVC .. "' all session_end ',,,,," .. sid .. "'")
end
local function session_row(s)
local word = STATE_WORD[s.state] and tr(STATE_WORD[s.state]) or tostring(s.state)
local line = tostring(s.sid) .. " · " .. word
if s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0 then
line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout)
end
if (s.cr or 0) > 0 then
line = line .. " · " .. tr("pulse.cached", { n = kfmt(s.cr) })
end
local cells = {
ui.label({
text = line,
maxWidth = WRAP - 92,
maxLines = 2,
color = s.state == "error" and ERROR_RGB or nil,
flexGrow = 1,
}),
}
-- The retire control is omitted rather than disabled for a non-conforming id:
-- a button that cannot act is worse than no button.
if safe_sid(s.sid) then
local sid = s.sid -- captured per row; the handler table is rebuilt each render
cells[#cells + 1] = ui.button({
text = tr("sessions.retire"),
variant = "secondary",
onClick = function() retire(sid) end,
})
end
return ui.row({ gap = 8, align = "center" }, cells)
end
local function render(snap)
local list = (type(snap) == "table" and type(snap.sessions) == "table") and snap.sessions or {}
local count = #list
-- Separate singular/plural keys rather than a "1 sessions" fudge: the count is
-- prominent in the header, so the disagreement reads as a bug.
local title = count == 1 and tr("sessions.title_one") or tr("sessions.title", { count = count })
local rows = { ui.label({ text = title, fontWeight = "bold" }) }
local body
if count == 0 then
body = ui.column({ gap = 6 }, {
ui.label({ text = tr("sessions.empty"), maxWidth = WRAP, opacity = 0.7 }),
})
else
local items = {}
local Tin, Tout = 0, 0
for i, s in ipairs(list) do
if i > 1 then items[#items + 1] = ui.separator({}) end
items[#items + 1] = session_row(s)
Tin = Tin + (tonumber(s.tin) or 0)
Tout = Tout + (tonumber(s.tout) or 0)
end
if (Tin + Tout) > 0 then
items[#items + 1] = ui.separator({})
items[#items + 1] = ui.label({
text = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) }),
opacity = 0.7,
})
end
body = ui.column({ gap = 6 }, items)
end
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.scroll({ flexGrow = 1 }, { body })
-- flexGrow on the root column is load-bearing for the same reason as the answer
-- panel: without it the column takes its full-content height and the panel clips
-- instead of the scroll child being bounded.
--
-- Deliberately UNFILLED, same as answer.luau. A decorated panel is inset by the
-- host (panel_manager.cpp: `hasDecoration ? contentScale * Style::panelPadding`),
-- and no [[panel]] manifest key opts out of decoration — so any fill on the root
-- is painted INSIDE that inset and leaves the host's panel colour as a rim. The
-- host background is the themed panel surface; letting it through is what makes
-- this panel read as part of the shell instead of a card floating in a frame.
-- Contrast comes from the tinted controls, not from repainting the backdrop.
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1 }, rows))
end
-- Fingerprint the rendered fields only, so a re-render happens on a real change and
-- not on every tick (which would reset the scroll position mid-read).
local function fingerprint(snap)
if type(snap) ~= "table" or type(snap.sessions) ~= "table" then return "" end
local parts = {}
for i, s in ipairs(snap.sessions) do
parts[i] = table.concat({
tostring(s.sid), tostring(s.state), tostring(s.model),
tostring(s.tin), tostring(s.tout), tostring(s.cr),
}, "\2")
end
return table.concat(parts, "\1")
end
function onOpen(_context)
panel.setWantsSecondTicks(true) -- host stops ticks on close, re-arms on reopen
local snap = noctalia.state.get and noctalia.state.get(KEY)
last = fingerprint(snap)
render(snap)
end
function update()
local snap = noctalia.state.get and noctalia.state.get(KEY)
local fp = fingerprint(snap)
if fp ~= last then
last = fp
render(snap)
end
end
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Manifest invariants for plugin.toml — the settings contract.
Covers the one class of defect neither `noctalia plugins lint` nor the luau specs
can see: `lint` only cross-checks declared settings against getConfig() calls, and
the widget code never observes a slider's step, so a wrong step is invisible to
both. Run: python3 tests/manifest_spec.py
The load-bearing invariant is STEP. Noctalia's manifest parser defaults an
omitted step to 1.0 (plugin_manifest.h: `double step = 1.0`), so a fractional
range silently degenerates to min + n*1.0 clamped to max — a handful of preset
stops instead of a slider. Shipped exactly that way once: pulse_glow_floor
(0.0-0.9) could only reach 0.0 and 0.9. Every double MUST declare step.
"""
import os
import unittest
try:
import tomllib
except ModuleNotFoundError: # py<3.11
import tomli as tomllib # type: ignore
ROOT = os.environ.get("PLUGIN_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NUMERIC = ("double", "number", "float")
def _settings(manifest):
"""Every declared setting, plugin-level and entry-level, as (origin, dict)."""
out = [("[[setting]]", s) for s in manifest.get("setting", [])]
for kind in ("widget", "desktop_widget", "panel", "service", "launcher"):
for entry in manifest.get(kind, []):
for s in entry.get("setting", []):
out.append((f"[[{kind}.setting]] {entry.get('id', '?')}", s))
return out
class Manifest(unittest.TestCase):
@classmethod
def setUpClass(cls):
with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh:
cls.manifest = tomllib.load(fh)
cls.settings = _settings(cls.manifest)
def test_has_settings(self):
self.assertTrue(self.settings, "expected at least one declared setting")
def test_every_numeric_declares_explicit_step(self):
"""The regression guard. An omitted step means 1.0, not 'continuous'."""
for origin, s in self.settings:
if s.get("type") in NUMERIC:
with self.subTest(setting=s.get("key"), origin=origin):
self.assertIn(
"step", s,
f"{s.get('key')} ({origin}) is type={s.get('type')} with no explicit "
"step; the parser would default it to 1.0 and snap the slider",
)
def test_step_is_positive(self):
"""step <= 0 is a hard parse error in noctalia (rejects the whole manifest)."""
for origin, s in self.settings:
if "step" in s:
with self.subTest(setting=s.get("key"), origin=origin):
self.assertGreater(s["step"], 0, f"{s.get('key')} step must be > 0")
def test_step_is_finer_than_range(self):
"""A step >= the span leaves only the two clamped endpoints reachable."""
for origin, s in self.settings:
if "step" in s and "min" in s and "max" in s:
with self.subTest(setting=s.get("key"), origin=origin):
span = s["max"] - s["min"]
self.assertLess(
s["step"], span,
f"{s.get('key')} step {s['step']} is not finer than its range {span}",
)
def test_default_lands_on_a_step_boundary(self):
"""Otherwise the shipped default is a value the slider cannot return to."""
for origin, s in self.settings:
if {"step", "min", "default"} <= s.keys() and s.get("type") in NUMERIC:
with self.subTest(setting=s.get("key"), origin=origin):
steps = (s["default"] - s["min"]) / s["step"]
self.assertAlmostEqual(
steps, round(steps), places=6,
msg=f"{s.get('key')} default {s['default']} is not an integer number "
f"of {s['step']} steps from min {s['min']}",
)
def test_default_within_range(self):
for origin, s in self.settings:
if {"min", "max", "default"} <= s.keys():
with self.subTest(setting=s.get("key"), origin=origin):
self.assertGreaterEqual(s["default"], s["min"])
self.assertLessEqual(s["default"], s["max"])
def test_label_and_description_use_key_form(self):
"""Raw `label`/`description` are REJECTED by the parser; only *_key works."""
for origin, s in self.settings:
with self.subTest(setting=s.get("key"), origin=origin):
self.assertNotIn("label", s, f"{s.get('key')}: use label_key, not label")
self.assertNotIn("description", s, f"{s.get('key')}: use description_key")
self.assertIn("label_key", s, f"{s.get('key')} is missing label_key")
class Translations(unittest.TestCase):
"""Every *_key must resolve in translations/en.json, or the UI shows a raw key."""
@classmethod
def setUpClass(cls):
import json
with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh:
cls.settings = _settings(tomllib.load(fh))
with open(os.path.join(ROOT, "translations", "en.json"), encoding="utf-8") as fh:
cls.en = json.load(fh)
def _resolve(self, dotted):
node = self.en
for part in dotted.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
def test_every_key_resolves(self):
for origin, s in self.settings:
for field in ("label_key", "description_key"):
if field in s:
with self.subTest(setting=s.get("key"), field=field, origin=origin):
self.assertIsInstance(
self._resolve(s[field]), str,
f"{s[field]} does not resolve to a string in translations/en.json",
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+20
View File
@@ -28,6 +28,26 @@
"title": "pulse", "title": "pulse",
"total": "Σ {tin} in / {tout} out" "total": "Σ {tin} in / {tout} out"
}, },
"sessions": {
"empty": "No live Claude sessions. Start one in a terminal, or with /claude.",
"retire": "Retire",
"title": "Claude — {count} sessions",
"title_one": "Claude — 1 session"
},
"settings": {
"breath_speed": {
"description": "How fast the pulse dot and orb breathe. 1.0 is the default; higher is faster, lower is slower.",
"label": "Breath speed"
},
"orb_swell": {
"description": "How far the desktop orb magnifies as it breathes. 0 keeps it a static size; 1.0 is the default; higher swells more.",
"label": "Orb swell"
},
"pulse_glow_floor": {
"description": "How dim the bar dot gets at the low point of its breath. 0 dims to black; higher stays brighter. 0.45 is the default.",
"label": "Bar dot glow floor"
}
},
"state": { "state": {
"orb": { "orb": {
"error": "error", "error": "error",