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

333 lines
17 KiB
Luau

-- /claude — the hands: launch Claude Code, or a quick one-shot ask.
-- /claude <task> → real Claude Code TUI in the terminal (full fidelity)
-- /claude → resume last session (claude --continue)
-- /claude ? <q> → one-shot read-only ask, streamed; answer via notify
--
-- Holds the BACKEND CHOKEPOINT (invoke + parse). v5 plugins load each entry as a
-- single chunk (no module system), and this launcher is the only entry that talks to a model,
-- so the seam lives here, inlined.
-- noctalia.runInTerminal / runStream both exec via `/bin/sh -c <string>`, so every
-- interpolated value must be shell-quoted. Single-quote wrap + escape embedded
-- single quotes ('\'') is sh-safe for arbitrary text.
local function shq(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- User-facing display strings live in translations/<lang>.json; resolve them at
-- call time (the noctalia global is ready by then). Model-facing prompts
-- (ASK_NOTE, SYSTEM_NOTE below) are deliberately NOT translated — they are
-- instructions to the model, not UI, and rewording them can shift its behavior.
local function tr(key, args) return noctalia.tr(key, args) end
-- A double-quoted sh argument: unlike shq it lets the shell expand $VAR (we need
-- $HOME in the shim path so nothing user-specific is hardcoded). ONLY use this on
-- fixed, trusted strings — never on user input, which must go through shq().
local function dq(s) return '"' .. tostring(s):gsub('"', '\\"') .. '"' end
-- ── backend seam: normalize at the boundary ──────────────────────────────────
-- Everything downstream consumes this vocabulary, never raw backend output.
local EVENT = {
turn_start = "turn_start", text = "text", tool_start = "tool_start",
tool_end = "tool_end", needs_attention = "needs_attention",
turn_end = "turn_end", error = "error",
}
-- noctalia launches us with the GUI-session PATH, which lacks ~/.local/bin (it's
-- added by the user's shell profile, not the graphical session). Claude Code's
-- own session hooks live there (memd, agent-scaffold, …), so a /claude session would
-- hit "command not found" that a terminal-started one never does. Prepend it to
-- every launch so a /claude session inherits the same PATH as a terminal. Fixed,
-- trusted literal — $HOME/$PATH are meant to expand in the `/bin/sh -c` context.
local PATH_PREFIX = 'PATH="$HOME/.local/bin:$PATH" '
-- A quick-ask answer is delivered as a desktop toast first, and toasts clip
-- after a couple of lines with no scroll — so steer the model toward toast-sized
-- answers at the source. Long answers still arrive intact: the full text goes to
-- the answer panel (see publish_answer below), the toast just leads with less.
local ASK_NOTE = table.concat({
"Your answer is delivered as a desktop notification. Lead with the direct answer",
"in one or two short sentences; add detail only if the question genuinely needs it.",
"Plain text only — no markdown formatting.",
}, " ")
-- claude only; this is the single backend seam.
-- A quick-ask is promised as read-only (README "Usage"), so it must NOT inherit
-- the user's Claude config, where pre-authorized permissions would let a plain
-- question run commands or edit files: --tools "" strips every built-in tool,
-- --strict-mcp-config (with no --mcp-config) strips inherited MCP servers, and
-- --setting-sources "" skips user/project settings and with them hooks, plugins,
-- and permission grants. NOT --bare: it never reads OAuth logins, which are the
-- auth path quick-ask depends on (see the auth guard below).
local function backend_command(prompt)
-- Claude Code: -p (print/non-interactive) requires --verbose for stream-json.
-- `-p --` before the prompt is load-bearing: without the `--` end-of-options
-- marker, a question whose first token starts with `-` (e.g. pasted text
-- leading with `--dangerously-skip-permissions`) is parsed as CLI flags and
-- can undo the read-only sandbox above. `--` forces the prompt to be a
-- positional (verified: a prompt of "--version" is answered, not executed).
return PATH_PREFIX .. "claude --tools '' --strict-mcp-config --setting-sources ''"
.. " --output-format stream-json --verbose"
.. " --append-system-prompt " .. shq(ASK_NOTE) .. " -p -- " .. shq(prompt)
end
-- one stream-json line → an EVENT (or nil). Claude Code emits one JSON object per
-- line: type "system" (init), "assistant" (a message; content is an array of
-- text / tool_use blocks), "user" (tool_result), "result" (final). We map the
-- subset we care about and ignore the rest (version-defensive).
-- Note: the content-block shape can shift across claude versions — if events
-- stop firing, re-dump a live stream and re-check the field names here.
local function parse(line)
local ok, msg = pcall(noctalia.json.decode, line)
if not ok or type(msg) ~= "table" then return nil end
local t = msg.type
if t == "system" then
return { kind = EVENT.turn_start }
elseif t == "assistant" then
local content = type(msg.message) == "table" and msg.message.content or msg.content
local text = ""
if type(content) == "table" then
for _, block in ipairs(content) do
if type(block) == "table" then
if block.type == "tool_use" then
return { kind = EVENT.tool_start }
elseif block.type == "text" and type(block.text) == "string" then
text = text .. block.text
end
end
end
end
return { kind = EVENT.text, text = text }
elseif t == "user" then
-- a tool result came back; the model is thinking again (clears tool_start)
return { kind = EVENT.turn_start }
elseif t == "result" then
local err = msg.is_error == true or msg.subtype == "error_during_execution"
return { kind = err and EVENT.error or EVENT.turn_end, text = msg.result }
end
return nil
end
-- publish quick-ask state for the pulse widget (cross-VM channel via shared
-- state). Only the hookless `/claude ? <q>` stream uses this; the widget tracks it as
-- one ephemeral "ask" session and drops it when the ask ends.
local function set_state(s) noctalia.state.set("claude.state", s) end
-- ── answer delivery ──────────────────────────────────────────────────────────
-- A toast clips long bodies with no scroll, so it is the preview surface only:
-- the complete answer is published to "claude.answer" and rendered wrapped +
-- scrollable by the answer panel (answer.luau). The panel opens from a click on
-- the bar pulse, the "Show last answer" launcher row, or the CLI.
local ANSWER_PANEL = "lowcache/claude-companion:answer"
local function publish_answer(q, text, is_err)
noctalia.state.set("claude.answer", {
q = q,
text = text,
error = is_err == true,
at = noctalia.formatTime("%H:%M"),
})
end
-- Fit the toast: collapse whitespace and cut at a word boundary. Returns the
-- preview and whether anything was dropped (→ the toast gains a pointer to the
-- full answer).
local PREVIEW_MAX = 200
local function preview(text)
local flat = text:gsub("%s+", " ")
if #flat <= PREVIEW_MAX then return flat, false end
local cut = flat:sub(1, PREVIEW_MAX):match("^(.*)%s%S*$") or flat:sub(1, PREVIEW_MAX)
return cut .. " …", true
end
-- ONE surface per answer: while the answer panel is open (it sets this flag) it
-- live-refreshes from claude.answer, so it IS the delivery — a toast on top would
-- duplicate it, and dismissing that toast clicks outside the panel, which the
-- click-shield turns into closing the panel as well (verified live: toast +
-- panel died to one click). Panel open → publish only; panel closed → toast.
local function panel_showing()
return noctalia.state.get("claude.answer.open") == true
end
-- ── auth guard ───────────────────────────────────────────────────────────────
-- Headless `claude -p` does NOT refresh an expired OAuth access token (8 h
-- lifetime) even when the refresh token is valid — only an interactive session
-- does (upstream: anthropics/claude-code#53063 and friends). So a quick-ask can
-- 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
-- an error-text match that swaps the raw API error for the 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"
-- true ONLY when the credentials file positively says the token is expired.
-- Missing file, undecodable JSON, absent expiresAt, or no epoch clock all mean
-- "unknown" → proceed and let the error match below catch a real failure. The
-- token itself is never read, only the expiry timestamp.
-- Note: %s is a glibc strftime extension; if noctalia's formatTime doesn't
-- pass it through, tonumber yields nil and the pre-flight silently disables —
-- the detect layer still covers.
local function token_expired()
local now = tonumber(noctalia.formatTime("%s"))
if not now then return false end
local raw = noctalia.readFile(noctalia.expandPath(CREDS_PATH))
if type(raw) ~= "string" then return false end
local ok, creds = pcall(noctalia.json.decode, raw)
if not ok or type(creds) ~= "table" then return false end
local oauth = creds.claudeAiOauth
local exp = type(oauth) == "table" and tonumber(oauth.expiresAt) or nil
if not exp then return false end
if exp > 1e12 then exp = exp / 1000 end -- stored in ms; tolerate seconds too
return exp <= now + 30 -- 30 s margin: don't start an ask about to 401 mid-flight
end
-- Recognize the auth-failure shapes claude -p emits in its result line:
-- "Not logged in · Please run /login" (no credentials) and "Failed to
-- authenticate. API Error: 401 {...authentication_error...}" (expired token).
local function is_auth_error(text)
return text:find("Please run /login", 1, true) ~= nil
or text:find("API Error: 401", 1, true) ~= nil
or text:find("authentication_error", 1, true) ~= nil
end
-- ── context injection ────────────────────────────────────────────────────────
-- Make /claude-launched sessions desktop-AWARE (senses) and desktop-CAPABLE (hands)
-- by wiring the noctalia MCP shim and a role note into the launch.
--
-- We pass the shim via inline --mcp-config (not a file) so the terminal's cwd is
-- irrelevant: /claude opens in the user's project dir, not the plugin dir. We push a
-- role note, NOT a senses snapshot — a snapshot taken at launch is stale before
-- the first turn; instead Claude pulls fresh senses on demand via the tools.
-- The shim is assumed at the canonical install path
-- ($HOME/.local/share/noctalia/plugins/<id>). $HOME stays bare for the shell to
-- expand (portable; nothing user-specific is committed). The JSON is fixed and
-- trusted (no user input), so dq() is injection-safe here.
local SHIM = "$HOME/.local/share/noctalia/plugins/claude-companion/shim/noctalia-mcp.py"
local MCP_JSON = '{"mcpServers":{"noctalia":{"command":"python3","args":["' .. SHIM .. '"]}}}'
local SYSTEM_NOTE = table.concat({
"You are running inside the Noctalia desktop shell (Wayland — niri, Hyprland, or Sway), launched from its Claude Code companion plugin.",
"An MCP server named 'noctalia' gives you live desktop senses and hands:",
"PERCEIVE — get_window (focused app/title), get_workspace (focused output + workspace), get_media (now playing), get_shell_state (shell status), get_power (battery/AC), get_network (connectivity/Wi-Fi), get_processes (top by CPU).",
"ACT — notify (desktop toast), set_theme_mode (dark/light/auto), set_color_scheme, focus_window (by the id from get_window), switch_workspace (by index/name), move_to_workspace (move focused window), set_wallpaper (path or random).",
"MEMORY — remember (persist a durable fact for future sessions).",
"Call the perceive tools when current desktop context matters instead of assuming it, and use notify for ambient status updates.",
}, " ")
-- Flags shared by every interactive launch (task + continue). Built once.
local CONTEXT_FLAGS =
"--mcp-config " .. dq(MCP_JSON) .. " --append-system-prompt " .. shq(SYSTEM_NOTE)
-- ── /claude routing ──────────────────────────────────────────────────────────────
local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
-- The last quick-ask answer, if any, earns a launcher row that reopens the
-- answer panel — the recovery path when the toast has already expired.
local function answer_row()
local a = noctalia.state.get("claude.answer")
if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then return nil end
return { id = "answer", title = tr("launcher.show_answer"), subtitle = a.q, glyph = "message-2" }
end
function onQuery(query)
local text = trim(query)
if text == "" then
local results = {
{ id = "continue", title = tr("launcher.continue"), glyph = "robot" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
return
end
local ask = text:match("^%?%s*(.*)$")
if ask ~= nil then
if ask == "" then
local results = {
{ id = "_askhint", title = tr("launcher.ask"), subtitle = tr("launcher.ask_hint"), glyph = "message-dots" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
else
launcher.setResults(query, {
{ id = "ask:" .. ask, title = tr("launcher.ask"), subtitle = ask, glyph = "message-dots" },
})
end
return
end
launcher.setResults(query, {
{ id = "task:" .. text, title = tr("launcher.launch"), subtitle = text, glyph = "robot" },
})
end
function onActivate(id)
-- A launched TUI session drives the pulse itself via the global Claude Code
-- hooks (with its own session id + token telemetry), so we DON'T set claude.state
-- here — doing so would register a phantom session the hooks never update or
-- retire. claude.state is only for the hookless quick-ask stream below.
if id == "continue" then
noctalia.runInTerminal(PATH_PREFIX .. "claude " .. CONTEXT_FLAGS .. " --continue")
return
end
if id == "answer" then
-- reopen the answer panel with the last quick-ask answer (fixed id, no user input)
noctalia.runAsync("noctalia msg panel-open " .. shq(ANSWER_PANEL))
return
end
local task = id:match("^task:(.*)$")
if task then
noctalia.runInTerminal(PATH_PREFIX .. "claude " .. CONTEXT_FLAGS .. " " .. shq(task))
return
end
local ask = id:match("^ask:(.*)$")
if ask then
-- pre-flight: an expired token would 401 only after burning the request,
-- and the remedy needs a terminal anyway — so say so now and skip the
-- launch. No claude.state touch: no ask session ever starts.
if token_expired() then
publish_answer(ask, auth_remedy(), true)
if not panel_showing() then noctalia.notifyError(tr("notify.title"), auth_remedy()) end
return
end
set_state(EVENT.turn_start)
-- Accumulate streamed assistant text; the final `result` event also carries
-- the full text, so prefer it at turn_end and fall back to the accumulation.
local acc = ""
noctalia.runStream(backend_command(ask), function(line)
local ev = parse(line)
if not ev then return end
set_state(ev.kind)
if ev.kind == EVENT.text and ev.text and ev.text ~= "" then
acc = acc .. ev.text
elseif ev.kind == EVENT.turn_end then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then
noctalia.notify(tr("notify.title"), tr("notify.no_output"))
else
publish_answer(ask, final, false)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notify(tr("notify.title"), body)
end
end
elseif ev.kind == EVENT.error then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then final = tr("notify.ask_failed") end
-- a 401 that slipped past the pre-flight: deliver the remedy, not the
-- raw API error blob
if is_auth_error(final) then final = auth_remedy() end
publish_answer(ask, final, true)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notifyError(tr("notify.title"), body)
end
end
end)
return
end
-- "_askhint" and any other ids: no-op.
end