Files
community-plugins/claude-companion/pulse-svc.luau
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

171 lines
7.9 KiB
Luau

-- 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()