* 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>
171 lines
7.9 KiB
Luau
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()
|