diff --git a/claude-companion/.gitignore b/claude-companion/.gitignore new file mode 100644 index 0000000..9c8bbbe --- /dev/null +++ b/claude-companion/.gitignore @@ -0,0 +1,3 @@ +# Python bytecode (the MCP shim and hooks are interpreted; caches are build artifacts) +__pycache__/ +*.pyc diff --git a/claude-companion/LICENSE b/claude-companion/LICENSE new file mode 100644 index 0000000..30f5bc5 --- /dev/null +++ b/claude-companion/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lowcache + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/claude-companion/PROTOCOL.md b/claude-companion/PROTOCOL.md new file mode 100644 index 0000000..5c70c09 --- /dev/null +++ b/claude-companion/PROTOCOL.md @@ -0,0 +1,163 @@ +# The Pulse Protocol + +An agent-agnostic contract for driving the pulse bar widget (and everything +downstream of it: the presence orb, tooltips, `claude.pulse` subscribers). The +widget knows nothing about Claude Code — it consumes **events** and an optional +**telemetry payload** over noctalia's plugin IPC. Any coding agent that can run +a shell command on its lifecycle hooks (gemini-cli, codex, opencode, aider, a +CI job, a cron script) can light up the same bar dot. + +Two adapters ship in `hooks/`: + +| Adapter | For | Telemetry | +|---|---|---| +| `pulse.py` | Claude Code (reads hook JSON on stdin, parses the session transcript) | live token burn, O(delta) | +| `pulse-emit` | anything else (plain POSIX sh, args only) | whatever you pass, or none | + +## Transport + +``` +noctalia msg plugin all [payload] +``` + +- `` is the plugin dispatch id: `:` — + `lowcache/claude-companion:pulse` for this install. Adapters must treat it as + configurable (`pulse-emit` reads `$PULSE_TARGET`). +- `all` addresses every monitor's widget instance. (`focused` or a bare + connector errors when the widget sits on multiple bars.) +- `[payload]` is a **single positional token** — noctalia's msg CLI splits on + whitespace, so the payload must be space-free. That's why it's a CSV, not + JSON. +- Fire-and-forget. The dispatch returns `ok: dispatched N` or an error string; + adapters ignore both (see the fail-open contract below). + +## Event vocabulary + +Eight events. Priority decides which session the bar shows when several are +active; "resting" matters for the default-slot rule below. + +| Event | Meaning | Priority | Resting | +|---|---|---|---| +| `needs_attention` | agent is blocked on the human (permission prompt, question) | 6 | no | +| `error` | hard failure | 5 | yes | +| `tool_start` | executing a tool / command | 4 | no | +| `turn_start` | thinking — a turn has begun | 3 | no | +| `text` | streaming a response | 3 | no | +| `turn_end` | turn finished — output ready for the human | 2 | yes | +| `idle` | session alive, nothing happening | 1 | yes | +| `session_end` | session is over — **retires** its slot | — | — | + +Unknown events render as idle-with-the-event-kept-as-state-word; stick to the +vocabulary. Glyph, accent color, and breath tempo are widget-side concerns +(see `VISUAL` in `pulse.luau`) — the protocol only fixes the *semantics*. + +## Payload + +``` +model,in,out,cacheCreate,cacheRead,session +``` + +- `session` (field 6) is the only field that changes behavior: it keys the + per-session slot, so every event from the same agent session must carry the + same short id (Claude's adapter uses the first `-` segment of the session + UUID; any stable `[A-Za-z0-9_-]+` token works). +- `model` is a display string; use `?` when unknown. +- Token fields are lifetime-cumulative for the session, not per-turn deltas. + The widget displays *input* as `in + cacheCreate` (full-rate work) and shows + `cacheRead` separately. All-zero telemetry is fine — the burn line is simply + omitted (`model` of `?` or zero in+out hides it). +- No commas or whitespace inside fields. + +**Minimum viable adapter:** fire bare events with just a session id — +`?,0,0,0,0,`. State tracking, urgency priority, multi-session tooltip all +work; you only lose the burn readout. + +## Session semantics (what the widget guarantees) + +- One slot per `session` id; re-sending updates the slot in place. +- The bar renders the **most urgent** state across all live slots (priority + table above); the tooltip lists every session, most recent first, with a + Σ burn total. +- `session_end` retires the slot. Nothing else does — a real session may sit + at `idle` or `turn_end` indefinitely and stays listed. +- **Payload-less events** (no CSV at all — e.g. a manual + `noctalia msg plugin … all needs_attention` poke from a terminal) land in a + single shared `default` slot. To keep CLI pokes from leaving a phantom + session, any **resting** event (`idle`, `turn_end`, `error`) retires the + `default` slot instead of updating it. Consequence for adapters: *always + send a session id*; the default slot is a test surface, not a home. + +## Adapter contract + +1. **Fail-open, always.** Exit 0 no matter what — noctalia offline, binary + missing, malformed input. An adapter runs inside an agent's hook path and + must never block or error the agent. Swallow stdout/stderr, cap the + dispatch with a timeout (~3 s). +2. **Tag everything with the session id** (see above). +3. **Send cumulative telemetry or none** — don't send per-turn deltas. +4. Don't invent events; map your agent's lifecycle onto the eight above. + +### Lifecycle mapping guide + +The Claude Code mapping (from `hooks/settings.snippet.json`) doubles as the +template for any agent: + +| Agent moment | Event | +|---|---| +| session starts / process launches | `idle` | +| prompt submitted / turn begins | `turn_start` | +| about to run a tool or shell command | `tool_start` | +| tool finished, agent resumes thinking | `turn_start` | +| response streaming to the user | `text` | +| waiting on permission / a question for the human | `needs_attention` | +| turn complete, output delivered | `turn_end` | +| unrecoverable failure | `error` | +| session exits (however it exits) | `session_end` | + +If your agent only exposes a subset (say, just "done" notifications), map what +you have — a session that only ever sends `turn_end`/`session_end` still +renders correctly. + +### The generic emitter + +``` +hooks/pulse-emit [session] [model] [in] [out] [cacheCreate] [cacheRead] +``` + +POSIX sh, no dependencies beyond `noctalia` on PATH. Omitted fields default to +`?`/`0`; omitting `session` sends a bare (default-slot) event. Env: +`PULSE_TARGET` overrides the dispatch id, `PULSE_DRYRUN=1` prints the command +instead of running it. Examples: + +```sh +pulse-emit turn_start mysess # state only +pulse-emit turn_end mysess gpt-5 12000 800 # with burn figures +pulse-emit session_end mysess # retire the slot +long_build && pulse-emit needs_attention ci # non-agent uses work too +``` + +## Downstream: the `claude.pulse` state mirror + +The widget is the **single aggregator**; subscribers (the orb, or any future +surface) never parse events themselves. On every event — never from the +animation timer — it publishes a rollup snapshot to noctalia shared state under +`claude.pulse`: + +```lua +{ state = , -- "idle" when no sessions + count = , + model = , -- single-session only + tin = , -- one session's, or the Σ across all + tout = , + cr = 1> } +``` + +Desktop widgets receive it via `noctalia.state.watch("claude.pulse", cb)`; bar +widgets must poll `state.get` (watch doesn't fire on bars in noctalia 5.0.0). + +## Deployment invariant + +The aggregator lives in the `pulse` **bar widget** — bar widgets only run when +placed on a bar. If `pulse` isn't in a bar layout, every event is silently +dropped and all subscribers freeze. Noctalia 5.0.0 has no headless plugin +entry kind, so "pulse on a bar" is a hard install requirement. diff --git a/claude-companion/README.md b/claude-companion/README.md new file mode 100644 index 0000000..9243b73 --- /dev/null +++ b/claude-companion/README.md @@ -0,0 +1,117 @@ +# Claude Companion + +![Claude Companion — a Claude Code companion for Noctalia: pulse, orb, and answer panel](thumbnail.webp) + +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) + +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. + +This plugin gives it a body. It wires Noctalia into Claude's lifecycle so a **pulse** on your bar tracks every session, an **orb** on your desktop breathes along with the work, and an **answer panel** catches one-shot replies before they scroll away. The terminal keeps doing the actual thinking — permissions, tools, MCP, all native. This is just the nervous system that lets the rest of your desktop feel it. + +Don't run Claude Code? The signal bus is agent-agnostic — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. See [Wiring up other agents](#wiring-up-other-agents). + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `lowcache/claude-companion` | +| Entries | Bar widget: `pulse`; desktop widget: `orb`; panel: `answer`; launcher: `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. + +## See it + +![The bar pulse and desktop orb breathing through a Claude session's lifecycle](assets/pulse.gif) + +One session, start to finish: the **pulse** on the bar and the **orb** on the desktop breathe through idle, thinking, a tool run, done, and needs-you. + +![A quick question answered in the answer panel](assets/question.gif) + +Ask something quick with `/claude ?` and the whole answer waits for you in the panel, instead of scrolling off the top of the terminal. + +## 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. + +**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. + +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. + +## Requirements + +- **Noctalia 5.0.0** on a supported Wayland compositor — **niri**, **Hyprland**, or **Sway**. The shim detects which one is running and speaks its IPC; the widgets themselves are compositor-agnostic. You only need the CLI for the compositor you actually run — `niri`, `hyprctl` (Hyprland), or `swaymsg` (Sway) — not all three. +- **[Claude Code](https://claude.com/claude-code)** — the `claude` agent being visualized. Optional if you're driving the widgets from another agent via [PROTOCOL.md](PROTOCOL.md). +- **`python3`** for the MCP shim (stdlib only, no pip installs) +- On the PATH as the shim's senses need them: `playerctl`, `nmcli`, `notify-send`, `ps` +- For the generic shell adapter (`hooks/pulse-emit`, only used when driving the widgets from a non-Claude agent): `tr` is required; `timeout` is optional — the adapter falls back to a direct dispatch when it's absent. + +## Install + +```sh +# clone and symlink into the plugins dir +ln -s "$PWD" ~/.local/share/noctalia/plugins/claude-companion + +# enable the plugin +noctalia msg plugins enable lowcache/claude-companion +``` + +Then, in order: + +1. **Put the `pulse` widget on a bar** (Settings → Bar). Read the warning below first — this one isn't optional. +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. +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.) + +Prove it works: + +```sh +noctalia msg plugin lowcache/claude-companion:pulse all needs_attention # bar icon → red bell +noctalia msg plugin lowcache/claude-companion:pulse all idle # back to robot +``` + +> [!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. + +## Usage + +`/claude ` 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 ? ` 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. + +That panel opens however you like it: click the pulse, use the "Show last answer" row under `/claude`, or toggle it from the CLI: + +```sh +noctalia msg panel-toggle lowcache/claude-companion:answer +``` + +Leave it open and it refreshes live while suppressing the toast, so you're never reading the same answer twice. A click outside or Esc puts it away. + +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. + +## 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: + +```sh +hooks/pulse-emit turn_start mysess +hooks/pulse-emit turn_end mysess gpt-5 12000 800 +hooks/pulse-emit session_end mysess +``` + +## Rough edges + +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. +- 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. +- 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. +- The MCP shim is a Python prototype. A compiled port is the intended endgame. +- The shim's memory tool drops notes into `~/.memory/inbox` for the memd curator to pick up. No memd, no reader — the files get written and simply sit there. It follows memd's Inbox Protocol v1.0 (`INBOX-PROTOCOL.md` in the memd repo). + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/claude-companion/answer.luau b/claude-companion/answer.luau new file mode 100644 index 0000000..d3cce6d --- /dev/null +++ b/claude-companion/answer.luau @@ -0,0 +1,87 @@ +-- The answer panel — the full-length surface for /claude ? quick-ask answers. +-- A notification toast clips long bodies and cannot scroll, so claude.luau publishes +-- the complete answer to noctalia.state "claude.answer" and this panel renders it +-- wrapped and scrollable. Ways in: click the bar pulse, the "Show last answer" +-- row under /claude, or `noctalia msg panel-open lowcache/claude-companion:answer`. +-- +-- Pure subscriber, same doctrine as the orb: claude.luau owns the state, this panel +-- only renders it. state.watch() callbacks proved unreliable for bar widgets +-- (see pulse.luau) and a panel only lives while open anyway, so this polls on +-- second ticks and re-renders only when the payload actually changed — an +-- unconditional re-render each second would reset the scroll position mid-read. + +local KEY = "claude.answer" + +-- The panel surface is 460 logical px wide (plugin.toml); wrap text inside the +-- padding with room for the scrollbar. +local WRAP = 408 +local PAD = 14 + +-- Fallback error accent, same as the pulse dot; normal body text keeps the +-- theme's default foreground by not setting a color at all. +local ERROR_RGB = "#FF4D1F" + +local last = nil -- fingerprint of the last-rendered payload (skip no-op renders) + +local function fingerprint(a) + if type(a) ~= "table" then return "" end + return tostring(a.at) .. "\1" .. tostring(a.q) .. "\1" .. tostring(a.error) .. "\1" .. tostring(a.text) +end + +local function render(a) + local rows = { ui.label({ text = "Claude — quick ask", fontWeight = "bold" }) } + local body + if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then + body = ui.label({ + text = "No answers yet. Ask one from the launcher: /claude ? ", + maxWidth = WRAP, + opacity = 0.7, + }) + else + if type(a.q) == "string" and a.q ~= "" then + local q = "? " .. a.q .. (type(a.at) == "string" and a.at ~= "" and (" · " .. a.at) or "") + rows[#rows + 1] = ui.label({ text = q, maxWidth = WRAP, maxLines = 3, opacity = 0.7 }) + end + body = ui.label({ + text = a.text, + maxWidth = WRAP, + color = a.error == true and ERROR_RGB or nil, + }) + end + rows[#rows + 1] = ui.separator({}) + rows[#rows + 1] = ui.scroll({ flexGrow = 1 }, { body }) + -- flexGrow on the ROOT is load-bearing: the host sizes its root flex to the + -- panel, but this column is a child of it — without grow it takes its natural + -- (full-content) height, overflows, and the panel hard-clips the answer. Grown + -- to the panel height, the scroll child above is the one that gets bounded and + -- actually scrolls. + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1 }, rows)) +end + +-- Visibility flag for claude.luau: while the panel is open it live-refreshes the +-- answer (update below), so a toast for the same answer would be a redundant +-- second surface — and dismissing that toast clicks outside the panel, which +-- the click-shield turns into closing the panel too. claude.luau reads this flag +-- and skips the toast when the panel is already showing. +local OPEN_KEY = "claude.answer.open" + +function onOpen(_context) + panel.setWantsSecondTicks(true) -- the host stops ticks on close, re-arms on reopen + noctalia.state.set(OPEN_KEY, true) + local a = noctalia.state.get(KEY) + last = fingerprint(a) + render(a) +end + +function onClose() + noctalia.state.set(OPEN_KEY, false) +end + +function update() + local a = noctalia.state.get(KEY) + local fp = fingerprint(a) + if fp ~= last then + last = fp + render(a) + end +end diff --git a/claude-companion/assets/pulse.gif b/claude-companion/assets/pulse.gif new file mode 100644 index 0000000..d6cf267 Binary files /dev/null and b/claude-companion/assets/pulse.gif differ diff --git a/claude-companion/assets/question.gif b/claude-companion/assets/question.gif new file mode 100644 index 0000000..7889f17 Binary files /dev/null and b/claude-companion/assets/question.gif differ diff --git a/claude-companion/barpulse.luau b/claude-companion/barpulse.luau new file mode 100644 index 0000000..ba84b4b --- /dev/null +++ b/claude-companion/barpulse.luau @@ -0,0 +1,89 @@ +-- EXPERIMENTAL — prototyping always-visible bar-widget animation, since the desktop +-- orb only shows on bare desktop. The real pulse.luau bar is UNTOUCHED; this is a +-- throwaway comparison surface. The winning behaviour gets folded into pulse.luau. +-- +-- Bar plugin widgets have NO per-frame tick (that's desktop-only) — they re-render on +-- a timer via noctalia.setUpdateInterval(ms) + a global update(). The only animatable +-- channel is the glyph colour. Rather than alpha (the bar didn't honour 8-digit +-- #RRGGBBAA), we breathe BRIGHTNESS: scale the accent RGB toward black (the near-black +-- surface) and emit a plain 6-digit "#RRGGBB" so the glyph glows brighter/dimmer while +-- keeping its hue. Two waveforms to compare by eye: +-- MODE "A" raised-cosine sine breath (smooth glow, like the orb) +-- MODE "B" square wave (discrete blink) +-- +-- It reads state from the same claude.pulse rollup pulse.luau publishes (so it needs none +-- of the session bookkeeping). Flip MODE + reload to compare. +-- +-- State RGB is hardcoded from the active dark palette (volnix: +-- mSecondary/mPrimary/mError) to validate the look fast. + +local MODE = "A" -- "A" sine breath | "B" square blink +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 + +-- glyph + accent RGB (hex, no #) per state — same glyph/role map as the bar dot/orb. +local VISUAL = { + idle = { glyph = "robot", rgb = "EAFF00", period = 8.0 }, + turn_start = { glyph = "brain", rgb = "B4FF00", period = 5.0 }, + text = { glyph = "message-dots", rgb = "B4FF00", period = 4.5 }, + tool_start = { glyph = "tool", rgb = "EAFF00", period = 5.0 }, + needs_attention = { glyph = "bell-ringing", rgb = "FF4D1F", period = 3.0 }, + turn_end = { glyph = "bell", rgb = "B4FF00", period = 5.5 }, + error = { glyph = "alert-triangle", rgb = "FF4D1F", period = 3.5 }, +} + +local state = "idle" +local phase = 0.0 + +local function level_for(v) -- brightness scale in [BMIN, BMAX] + local t = phase % v.period + if MODE == "B" then + return (t < v.period * 0.5) and BMAX or BMIN -- bright for the first half, dim for the second + end + local s = 0.5 - 0.5 * math.cos((t / v.period) * 2 * math.pi) + return BMIN + (BMAX - BMIN) * s +end + +-- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB". +local function dim(rgb, b) + local function ch(i) + local x = math.floor((tonumber(rgb:sub(i, i + 1), 16) or 0) * b + 0.5) + if x < 0 then x = 0 elseif x > 255 then x = 255 end + return x + end + return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5)) +end + +local function paint() + local v = VISUAL[state] or VISUAL.idle + barWidget.setGlyph(v.glyph) + barWidget.setGlyphColor(dim(v.rgb, level_for(v))) + barWidget.setTooltip(string.format("barpulse [%s] · %s", MODE, state)) +end + +-- Pull the most-urgent state from the bar pulse's published rollup. Bar widgets don't +-- seem to receive noctalia.state.watch callbacks (the desktop orb does; this bar one +-- did not), so we POLL noctalia.state.get each tick instead — cheap, and the timer is +-- already running. Only adopt a recognised state so a nil/garbage read can't blank it. +local function refresh_state() + local snap = noctalia.state.get and noctalia.state.get("claude.pulse") + if type(snap) == "table" and type(snap.state) == "string" and VISUAL[snap.state] then + state = snap.state + elseif type(snap) == "string" and VISUAL[snap] then + state = snap + end +end + +-- Timer loop. Re-arm the interval each tick (mirrors the scratchpad pattern), refresh +-- the state by polling, and advance the breath by the known step (no dt on bar widgets). +function update() + noctalia.setUpdateInterval(INTERVAL_MS) + refresh_state() + phase = phase + INTERVAL_MS / 1000 + if phase > 1e6 then phase = 0 end + paint() +end + +refresh_state() +noctalia.setUpdateInterval(INTERVAL_MS) +paint() diff --git a/claude-companion/claude.luau b/claude-companion/claude.luau new file mode 100644 index 0000000..008cbdc --- /dev/null +++ b/claude-companion/claude.luau @@ -0,0 +1,324 @@ +-- /claude — the hands: launch Claude Code, or a quick one-shot ask. +-- /claude → real Claude Code TUI in the terminal (full fidelity) +-- /claude → resume last session (claude --continue) +-- /claude ? → 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 `, 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 + +-- 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 ? ` 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. +local AUTH_REMEDY = "Claude login expired — start a Claude session in a terminal to refresh it, then re-ask." + +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/). $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 = "Show last answer", subtitle = a.q, glyph = "message-2" } +end + +function onQuery(query) + local text = trim(query) + if text == "" then + local results = { + { id = "continue", title = "Resume last Claude session", 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 = "Ask Claude (one-shot)", subtitle = "Type a question after ?", glyph = "message-dots" }, + } + results[#results + 1] = answer_row() + launcher.setResults(query, results) + else + launcher.setResults(query, { + { id = "ask:" .. ask, title = "Ask Claude (one-shot)", subtitle = ask, glyph = "message-dots" }, + }) + end + return + end + launcher.setResults(query, { + { id = "task:" .. text, title = "Launch Claude Code", 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("Claude", 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("Claude", "(no output)") + else + publish_answer(ask, final, false) + if not panel_showing() then + local body, clipped = preview(final) + if clipped then body = body .. "\nFull answer: click the bar pulse" end + noctalia.notify("Claude", 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 = "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 .. "\nFull answer: click the bar pulse" end + noctalia.notifyError("Claude", body) + end + end + end) + return + end + -- "_askhint" and any other ids: no-op. +end diff --git a/claude-companion/config.example.toml b/claude-companion/config.example.toml new file mode 100644 index 0000000..29cb114 --- /dev/null +++ b/claude-companion/config.example.toml @@ -0,0 +1,14 @@ +# Backend config — claude only in v1. The seam (one chokepoint in claude.luau) means +# adding a backend later is a config block + a parse() branch, with no call-site +# change. This file documents the seam; it is NOT read yet (claude.luau hardcodes claude). + +default_backend = "claude" + +[backends.claude] +command = "claude" +# capability + locality flags — defined now, trivial in v1. The locality flag gates +# the perception tier: remote -> low/medium senses; local -> high tier allowed. +agentic = true +tools = true +streaming = true +is_local = false # remote/frontier (use is_local, not the `local` keyword) diff --git a/claude-companion/hooks/pulse-emit b/claude-companion/hooks/pulse-emit new file mode 100755 index 0000000..946ed19 --- /dev/null +++ b/claude-companion/hooks/pulse-emit @@ -0,0 +1,43 @@ +#!/bin/sh +# pulse-emit — generic, agent-agnostic emitter for the pulse protocol. +# The whole contract lives in PROTOCOL.md; this is the reference adapter for +# any agent that can run a shell command on its lifecycle hooks (gemini-cli, +# codex, opencode, a CI job). Claude Code uses pulse.py instead (it enriches +# events with transcript-derived token telemetry; this one just relays args). +# +# pulse-emit [session] [model] [in] [out] [cacheCreate] [cacheRead] +# +# With a session id the event carries the space-free CSV payload +# "model,in,out,cacheCreate,cacheRead,session" (omitted fields -> ?/0). +# 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. +# +# Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse) +# PULSE_DRYRUN non-empty -> print the command instead of dispatching +# +# 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. + +TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse}" +event="${1:-idle}" +session="$2" + +# CSV fields must stay space- and comma-free (payload is one positional token). +clean() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._?-'; } + +payload="" +if [ -n "$session" ]; then + payload="$(clean "${3:-?}"),$(clean "${4:-0}"),$(clean "${5:-0}"),$(clean "${6:-0}"),$(clean "${7:-0}"),$(clean "$session")" +fi + +if [ -n "$PULSE_DRYRUN" ]; then + echo "noctalia msg plugin $TARGET all $event${payload:+ $payload}" + exit 0 +fi + +if command -v timeout >/dev/null 2>&1; then + timeout 3 noctalia msg plugin "$TARGET" all "$event" ${payload:+"$payload"} >/dev/null 2>&1 +else + noctalia msg plugin "$TARGET" all "$event" ${payload:+"$payload"} >/dev/null 2>&1 +fi +exit 0 diff --git a/claude-companion/hooks/pulse.py b/claude-companion/hooks/pulse.py new file mode 100755 index 0000000..76e7c20 --- /dev/null +++ b/claude-companion/hooks/pulse.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Pulse hook dispatcher (lowcache/claude-companion plugin). + +Bridges a Claude Code lifecycle hook to the pulse bar widget, enriching the +event with live model + token-burn telemetry parsed from the session transcript. +Invoked by the hooks in settings.snippet.json as: + + pulse.py # event name, e.g. turn_start / tool_start / ... + +Hook JSON arrives on stdin (transcript_path, session_id). The widget is driven via +noctalia's documented plugin IPC (`noctalia msg --help`): + + noctalia msg plugin lowcache/claude-companion:pulse all [payload] + +`[payload]` is a single positional token, so the payload is a SPACE-FREE CSV the +widget (pulse.luau) parses: + + model,in,out,cacheCreate,cacheRead,session + +The `session` (short id) tags EVERY event, so the widget can track each concurrent +session separately. The matching SessionEnd hook fires `session_end`, which retires +the session in the widget and drops its token cache here. + +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 +lines (O(delta), not O(whole transcript)). Transcript JSONL only appends; if it +ever shrinks (context compaction rewrites it), the cache resets. + +Fail-open by contract: ANY error (no stdin, malformed transcript, noctalia offline) +still fires the bare event with no payload and never exits non-zero — a hook must +never block Claude or surface an error. + +""" +import json +import os +import subprocess +import sys + +PLUGIN = "lowcache/claude-companion:pulse" +TARGET = "all" + + +def _cache_path(session): + base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp" + safe = "".join(c for c in session if c.isalnum() or c in "-_") or "nosession" + return os.path.join(base, f"noctalia-pulse-{safe}.json") + + +def _accumulate(transcript, session): + """Sum usage over newly-appended transcript lines since the last call.""" + cache = _cache_path(session) + st = {"offset": 0, "in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""} + try: + with open(cache) as f: + st.update(json.load(f)) + except (OSError, ValueError): + pass + + if os.path.getsize(transcript) < st["offset"]: # shrank (compaction) → reset + st = {"offset": 0, "in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""} + + with open(transcript) as f: + f.seek(st["offset"]) + for line in f: + line = line.strip() + if not line: + continue + try: + msg = (json.loads(line).get("message") or {}) + except ValueError: + continue + u = msg.get("usage") + if not u: + continue + st["in"] += u.get("input_tokens", 0) or 0 + st["out"] += u.get("output_tokens", 0) or 0 + st["cc"] += u.get("cache_creation_input_tokens", 0) or 0 + st["cr"] += u.get("cache_read_input_tokens", 0) or 0 + if msg.get("model"): + st["model"] = msg["model"] + st["offset"] = f.tell() + + tmp = cache + ".tmp" + try: + with open(tmp, "w") as f: + json.dump(st, f) + os.replace(tmp, cache) + except OSError: + pass + return st + + +def _payload(data, event): + """Build the CSV payload, or None when the event can't be attributed. + + Requires only a session id — every tagged event carries it so the widget can + track sessions individually. Token figures are best-effort (zeros when the + transcript is unreadable or empty); the widget decides whether to render them. + `session_end` skips the transcript parse (the id alone retires the session). + """ + session = data.get("session_id") or "" + if not session: + return None + st = {"in": 0, "out": 0, "cc": 0, "cr": 0, "model": ""} + transcript = data.get("transcript_path") or "" + if event != "session_end" and transcript and os.path.isfile(transcript): + try: + st = _accumulate(transcript, session) + except OSError: + pass + model = st["model"].replace("claude-", "") if st["model"] else "?" + short = session.split("-")[0] + return f"{model},{st['in']},{st['out']},{st['cc']},{st['cr']},{short}" + + +def _cleanup(session): + """Drop a finished session's token cache (best-effort).""" + if not session: + return + try: + os.unlink(_cache_path(session)) + except OSError: + pass + + +def main(): + event = sys.argv[1] if len(sys.argv) > 1 else "idle" + try: + raw = sys.stdin.read() + data = json.loads(raw) if raw.strip() else {} + except (ValueError, OSError): + data = {} + payload = _payload(data, event) + argv = ["noctalia", "msg", "plugin", PLUGIN, TARGET, event] + if payload: + argv.append(payload) + if os.environ.get("NOCTALIA_PULSE_DRYRUN"): + print(" ".join(argv)) + else: + try: + subprocess.run(argv, capture_output=True, timeout=3) + except Exception: # noqa: BLE001 — noctalia offline/missing must stay silent + pass + if event == "session_end": + _cleanup(data.get("session_id") or "") + + +if __name__ == "__main__": + main() diff --git a/claude-companion/hooks/settings.snippet.json b/claude-companion/hooks/settings.snippet.json new file mode 100644 index 0000000..556d232 --- /dev/null +++ b/claude-companion/hooks/settings.snippet.json @@ -0,0 +1,26 @@ +{ + "_comment": "Merge into ~/.claude/settings.json. The attention reflex: Claude Code lifecycle hooks invoke hooks/pulse.py , 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 [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.", + "hooks": { + "SessionStart": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py idle" } ] } + ], + "UserPromptSubmit": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_start" } ] } + ], + "PreToolUse": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py tool_start" } ] } + ], + "PostToolUse": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_start" } ] } + ], + "Notification": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py needs_attention" } ] } + ], + "Stop": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py turn_end" } ] } + ], + "SessionEnd": [ + { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py session_end" } ] } + ] + } +} diff --git a/claude-companion/orb.luau b/claude-companion/orb.luau new file mode 100644 index 0000000..94d06e1 --- /dev/null +++ b/claude-companion/orb.luau @@ -0,0 +1,132 @@ +-- The presence orb (desktop widget) — the ambient "wow" half of the attention +-- pulse. Where the bar dot is a small static glyph, the orb is the SAME state icon +-- blown up on the desktop, magnifying in and out like a "bat signal": calm and slow +-- at idle, quick and insistent when Claude needs you. +-- +-- It is a pure VIEW. pulse.luau already rolls all sessions up for the bar dot and +-- now mirrors that rollup into noctalia.state ("claude.pulse"); the orb just subscribes +-- and animates. No hook, IPC, or session bookkeeping lives here — one source of +-- truth (the bar pulse), two surfaces (bar dot + desktop orb). +-- +-- Unlike the bar dot's DISCRETE glyph/color, the orb does PER-FRAME motion: +-- desktopWidget.setNeedsFrameTick(true) asks the host for onFrameTick(dt) callbacks +-- (dt = seconds since last frame); a raised-cosine "breath" drives the glyph's size +-- (the magnification) and opacity together. Breath speed/depth come from the state, +-- so the motion itself carries meaning. We keep ticking a slow idle breath even with +-- no sessions — ambient presence is the whole point of the orb. +-- +-- API surface (verified against the noctalia 5.0.0 binary — no source ships; props +-- read from the ui reconciler's BoxProps/applyProps tables): +-- desktopWidget.render(tree) -- declarative ui.* tree +-- desktopWidget.setNeedsFrameTick(bool) -- opt into onFrameTick(dt) +-- ui.glyph{ name,size,color,opacity,width,height } ui.column/row ui.label{ text, +-- fontSize,color,maxWidth,maxLines,textAlign } noctalia.state.watch(key, cb) +-- NB: ui.box is a LEAF (RectNode) — it draws no children, its fill prop is `fill` +-- (not color/backgroundColor) and its soft glow is `softness`; there is no overlay +-- control, which is why the orb is a magnifying glyph rather than a glyph-on-disc. +-- Colors are the global scheme accents (secondary/primary/error) — same map as the +-- bar dot. See pulse.luau for the shared state→visual map. + +-- Per-state look + motion. `color` is the accent the icon is drawn in; `glyph` is the +-- Tabler icon (same one the bar dot shows). `period` = seconds per full breath; +-- `omin`/`omax` = opacity bounds. Faster + deeper = more urgent. `word` labels it. +-- Slow, glowing breath — NOT a strobe. Periods are multi-second (a human breath is +-- ~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, +-- but even "needs you" stays a breath, never a flicker. +local VISUAL = { + idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80, word = "idle" }, + turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00, word = "thinking" }, + text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00, word = "responding" }, + tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00, word = "running a tool" }, + needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00, word = "needs you" }, + turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95, word = "done — ready for you" }, + error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00, word = "error" }, +} + +-- The orb IS the bar icon, swelling gently in and out — a desktop "bat signal". The +-- glyph size eases between MIN and MAX with the breath (a soft swell, not a zoom); a +-- fixed BOX reserves space so the swell never nudges the label below it. +local GLYPH_MIN = 70 +local GLYPH_MAX = 75 +local GLYPH_BOX = 84 + +-- 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 phase = 0.0 -- breath phase accumulator (seconds), wrapped per period + +local function kfmt(n) + n = tonumber(n) or 0 + if n >= 1e6 then return string.format("%.1fM", n / 1e6) end + if n >= 1000 then return string.format("%.1fk", n / 1000) end + return tostring(math.floor(n)) +end + +-- 0..1..0 over one period (raised cosine): 0 at phase 0, 1 at half period. +local function breath() + local v = VISUAL[cur.state] or VISUAL.idle + local s = 0.5 - 0.5 * math.cos((phase / v.period) * 2 * math.pi) + return v, s +end + +-- 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 +-- a single attributable session. +local function subtitle(v) + if cur.count > 1 then + return cur.count .. " sessions" + end + if cur.count == 1 and cur.model ~= "?" and (cur.tin + cur.tout) > 0 then + return v.word .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout) + end + return v.word +end + +local function render() + local v, s = breath() + local opacity = v.omin + (v.omax - v.omin) * s + local size = GLYPH_MIN + (GLYPH_MAX - GLYPH_MIN) * s -- the magnification swing + + desktopWidget.render(ui.column({ gap = 6, padding = 12, align = "center" }, { + -- The "bat signal": the state icon itself, magnifying larger/smaller and breathing + -- opacity in the accent color — the same glyph the bar dot shows for this state. + -- A fixed width/height reserves the icon's footprint so the pulse doesn't shove + -- the label as it grows. (ui.glyph can't carry the box's soft `softness` glow, and + -- there's no overlay control, so the icon pulses crisp rather than haloed.) + ui.glyph({ + name = v.glyph, size = size, color = v.color, opacity = opacity, + width = GLYPH_BOX, height = GLYPH_BOX, + }), + ui.label({ + text = subtitle(v), fontSize = 12, color = "on_surface_variant", + maxWidth = 180, maxLines = 1, textAlign = "center", + }), + })) +end + +-- Per-frame breath. `dt` is MILLISECONDS since the last frame (≈16.7 at 60 Hz, ≈6.9 +-- at 144 Hz — verified empirically; the host passes real elapsed ms, so dividing by +-- 1000 makes `period` a true wall-clock second count, identical on every monitor). +-- Accumulate into `phase` and wrap at the current state's period so it can't grow +-- unbounded. +function onFrameTick(dt) + local v = VISUAL[cur.state] or VISUAL.idle + phase = (phase + (tonumber(dt) or 0) / 1000) % v.period + render() +end + +-- New status from the bar pulse. We don't reset `phase` on state change — the +-- breath glides into the new tempo instead of snapping, which reads calmer. +noctalia.state.watch("claude.pulse", function(snap) + if type(snap) ~= "table" then return end + cur.state = (type(snap.state) == "string" and VISUAL[snap.state]) and snap.state or "idle" + cur.count = tonumber(snap.count) or 0 + cur.model = (type(snap.model) == "string" and snap.model ~= "") and snap.model or "?" + cur.tin = tonumber(snap.tin) or 0 + cur.tout = tonumber(snap.tout) or 0 + render() +end) + +-- Breathe from the first frame, even before any session reports in (idle presence). +desktopWidget.setNeedsFrameTick(true) +render() diff --git a/claude-companion/plugin.toml b/claude-companion/plugin.toml new file mode 100644 index 0000000..e7b6c7a --- /dev/null +++ b/claude-companion/plugin.toml @@ -0,0 +1,53 @@ +# Claude Companion — a Claude Code companion for Noctalia v5. +# Not a chat client: the terminal does the agentic work; this is the shell-side +# body — senses, hands, and an attention pulse. See README.md for the design. + +id = "lowcache/claude-companion" +name = "Claude Companion" +version = "1.0.0" +# Oldest plugin API level this plugin needs (the [[panel]] kind, ui controls, +# plugin IPC dispatch, the barWidget/onIpc surface). Bump when adopting a newer API. +plugin_api = 3 +author = "lowcache" +license = "MIT" +icon = "robot" +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 / +# Hyprland / Sway is running Noctalia (so exactly one of niri/hyprctl/swaymsg is +# present). See README "Requirements". +dependencies = ["claude", "python3", "niri", "hyprctl", "swaymsg", "playerctl", "notify-send", "nmcli", "ps", "tr", "timeout"] +tags = ["ai", "productivity", "bar", "desktop", "panel", "launcher", "niri", "hyprland", "sway"] + +# The attention pulse — the visual centerpiece. Reads agent state from +# noctalia.state ("claude.state") and from hook signals (onIpc), 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]] +id = "pulse" +entry = "pulse.luau" + +# The presence orb — the ambient desktop half of the pulse. A softly breathing disc +# that mirrors the bar dot's rollup (published to noctalia.state "claude.pulse"); pure +# view, no hooks of its own. Per-frame motion via setNeedsFrameTick/onFrameTick. +[[desktop_widget]] +id = "orb" +entry = "orb.luau" + +# The answer panel — full-length scrollable surface for /claude ? quick-ask answers +# (a notification toast clips long bodies; it carries only a preview). Opens on +# a click on the bar pulse, from the "Show last answer" launcher row, or via +# `noctalia msg panel-toggle lowcache/claude-companion:answer`. +[[panel]] +id = "answer" +entry = "answer.luau" +width = 460 +height = 420 + +# /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 +# module system, and this is the only entry that talks to a model. +[[launcher_provider]] +id = "claude" +entry = "claude.luau" +prefix = "claude" +glyph = "robot" diff --git a/claude-companion/pulse.luau b/claude-companion/pulse.luau new file mode 100644 index 0000000..871c870 --- /dev/null +++ b/claude-companion/pulse.luau @@ -0,0 +1,345 @@ +-- The attention pulse (bar widget) — a glanceable live readout of Claude across +-- ALL active sessions. Two feeds converge on the session table: +-- • Claude Code hooks fire the plugin-dispatch IPC → onIpc here (the reflex): +-- noctalia msg plugin lowcache/claude-companion:pulse all [payload] +-- payload = "model,in,out,cacheCreate,cacheRead,session" (hooks/pulse.py). +-- Each real session is tracked by its id; SessionEnd removes it. +-- • claude.luau writes "claude.state" (the launcher quick-ask) → POLLED here (bar +-- widgets don't fire state.watch in v5 — D8 / README) as one ephemeral pseudo- +-- session ("ask"), removed when the ask completes. +-- +-- Bar plugin widgets have no per-frame tick (that's desktop-only), so the breath +-- runs on noctalia.setUpdateInterval(ms) + the global update(): a raised-cosine +-- BRIGHTNESS breath (the winner of the barpulse A/B prototype — the bar ignores +-- 8-digit #RRGGBBAA alpha, but a 6-digit #RRGGBB scaled toward black reads as a +-- glow). Discrete state (glyph/tooltip/session rollup) renders on events; the +-- 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 ───────────────────────────────────────────────────────────── +-- Accent roles follow the global scheme so the dot matches the other bar +-- widgets: `secondary` for the ambient/working states (idle, tool), `primary` +-- for the Claude-active states (thinking/responding/done), `error` for the +-- attention bell + hard errors. Brightness math needs real RGB, so the roles +-- are resolved from the active palette JSON on disk: +-- custom → $XDG_CONFIG_HOME/noctalia/palettes/.json +-- community → $XDG_STATE_HOME/noctalia/community-palettes/.json +-- (noctalia.string.urlEncode is the same urlEncode noctalia names the cache +-- file with, so the round-trip is exact.) +-- Builtin + wallpaper-generated palettes have no on-disk JSON (they +-- live in the binary / the generator), so those sources keep the fallback +-- accents below. +local ACCENTS = { secondary = "EAFF00", primary = "B4FF00", error = "FF4D1F" } + +local function xdg(env, fallback) + local v = noctalia.getenv and noctalia.getenv(env) + if type(v) == "string" and v ~= "" then return v end + return noctalia.expandPath(fallback) +end + +-- Minimal [theme] reader: walk lines, track the section, keep quoted k/v pairs. +-- (Indented subsections like [theme.templates] end the block; their keys are +-- arrays/bools and would not match the quoted-string pattern anyway.) +local function theme_cfg(toml) + local cfg, in_theme = {}, false + for line in (toml .. "\n"):gmatch("([^\n]*)\n") do + local sec = line:match("^%s*%[([^%]]+)%]") + if sec then + in_theme = (sec == "theme") + elseif in_theme then + local k, v = line:match('^%s*([%w_]+)%s*=%s*"(.-)"') + if k then cfg[k] = v end + end + end + return cfg +end + +local function resolve_accents() + local toml = noctalia.readFile(xdg("XDG_STATE_HOME", "~/.local/state") .. "/noctalia/settings.toml") + if type(toml) ~= "string" then return end + local cfg = theme_cfg(toml) + local path + if cfg.source == "custom" and cfg.custom_palette then + path = xdg("XDG_CONFIG_HOME", "~/.config") .. "/noctalia/palettes/" .. cfg.custom_palette .. ".json" + elseif cfg.source == "community" and cfg.community_palette then + path = xdg("XDG_STATE_HOME", "~/.local/state") .. "/noctalia/community-palettes/" + .. noctalia.string.urlEncode(cfg.community_palette) .. ".json" + else + return -- builtin / wallpaper-generated: keep current accents + end + local raw = noctalia.readFile(path) + if type(raw) ~= "string" then return end + local pal = noctalia.json.decode(raw) + if type(pal) ~= "table" then return end + local m = pal[noctalia.isDarkMode() and "dark" or "light"] or pal.dark or pal + if type(m) ~= "table" then return end + local function hex(c) + return type(c) == "string" and c:match("^#(%x%x%x%x%x%x)$") or nil + end + ACCENTS.secondary = hex(m.mSecondary) or ACCENTS.secondary + ACCENTS.primary = hex(m.mPrimary) or ACCENTS.primary + ACCENTS.error = hex(m.mError) or ACCENTS.error +end + +-- ── state map ──────────────────────────────────────────────────────────────── +-- `color` names an ACCENTS role; `period` is the breath cycle in seconds — +-- urgency reads as tempo (needs-you breathes fast, idle slow). +local VISUAL = { + idle = { glyph = "robot", color = "secondary", tip = "Claude: idle", period = 8.0 }, + turn_start = { glyph = "brain", color = "primary", tip = "Claude: thinking", period = 5.0 }, + text = { glyph = "message-dots", color = "primary", tip = "Claude: responding", period = 4.5 }, + tool_start = { glyph = "tool", color = "secondary", tip = "Claude: running a tool", period = 5.0 }, + needs_attention = { glyph = "bell-ringing", color = "error", tip = "Claude needs you", period = 3.0 }, + turn_end = { glyph = "bell", color = "primary", tip = "Claude: done — ready for you", period = 5.5 }, + error = { glyph = "alert-triangle", color = "error", tip = "Claude: error", period = 3.5 }, +} + +-- With several sessions in different states, the bar shows the most urgent one: +-- 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, +} +-- Compact per-session words for the multi-session tooltip. +local STATE_WORD = { + idle = "idle", turn_start = "thinking", text = "responding", + tool_start = "tool", needs_attention = "needs you", + turn_end = "done", error = "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 ─────────────────────────────────────────────────────────────────── +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 PALETTE_EVERY = 128 -- re-resolve accents every ~7.7 s so theme changes follow + +local cur = "idle" -- most-urgent state across sessions (drives glyph + tempo) +local phase = VISUAL.idle.period / 2 -- breath clock, seconds; born at peak brightness +local ticks = 0 +local last_ask = nil -- last claude.state seen by the quick-ask poll (below) + +local function level_for(period) -- raised-cosine brightness in [BMIN, BMAX] + local t = phase % period + local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi) + return BMIN + (BMAX - BMIN) * s +end + +-- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB". +local function dimmed(rgb, b) + local function ch(i) + local x = math.floor((tonumber(rgb:sub(i, i + 1), 16) or 0) * b + 0.5) + if x < 0 then x = 0 elseif x > 255 then x = 255 end + return x + end + return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5)) +end + +local function paint() + local v = VISUAL[cur] or VISUAL.idle + barWidget.setGlyphColor(dimmed(ACCENTS[v.color] or ACCENTS.secondary, level_for(v.period))) +end + +-- ── session plumbing ───────────────────────────────────────────────────────── +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 n = tonumber(s) or 0 + if n >= 1e6 then return string.format("%.1fM", n / 1e6) end + if n >= 1000 then return string.format("%.1fk", n / 1000) end + return tostring(math.floor(n)) +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 burn_line(s) + local l = string.format("%s · %s in / %s out", s.model, kfmt(s.tin), kfmt(s.tout)) + if (s.cr or 0) > 0 then l = l .. " · " .. kfmt(s.cr) .. " cached" end + return l +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 render() + 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 + 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 + local v = VISUAL[cur] or VISUAL.idle + barWidget.setGlyph(v.glyph) + paint() + + local tip + if count == 0 then + tip = VISUAL.idle.tip + elseif count == 1 then + local s = arr[1] + local sv = VISUAL[s.state] or VISUAL.idle + tip = has_burn(s) and (sv.tip .. "\n" .. burn_line(s)) or sv.tip + else + local lines = { string.format("Claude — %d sessions", count) } + local Tin, Tout = 0, 0 + for _, s in ipairs(arr) do + local line = s.sid .. " · " .. (STATE_WORD[s.state] or s.state) + if has_burn(s) then + line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout) + Tin = Tin + s.tin; Tout = Tout + s.tout + end + lines[#lines + 1] = line + end + if (Tin + Tout) > 0 then + lines[#lines + 1] = string.format("Σ %s in / %s out", kfmt(Tin), kfmt(Tout)) + end + tip = table.concat(lines, "\n") + end + 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 + +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 + +-- 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 + 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 ` 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 + render() +end + +-- Click the dot to show the answer panel — the full-length surface for /claude ? +-- quick-ask answers (the toast only carries a preview; claude.luau publishes the +-- complete text to "claude.answer" and answer.luau renders it scrollable). +-- panel-OPEN, not -toggle: open is idempotent, so it survives the click handler +-- firing more than once per click (a toggle nets out to closed again — click +-- appeared dead in live testing). Dismiss is click-outside/Esc, panel idiom. +function onClick() + if not noctalia.runAsync("noctalia msg panel-open 'lowcache/claude-companion:answer'") then + noctalia.notifyError("pulse", "could not launch the answer panel") + end +end + +-- Breath timer. Re-arm the interval each tick (the pattern proven live in the +-- barpulse prototype), advance the clock, repaint the glyph brightness only — +-- glyph/tooltip/rollup are event-driven in render(). +function update() + noctalia.setUpdateInterval(INTERVAL_MS) + phase = phase + INTERVAL_MS / 1000 + if phase > 1e6 then phase = 0 end + ticks = ticks + 1 + if ticks % PALETTE_EVERY == 0 then resolve_accents() end + poll_ask() -- surface quick-ask state changes (bar widgets can't watch) + paint() +end + +resolve_accents() +noctalia.setUpdateInterval(INTERVAL_MS) +render() diff --git a/claude-companion/shim/noctalia-mcp.py b/claude-companion/shim/noctalia-mcp.py new file mode 100644 index 0000000..85a92e5 --- /dev/null +++ b/claude-companion/shim/noctalia-mcp.py @@ -0,0 +1,559 @@ +#!/usr/bin/env python3 +"""noctalia-mcp — stdio MCP shim bridging Claude Code <-> the Noctalia shell. + +SENSES (shell -> Claude): query the env directly — the running compositor + (niri / Hyprland / Sway), playerctl, /sys, and + `noctalia msg status` for shell-internal state. +HANDS (Claude -> shell): `noctalia msg ` (request/response; reply on + stdout, "error:" prefix on failure). Desktop notifications + go through notify-send: noctalia exposes no generic notify + IPC (notifications are shell-internal / luau-only). + +MCP transport: newline-delimited JSON-RPC 2.0 over stdio (one message per line, +no embedded newlines) — the stdio transport Claude Code speaks. Spawned by Claude +via --mcp-config; no daemon to babysit. + +Tool set is the low/medium perception tier only — high-tier senses +(clipboard/screen/files) stay gated until a local backend lands. +""" +import datetime +import json +import os +import re +import subprocess +import sys +import tempfile + +PROTOCOL_VERSION = "2024-11-05" +SERVER_INFO = {"name": "noctalia-mcp", "version": "0.1.0"} + + +def sh(args, timeout=5): + """Run argv (no shell), return stdout or an 'error: ...' string.""" + try: + out = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + return (out.stdout or out.stderr).strip() + except Exception as e: # noqa: BLE001 + return f"error: {e}" + + +# ── compositor abstraction ──────────────────────────────────────────────────── +# The window/workspace ops are the ONLY compositor-coupled surface. Everything +# else (`noctalia msg`, playerctl, nmcli, /sys, notify-send) is compositor-neutral. +# Noctalia runs on niri, Hyprland, and Sway but exposes no generic window/workspace +# IPC (`noctalia msg` has no such verbs), so we speak each compositor's own protocol. +# Detection prefers the compositor's own socket env var, then XDG_CURRENT_DESKTOP. +# +# Command shapes: niri verified live; Hyprland against HyprCtl.cpp + the dispatcher +# wiki; Sway against sway-ipc(7)/sway(5). The pure helpers below (compositor_argv, +# pick_focused_monitor, find_focused_view, sway_focused_output) do no I/O, so they +# are unit-tested without a live session — see tests/shim_spec.py. +SUPPORTED_COMPOSITORS = ("niri", "hyprland", "sway") + + +def detect_compositor(env=None): + """Identify the running compositor, or None if unsupported/undetectable.""" + env = os.environ if env is None else env + if env.get("NIRI_SOCKET"): + return "niri" + if env.get("HYPRLAND_INSTANCE_SIGNATURE"): + return "hyprland" + if env.get("SWAYSOCK"): + return "sway" + xdg = (env.get("XDG_CURRENT_DESKTOP") or "").lower() + return next((c for c in SUPPORTED_COMPOSITORS if c in xdg), None) + + +def _ws_numeric(ref): + """A workspace ref is 'numeric' if it is a plain (optionally signed) integer.""" + return str(ref).lstrip("-").isdigit() + + +# Caller-supplied identifiers are VALIDATED, not quoted: swaymsg concatenates its +# argv into one command in Sway's command language, where `;`/`,` chain further +# commands (`exec` included) — so a hostile ref could smuggle arbitrary execution +# past a narrowly approved window/workspace action. Strict allowlists; every id +# the compositors themselves emit fits (niri/Sway integer ids, Hyprland 0x-hex +# addresses, workspace indices, single-word names). +_WINDOW_ID_RE = re.compile(r"(?:0x[0-9A-Fa-f]+|[0-9]+)\Z") +_WS_REF_RE = re.compile(r"[A-Za-z0-9._:+-]+\Z") + + +def valid_window_id(wid): + """True iff wid is a compositor window id: decimal or 0x-prefixed hex.""" + return _WINDOW_ID_RE.fullmatch(str(wid)) is not None + + +def valid_workspace_ref(ref): + """True iff ref is a safe workspace index/name — no spaces and none of Sway's + command metacharacters (separators, quotes, criteria brackets).""" + return _WS_REF_RE.fullmatch(str(ref)) is not None + + +def compositor_argv(comp, op, ref=None, wid=None): + """Pure map (compositor, op) -> argv; no I/O, so it is directly unit-testable. + + For query ops that need client-side filtering (sway focused_*, hyprland + focused_output) this returns the *query* argv and the caller does the filtering. + Workspace refs: Hyprland needs a `name:` prefix for named workspaces; Sway needs + the `number` keyword for numeric ones (else the int is matched as a literal name). + Move semantics differ slightly and are documented, not normalized: niri and + Hyprland follow focus to the target workspace, Sway does not.""" + if comp == "niri": + return { + "focused_output": ["niri", "msg", "-j", "focused-output"], + "focused_window": ["niri", "msg", "-j", "focused-window"], + "focus_window": ["niri", "msg", "action", "focus-window", "--id", str(wid)], + "focus_workspace": ["niri", "msg", "action", "focus-workspace", str(ref)], + "move_to_workspace": ["niri", "msg", "action", "move-column-to-workspace", str(ref)], + }[op] + if comp == "hyprland": + hws = str(ref) if _ws_numeric(ref) else "name:" + str(ref) + return { + "focused_output": ["hyprctl", "-j", "monitors"], + "focused_window": ["hyprctl", "-j", "activewindow"], + "focus_window": ["hyprctl", "dispatch", "focuswindow", "address:" + str(wid)], + "focus_workspace": ["hyprctl", "dispatch", "workspace", hws], + "move_to_workspace": ["hyprctl", "dispatch", "movetoworkspace", hws], + }[op] + if comp == "sway": + sws = ["number", str(ref)] if _ws_numeric(ref) else [str(ref)] + return { + "focused_output": ["swaymsg", "-t", "get_outputs"], + "focused_window": ["swaymsg", "-t", "get_tree"], + "focus_window": ["swaymsg", "[con_id=%s]" % wid, "focus"], + "focus_workspace": ["swaymsg", "workspace"] + sws, + "move_to_workspace": ["swaymsg", "move", "container", "to", "workspace"] + sws, + }[op] + raise KeyError((comp, op)) + + +def pick_focused_monitor(mons): + """Hyprland `monitors` array -> the focused monitor object (or None).""" + return next((m for m in mons if m.get("focused")), None) + + +def find_focused_view(node): + """Sway `get_tree` -> the focused leaf view (con/floating_con), recursively. + + Constrained to view node types so an ancestor workspace/output that also + reports focused does not shadow the actual window.""" + if node.get("focused") and node.get("type") in ("con", "floating_con"): + return node + for child in node.get("nodes", []) + node.get("floating_nodes", []): + hit = find_focused_view(child) + if hit: + return hit + return None + + +def sway_focused_output(outputs, workspaces): + """Sway output objects carry no `focused` flag [SUPPORTED, sway-ipc(7)]; derive + the focused output from the focused workspace's `output` name.""" + ws = next((w for w in workspaces if w.get("focused")), None) + if not ws: + return None + return next((o for o in outputs if o.get("name") == ws.get("output")), None) + + +def _no_comp(): + return "error: no supported compositor detected (niri/hyprland/sway)" + + +def _run_json(argv): + """Run argv and parse stdout as JSON: (obj, None) on success, (None, err) else.""" + out = sh(argv) + if out.startswith("error:"): + return None, out + try: + return json.loads(out), None + except Exception as e: # noqa: BLE001 + return None, f"error: bad JSON from {argv[0]}: {e}" + + +def _get_workspace(a): + """Focused output/monitor (connector, mode, current workspace), compositor-native JSON.""" + comp = detect_compositor() + if comp is None: + return _no_comp() + if comp == "niri": + return sh(compositor_argv(comp, "focused_output")) + if comp == "hyprland": + mons, err = _run_json(compositor_argv(comp, "focused_output")) + if err: + return err + foc = pick_focused_monitor(mons) + return json.dumps(foc) if foc else "error: no focused monitor" + outs, err = _run_json(["swaymsg", "-t", "get_outputs"]) + if err: + return err + wss, err = _run_json(["swaymsg", "-t", "get_workspaces"]) + if err: + return err + foc = sway_focused_output(outs, wss) + return json.dumps(foc) if foc else "error: no focused output" + + +def _get_window(a): + """Focused window (app id/class + title), compositor-native JSON.""" + comp = detect_compositor() + if comp is None: + return _no_comp() + if comp in ("niri", "hyprland"): + return sh(compositor_argv(comp, "focused_window")) + tree, err = _run_json(compositor_argv(comp, "focused_window")) + if err: + return err + view = find_focused_view(tree) + return json.dumps(view) if view else "error: no focused window" + + +def _remember(a): + """Persist a durable fact to GLOBAL memory's inbox; memd distills ~/.memory. + + The membrane (decision #25): ephemeral senses -> noctalia.state; durable + learnings -> global memd. This is the durable side. Notes are routed:global + so memd's curator files them into the system-wide store, not a project.""" + # Coerce: a model may send a non-string (number/list) — str() keeps the + # handler (and the server) from raising on .strip()/.lower(). + text = str(a.get("text") or "").strip() + if not text: + return "error: 'text' is required" + slug = re.sub(r"[^a-z0-9]+", "-", str(a.get("topic") or "note").lower()).strip("-") or "note" + mem = os.path.expanduser("~/.memory") + inbox = os.path.join(mem, "inbox") + body = ( + f"---\nrouted: global\ntopic: {slug}\n" + f"date: {datetime.date.today()}\nsource: noctalia-mcp/remember\n---\n\n" + f"{text}\n" + ) + try: + os.makedirs(inbox, exist_ok=True) + # Concurrency: every Claude session runs its own shim, and the curator + # (memd) reads/clears this inbox in parallel. Two guards: + # 1) Unique name — microsecond timestamp + PID, so simultaneous notes + # from different sessions never collide (second-resolution did). + # 2) Atomic publish — write a temp file OUTSIDE the inbox, then + # os.replace() it in. A sweep either sees the whole note or not at + # all; it can never read a half-written file mid-write. + # 3) Crash durability — fsync the file before publish, then fsync the + # inbox dir after the rename. Without both, a power loss/panic can + # leave a flushed file whose directory entry never landed (lost + # note) or a renamed entry pointing at unflushed data. Cheap + # insurance; matters for an alpha shell on a crash-prone desktop. + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S-%f") + final = os.path.join(inbox, f"{ts}-{slug}-{os.getpid()}.md") + fd, tmp = tempfile.mkstemp(dir=mem, prefix=".remember-", suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + f.write(body) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, final) + dfd = os.open(inbox, os.O_RDONLY) + try: + os.fsync(dfd) + finally: + os.close(dfd) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + return f"remembered -> {final}" + except OSError as e: + return f"error: {e}" + + +# ── additional senses (read-only) ──────────────────────────────────────────── +def _get_power(a): + """Battery level/status + AC state, as JSON. battery=null if none present.""" + base = "/sys/class/power_supply" + try: + names = os.listdir(base) + except OSError as e: + return f"error: {e}" + + def rd(node, field): + try: + with open(os.path.join(base, node, field)) as fh: + return fh.read().strip() + except OSError: + return None + + out = {} + bats = sorted(n for n in names if n.startswith("BAT")) + out["battery"] = ( + {b: {"capacity": rd(b, "capacity"), "status": rd(b, "status")} for b in bats} + if bats else None + ) + for ac in ("AC", "ACAD", "ADP1", "AC0"): + online = rd(ac, "online") + if online is not None: + out["ac_online"] = online == "1" + break + return json.dumps(out) + + +def _get_network(a): + """Connectivity + the active Wi-Fi connection (SSID/signal), as JSON.""" + state = sh(["nmcli", "-t", "-f", "STATE,CONNECTIVITY", "general"]) + if state.startswith("error:"): + return state + wifi = sh(["nmcli", "-t", "-f", "ACTIVE,SSID,SIGNAL", "dev", "wifi"]) + active = next((l for l in wifi.splitlines() if l.startswith("yes:")), "") + return json.dumps({"general": state, "wifi_active": active}) + + +def _get_processes(a): + """Top processes by CPU (header + top 10), as text.""" + out = sh(["ps", "-eo", "pid,pcpu,pmem,comm", "--sort=-pcpu"]) + if out.startswith("error:"): + return out + return "\n".join(out.splitlines()[:11]) + + +# ── additional hands (mutate shared desktop state) ──────────────────────────── +# These race on the single real desktop across concurrent sessions (last write +# wins) — inherent to a shared environment, not a shim bug. Each is a stateless +# subprocess, so the shim itself stays concurrency-safe. +def _focus_window(a): + wid = str(a.get("id") or "").strip() + if not wid: + return "error: 'id' is required" + if not valid_window_id(wid): + return "error: invalid window id (use the numeric or 0x-hex id from get_window)" + comp = detect_compositor() + if comp is None: + return _no_comp() + return sh(compositor_argv(comp, "focus_window", wid=wid)) + + +def _switch_workspace(a): + ref = str(a.get("reference") or "").strip() + if not ref: + return "error: 'reference' is required" + if not valid_workspace_ref(ref): + return "error: invalid workspace reference (letters/digits/._:+- only, no spaces)" + comp = detect_compositor() + if comp is None: + return _no_comp() + return sh(compositor_argv(comp, "focus_workspace", ref=ref)) + + +def _move_to_workspace(a): + ref = str(a.get("reference") or "").strip() + if not ref: + return "error: 'reference' is required" + if not valid_workspace_ref(ref): + return "error: invalid workspace reference (letters/digits/._:+- only, no spaces)" + comp = detect_compositor() + if comp is None: + return _no_comp() + return sh(compositor_argv(comp, "move_to_workspace", ref=ref)) + + +def _set_wallpaper(a): + """Set a wallpaper by path, or switch to a random one if no path given.""" + path = str(a.get("path") or "").strip() + conn = str(a.get("connector") or "").strip() + if path: + argv = ["noctalia", "msg", "wallpaper-set"] + ([conn] if conn else []) + [path] + else: + argv = ["noctalia", "msg", "wallpaper-random"] + ([conn] if conn else []) + return sh(argv) + + +# name -> (description, inputSchema properties, handler). Commands verified against +# noctalia 5.0.0 (`noctalia msg --help`); window/workspace ops route through the +# compositor abstraction above (niri / Hyprland / Sway). +TOOLS = { + # ── senses (low/medium tier) ────────────────────────────────────────────── + "get_workspace": ( + "Focused output (connector, mode, current workspace) as compositor-native JSON.", + {}, + _get_workspace, + ), + "get_window": ( + "Focused window (app_id/class and title) as compositor-native JSON.", + {}, + _get_window, + ), + "get_media": ( + "Now-playing media (artist - title), or empty if nothing is playing.", + {}, + lambda a: sh(["playerctl", "metadata", "--format", "{{artist}} - {{title}}"]), + ), + "get_shell_state": ( + "Noctalia shell-internal state (active panel, theme mode, etc.) as JSON.", + {}, + lambda a: sh(["noctalia", "msg", "status"]), + ), + "get_power": ( + "Battery level/status and AC state as JSON (battery=null on desktops).", + {}, + _get_power, + ), + "get_network": ( + "Connectivity and the active Wi-Fi connection (SSID/signal) as JSON.", + {}, + _get_network, + ), + "get_processes": ( + "Top processes by CPU (pid, %cpu, %mem, command) as text.", + {}, + _get_processes, + ), + # ── memory (durable, cross-session) ─────────────────────────────────────── + "remember": ( + "Persist a durable fact, preference, or system detail to GLOBAL memory " + "(system-wide, cross-project) so future sessions know it. Use for things " + "true beyond the current task; memd distills it into ~/.memory.", + { + "text": {"type": "string", "required": True, + "description": "The durable fact/preference, 1-2 sentences."}, + "topic": {"type": "string", + "description": "Short kebab-case slug for the note (optional)."}, + }, + _remember, + ), + # ── hands ───────────────────────────────────────────────────────────────── + "notify": ( + "Post a desktop notification.", + { + "title": {"type": "string", "description": "Notification title"}, + "body": {"type": "string", "description": "Notification body"}, + }, + lambda a: sh(["notify-send", a.get("title", "Claude"), a.get("body", "")]), + ), + "set_theme_mode": ( + "Set the shell light/dark mode.", + {"mode": {"type": "string", "enum": ["dark", "light", "auto"]}}, + lambda a: sh(["noctalia", "msg", "theme-mode-set", a.get("mode", "auto")]), + ), + "set_color_scheme": ( + "Set the active color palette.", + { + "source": { + "type": "string", + "description": "builtin | wallpaper | community | custom", + }, + "name": {"type": "string", "description": "Scheme name/id for that source"}, + }, + lambda a: sh(["noctalia", "msg", "color-scheme-set", a.get("source", "builtin"), a.get("name", "")]), + ), + "focus_window": ( + "Focus a window by its id (the id/address returned by get_window).", + {"id": {"type": "string", "required": True, + "description": "Window id from get_window (niri id, Hyprland address, Sway con_id)"}}, + _focus_window, + ), + "switch_workspace": ( + "Switch to a workspace by reference (index like '2', or its name).", + {"reference": {"type": "string", "required": True, + "description": "Workspace index or name"}}, + _switch_workspace, + ), + "move_to_workspace": ( + "Move the focused column to a workspace by reference (index or name).", + {"reference": {"type": "string", "required": True, + "description": "Target workspace index or name"}}, + _move_to_workspace, + ), + "set_wallpaper": ( + "Set the wallpaper to an image path, or switch to a random one if no path.", + {"path": {"type": "string", + "description": "Image path; omit for a random wallpaper"}, + "connector": {"type": "string", + "description": "Output connector (optional; default all)"}}, + _set_wallpaper, + ), +} + + +def _tool_list(): + tools = [] + for name, (desc, props, _fn) in TOOLS.items(): + # `required` is a control flag in our table, not a JSON Schema property + # keyword — lift it to the object-level `required` array and strip it + # from each property def so strict MCP validators accept the schema. + clean = {k: {pk: pv for pk, pv in spec.items() if pk != "required"} + for k, spec in props.items()} + tools.append({ + "name": name, + "description": desc, + "inputSchema": { + "type": "object", + "properties": clean, + "required": [k for k, v in props.items() if v.get("required")], + }, + }) + return tools + + +def _dispatch(method, params): + """Return (result, error) for a request; result is None for notifications.""" + if method == "initialize": + return { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }, None + if method == "tools/list": + return {"tools": _tool_list()}, None + if method == "tools/call": + name = params.get("name") + args = params.get("arguments") or {} + entry = TOOLS.get(name) + if not entry: + return None, {"code": -32602, "message": f"unknown tool: {name}"} + try: + text = entry[2](args if isinstance(args, dict) else {}) + except Exception as e: # noqa: BLE001 — a tool bug must not crash the server + return None, {"code": -32603, "message": f"tool '{name}' failed: {e}"} + is_error = isinstance(text, str) and text.startswith("error:") + return {"content": [{"type": "text", "text": str(text)}], "isError": is_error}, None + return None, {"code": -32601, "message": f"method not found: {method}"} + + +def _emit(out, payload): + out.write(json.dumps(payload) + "\n") + out.flush() + + +def main(): + out = sys.stdout + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + _emit(out, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "parse error"}}) + continue + if not isinstance(req, dict): + _emit(out, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32600, "message": "invalid request"}}) + continue + mid = req.get("id") + method = req.get("method", "") + # Notifications (no id) — e.g. notifications/initialized: ack by ignoring. + if mid is None: + continue + try: + result, error = _dispatch(method, req.get("params") or {}) + except Exception as e: # noqa: BLE001 — never let a handler kill the loop + result, error = None, {"code": -32603, "message": f"internal error: {e}"} + resp = {"jsonrpc": "2.0", "id": mid} + if error is not None: + resp["error"] = error + else: + resp["result"] = result + _emit(out, resp) + + +if __name__ == "__main__": + main() diff --git a/claude-companion/tests/shim_spec.py b/claude-companion/tests/shim_spec.py new file mode 100644 index 0000000..2096c49 --- /dev/null +++ b/claude-companion/tests/shim_spec.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Unit tests for the shim's compositor abstraction (shim/noctalia-mcp.py). + +These cover the pure, I/O-free seam — detection, the (compositor, op) -> argv +mapping, and the client-side focus filters — so the niri/Hyprland/Sway command +shapes are pinned without a live session. Run: python3 tests/shim_spec.py + +Fixtures mirror the documented JSON shapes: Hyprland from HyprCtl.cpp serializers +(getMonitorData/getWindowData), Sway from sway-ipc(7) GET_TREE/GET_OUTPUTS/ +GET_WORKSPACES. They are the contract; a live nested-compositor run confirms them. +""" +import importlib.util +import os +import unittest +from unittest import mock + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SHIM = os.path.join(_HERE, "..", "shim", "noctalia-mcp.py") +_spec = importlib.util.spec_from_file_location("noctalia_mcp", _SHIM) +mcp = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(mcp) + + +class DetectCompositor(unittest.TestCase): + def test_socket_env_wins(self): + self.assertEqual(mcp.detect_compositor({"NIRI_SOCKET": "/x"}), "niri") + self.assertEqual( + mcp.detect_compositor({"HYPRLAND_INSTANCE_SIGNATURE": "abc"}), "hyprland") + self.assertEqual(mcp.detect_compositor({"SWAYSOCK": "/run/x"}), "sway") + + def test_socket_precedence_over_xdg(self): + # An explicit niri socket beats a mismatched XDG hint. + env = {"NIRI_SOCKET": "/x", "XDG_CURRENT_DESKTOP": "sway"} + self.assertEqual(mcp.detect_compositor(env), "niri") + + def test_xdg_fallback(self): + self.assertEqual( + mcp.detect_compositor({"XDG_CURRENT_DESKTOP": "Hyprland"}), "hyprland") + self.assertEqual( + mcp.detect_compositor({"XDG_CURRENT_DESKTOP": "sway"}), "sway") + + def test_none_when_undetectable(self): + self.assertIsNone(mcp.detect_compositor({})) + self.assertIsNone(mcp.detect_compositor({"XDG_CURRENT_DESKTOP": "gnome"})) + + +class Argv(unittest.TestCase): + def test_niri(self): + a = mcp.compositor_argv + self.assertEqual(a("niri", "focused_output"), ["niri", "msg", "-j", "focused-output"]) + self.assertEqual(a("niri", "focused_window"), ["niri", "msg", "-j", "focused-window"]) + self.assertEqual(a("niri", "focus_window", wid="42"), + ["niri", "msg", "action", "focus-window", "--id", "42"]) + self.assertEqual(a("niri", "focus_workspace", ref="3"), + ["niri", "msg", "action", "focus-workspace", "3"]) + self.assertEqual(a("niri", "move_to_workspace", ref="web"), + ["niri", "msg", "action", "move-column-to-workspace", "web"]) + + def test_hyprland(self): + a = mcp.compositor_argv + self.assertEqual(a("hyprland", "focused_output"), ["hyprctl", "-j", "monitors"]) + self.assertEqual(a("hyprland", "focused_window"), ["hyprctl", "-j", "activewindow"]) + # address: prefix is mandatory (bare selector is a class regex otherwise). + self.assertEqual(a("hyprland", "focus_window", wid="0x5641f2a3b8c0"), + ["hyprctl", "dispatch", "focuswindow", "address:0x5641f2a3b8c0"]) + # numeric ws: bare; named ws: name: prefix. + self.assertEqual(a("hyprland", "focus_workspace", ref="3"), + ["hyprctl", "dispatch", "workspace", "3"]) + self.assertEqual(a("hyprland", "focus_workspace", ref="work"), + ["hyprctl", "dispatch", "workspace", "name:work"]) + self.assertEqual(a("hyprland", "move_to_workspace", ref="3"), + ["hyprctl", "dispatch", "movetoworkspace", "3"]) + self.assertEqual(a("hyprland", "move_to_workspace", ref="work"), + ["hyprctl", "dispatch", "movetoworkspace", "name:work"]) + + def test_sway(self): + a = mcp.compositor_argv + self.assertEqual(a("sway", "focused_output"), ["swaymsg", "-t", "get_outputs"]) + self.assertEqual(a("sway", "focused_window"), ["swaymsg", "-t", "get_tree"]) + self.assertEqual(a("sway", "focus_window", wid="94"), + ["swaymsg", "[con_id=94]", "focus"]) + # numeric ws uses the `number` keyword; named ws is passed literally. + self.assertEqual(a("sway", "focus_workspace", ref="3"), + ["swaymsg", "workspace", "number", "3"]) + self.assertEqual(a("sway", "focus_workspace", ref="web"), + ["swaymsg", "workspace", "web"]) + self.assertEqual(a("sway", "move_to_workspace", ref="3"), + ["swaymsg", "move", "container", "to", "workspace", "number", "3"]) + self.assertEqual(a("sway", "move_to_workspace", ref="web"), + ["swaymsg", "move", "container", "to", "workspace", "web"]) + + def test_unknown_raises(self): + with self.assertRaises(KeyError): + mcp.compositor_argv("river", "focus_window", wid="1") + + +class InjectionGuards(unittest.TestCase): + """swaymsg concatenates argv into Sway's command language, where `;`/`,` + chain further commands (exec included) — the mutator handlers must therefore + reject anything but strictly-safe ids/refs before compositor_argv runs.""" + + def test_window_ids_accepted(self): + for wid in ("42", "94", "0x5641f2a3b8c0", "0xABCDEF"): + self.assertTrue(mcp.valid_window_id(wid), wid) + + def test_window_ids_rejected(self): + for wid in ("", " ", "1] focus; exec touch /tmp/pwn; [con_id=1", + "42; exec rm -rf ~", "0x", "0xZZ", "12 34", "id=5", "-1"): + self.assertFalse(mcp.valid_window_id(wid), wid) + + def test_workspace_refs_accepted(self): + for ref in ("3", "web", "1:web", "dev-2", "mail.work", "ws_9", "v2+"): + self.assertTrue(mcp.valid_workspace_ref(ref), ref) + + def test_workspace_refs_rejected(self): + for ref in ("", " ", "web; exec rm -rf ~", "a b", 'na"me', "x,y", + "[con_id=1]", "back\\slash", "semi;colon"): + self.assertFalse(mcp.valid_workspace_ref(ref), ref) + + def test_mutators_refuse_unsafe_args_before_any_exec(self): + # Handler-level: a hostile MCP call must come back as an error string. + # The env is scrubbed so no compositor is detectable — if the guard ever + # regresses, the handler returns the no-compositor error (failing the + # 'invalid' assertion) instead of executing the payload on a live session. + with mock.patch.dict(os.environ, {}, clear=True): + payload = "1] focus; exec touch /tmp/pwn; [con_id=1" + self.assertIn("invalid window id", mcp._focus_window({"id": payload})) + self.assertIn("invalid workspace reference", + mcp._switch_workspace({"reference": "web; exec rm -rf ~"})) + self.assertIn("invalid workspace reference", + mcp._move_to_workspace({"reference": "a b; exec id"})) + + +class HyprlandFilter(unittest.TestCase): + MONS = [ + {"name": "DP-1", "focused": False, "activeWorkspace": {"id": 1, "name": "1"}}, + {"name": "eDP-1", "focused": True, "activeWorkspace": {"id": 2, "name": "2"}}, + ] + + def test_pick_focused(self): + self.assertEqual(mcp.pick_focused_monitor(self.MONS)["name"], "eDP-1") + + def test_pick_focused_none(self): + self.assertIsNone(mcp.pick_focused_monitor( + [{"name": "DP-1", "focused": False}])) + + +class SwayTreeFilter(unittest.TestCase): + # A minimal get_tree: root -> output -> workspace -> two views, one focused, + # plus a floating view. Mirrors sway-ipc(7) node fields. + TREE = { + "type": "root", "focused": False, "nodes": [ + {"type": "output", "name": "eDP-1", "focused": False, "nodes": [ + {"type": "workspace", "name": "1", "focused": False, + "nodes": [ + {"type": "con", "id": 10, "name": "kitty", + "app_id": "kitty", "focused": False, + "nodes": [], "floating_nodes": []}, + {"type": "con", "id": 11, "name": "Firefox", + "app_id": None, + "window_properties": {"class": "firefox"}, + "focused": True, + "nodes": [], "floating_nodes": []}, + ], + "floating_nodes": []}, + ], "floating_nodes": []}, + ], "floating_nodes": [], + } + + def test_finds_focused_leaf(self): + view = mcp.find_focused_view(self.TREE) + self.assertEqual(view["id"], 11) + self.assertEqual(view["name"], "Firefox") + + def test_xwayland_class_available_for_fallback(self): + # app_id is null for XWayland; the class lives under window_properties. + view = mcp.find_focused_view(self.TREE) + self.assertIsNone(view["app_id"]) + self.assertEqual(view["window_properties"]["class"], "firefox") + + def test_finds_focused_in_floating(self): + tree = {"type": "root", "focused": False, "nodes": [], "floating_nodes": [ + {"type": "floating_con", "id": 20, "name": "mpv", + "app_id": "mpv", "focused": True, "nodes": [], "floating_nodes": []}, + ]} + self.assertEqual(mcp.find_focused_view(tree)["id"], 20) + + def test_no_focus_returns_none(self): + tree = {"type": "root", "focused": False, "nodes": [], "floating_nodes": []} + self.assertIsNone(mcp.find_focused_view(tree)) + + +class SwayFocusedOutput(unittest.TestCase): + OUTPUTS = [ + {"name": "DP-1", "active": True, "current_workspace": "2"}, + {"name": "eDP-1", "active": True, "current_workspace": "1"}, + ] + WORKSPACES = [ + {"num": 1, "name": "1", "focused": True, "output": "eDP-1"}, + {"num": 2, "name": "2", "focused": False, "output": "DP-1"}, + ] + + def test_derives_output_from_focused_workspace(self): + out = mcp.sway_focused_output(self.OUTPUTS, self.WORKSPACES) + self.assertEqual(out["name"], "eDP-1") + + def test_none_when_no_focused_workspace(self): + wss = [dict(w, focused=False) for w in self.WORKSPACES] + self.assertIsNone(mcp.sway_focused_output(self.OUTPUTS, wss)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/claude-companion/thumbnail.webp b/claude-companion/thumbnail.webp new file mode 100644 index 0000000..0e28f4e Binary files /dev/null and b/claude-companion/thumbnail.webp differ diff --git a/claude-companion/translations/en.json b/claude-companion/translations/en.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/claude-companion/translations/en.json @@ -0,0 +1 @@ +{}