aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-26 09:33:00 +0200
committerDanilo M. <danix@danix.xyz>2026-09-26 09:33:00 +0200
commit858fc7fe5e32c2db1133bcff3a50808e42dedc4e (patch)
tree1931a20a05eebe4cd8b1e5bb97bb8e6637289863
parent94c57e833c158d503b372baebce3dd6942f684a5 (diff)
downloaddesktop-assistant-858fc7fe5e32c2db1133bcff3a50808e42dedc4e.tar.gz
desktop-assistant-858fc7fe5e32c2db1133bcff3a50808e42dedc4e.zip
Add implementation plan for the assistant overlay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-09-26-overlay.md1000
1 files changed, 1000 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-26-overlay.md b/docs/superpowers/plans/2026-09-26-overlay.md
new file mode 100644
index 0000000..fdb05a7
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-26-overlay.md
@@ -0,0 +1,1000 @@
+# Assistant Overlay Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A quickshell overlay that shows the assistant's state, context use, running tool and real audio levels, fed by a JSON-lines Unix socket the assistant owns; ignored presses send a notification instead.
+
+**Architecture:** `assistant.py` gains a socket broadcaster (stdlib `socket` + threads), state/ctx/level events at the points the voice loop already passes through, a playback-clocked level emitter for the reply, and `notify-send` for ignored presses. A new standalone component `assistant/` in the quickshell repo connects to the socket and draws the circle and the wave ring. With no overlay running, the assistant behaves exactly as before.
+
+**Tech Stack:** Python 3.12 stdlib (+ numpy, already present through kokoro-onnx), `notify-send`, Quickshell 0.3.1 QML (`Quickshell.Io.Socket`, `Canvas`), node for the QML library test.
+
+**Spec:** `docs/superpowers/specs/2026-09-26-overlay-design.md`
+
+**Conventions (both repos):**
+- No em dashes. No personal data or home paths in committed files (gitleaks hooks enforce it). GPLv2-only header in every new source file.
+- Commits GPG-signed (default config), `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` at the end, pushed after each commit. Commit only with the user's go-ahead for this plan's work.
+- desktop-assistant self-check: `python3 assistant.py --test` prints `ok`.
+- quickshell repo: read its `AGENTS.md` first. Process is `qs` (never `pkill -f`, never `pkill -x qs`, which kills every component: stop only this one with `qs kill -p <dir>`). A detached `qs` dies with the agent's tool call: run it as a harness-owned background command and read its log. For anything visual, ask the user. Commit subjects there use the `feat(component): ...` style.
+
+**Deviation from the spec, decided while planning:** `speaking` is emitted by the reply thread when it queues the first sentence of an answer (and again after a tool round), not by the speaker when audio starts. If the speaker emitted it, the tool announcement would overwrite the `tool` state and hide the tool icon while the tool is still running. `idle` still comes from the speaker when the reply's audio has finished.
+
+---
+
+## File structure
+
+| File | Repo | Status | Responsibility |
+|------|------|--------|----------------|
+| `assistant.py` | desktop-assistant | modify | level maths, events socket, notifications, wiring |
+| `examples/config.toml`, `README.md`, `AGENTS.md` | desktop-assistant | modify | `[overlay]` options, docs, findings |
+| `assistant/shell.qml` | quickshell | create | shell root |
+| `assistant/Assistant.qml` | quickshell | create | socket client, state, overlay window |
+| `assistant/Place.js`, `assistant/test_place.js` | quickshell | create | position maths and its node test |
+| `assistant/Theme.qml` | quickshell | symlink | `-> ../shared/Theme.qml` (repo rule: never a copy) |
+| `assistant/README.md` | quickshell | create | what it is, protocol pointer, run/test |
+
+---
+
+### Task 1: level maths
+
+**Files:** Modify `assistant.py` (imports, constants after `TOOL_SCHEMA`, helpers after `latin()`, `test()`)
+
+- [ ] **Step 1: failing test.** Append to `test()` before `print("ok")`:
+
+```python
+ import numpy as np
+ assert scale(0) == 0.0 and scale(1) == 0.0 # -90 dBFS clamps to 0
+ assert scale(32768) == 1.0
+ assert abs(scale(32768 * 10 ** (-30 / 20)) - 0.5) < 1e-9
+ assert pcm_level(b"") == 0.0
+ assert pcm_level(array.array("h", [1000] * 100).tobytes()) == scale(1000)
+ assert pcm_level(b"\x00") == 0.0 # half a sample is ignored
+ env = envelope(np.zeros(24000, dtype=np.float32), 24000)
+ assert len(env) == 30 and max(env) == 0.0 # 1 s at 30 frames/s
+ env = envelope(np.full(24000, 0.5, dtype=np.float32), 24000)
+ assert all(abs(v - scale(16384)) < 1e-6 for v in env)
+```
+
+- [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `NameError: name 'scale' is not defined`.
+
+- [ ] **Step 3: implement.** Add `import array` (before `import ctypes`) and `import math` (after `import json`) to the imports.
+
+After `TOOL_SCHEMA = schema(TOOLS) or None` add:
+
+```python
+FRAME = 1 / 30 # one audio level per frame, seconds
+```
+
+After `latin()` add:
+
+```python
+def scale(rms):
+ # Loudness as the overlay draws it: -60 dBFS and below is 0, full scale
+ # is 1. Logarithmic, so quiet speech still moves the ring.
+ if rms <= 0:
+ return 0.0
+ return max(0.0, min(1.0, (20 * math.log10(rms / 32768) + 60) / 60))
+
+
+def pcm_level(data):
+ # 16-bit little-endian mono, as arecord writes it; a trailing odd byte is
+ # half a sample and ignored.
+ samples = array.array("h", data[: len(data) // 2 * 2])
+ if not samples:
+ return 0.0
+ return scale(math.sqrt(sum(s * s for s in samples) / len(samples)))
+
+
+def envelope(audio, sr):
+ # One level per FRAME of float audio in -1..1 (Kokoro's output).
+ n = int(sr * FRAME)
+ frames = audio[: len(audio) // n * n].reshape(-1, n)
+ return [scale(float(r) * 32768) for r in (frames.astype("float64") ** 2).mean(axis=1) ** 0.5]
+```
+
+- [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`.
+
+- [ ] **Step 5: commit**
+
+```bash
+git add assistant.py
+git commit -m "Compute audio levels for the overlay
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 2: say why a press was ignored
+
+**Files:** Modify `assistant.py` (`presenting()`, new `notify_ignored()`, `main()`, `test()`)
+
+- [ ] **Step 1: failing test.** In `test()`, replace the existing presenting block:
+
+```python
+ with tempfile.TemporaryDirectory() as d:
+ assert not presenting(d)
+ for name, value, want in (("gaming", "1", True), ("gaming", "0", False),
+ ("presentation", "1\n", True)):
+ with open(os.path.join(d, f"status.{name}"), "w") as f:
+ f.write(value)
+ assert presenting(d) == want, (name, value)
+```
+
+with:
+
+```python
+ with tempfile.TemporaryDirectory() as d:
+ assert presenting(d) == ""
+ for name, value, want in (("gaming", "1", "gaming"), ("gaming", "0", ""),
+ ("presentation", "1\n", "presentation")):
+ with open(os.path.join(d, f"status.{name}"), "w") as f:
+ f.write(value)
+ assert presenting(d) == want, (name, value)
+```
+
+- [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `AssertionError` (`presenting` returns `False`, not `""`).
+
+- [ ] **Step 3: implement.** In `presenting()`, replace `return flag("presentation") or flag("gaming")` with:
+
+```python
+ # Why the press is ignored, for the notification; "" when it is not.
+ return "gaming" if flag("gaming") else "presentation" if flag("presentation") else ""
+```
+
+After `presenting()` add:
+
+```python
+notify_id = ""
+
+
+def notify_ignored(reason):
+ # Normal urgency: DND (on during presentation mode) holds it back from
+ # popping over a call or a game, and it waits in the notification list.
+ # Each press replaces the last (-r), so one game makes one entry.
+ global notify_id
+ cmd = ["notify-send", "-a", "desktop-assistant", "-u", "normal", "-p"]
+ if notify_id:
+ cmd += ["-r", notify_id]
+ cmd += ["Voice assistant paused", f"The button press was ignored: {reason} mode is on."]
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ notify_id = r.stdout.strip() or notify_id
+```
+
+In `main()`, replace:
+
+```python
+ if on and presenting():
+ log("presentation mode: ignored")
+ elif on:
+```
+
+with:
+
+```python
+ reason = presenting() if on else ""
+ if reason:
+ log(f"{reason} mode: ignored")
+ notify_ignored(reason)
+ elif on:
+```
+
+- [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`. Then check the notification by hand: `python3 -c "import assistant as a; a.notify_ignored('gaming'); a.notify_ignored('gaming'); print(a.notify_id)"`. Expected: an id printed, and one "Voice assistant paused" notification (the second replaced the first; with DND on it is in the drawer list, not popped). Ask the user to confirm there is one entry, not two.
+
+- [ ] **Step 5: commit**
+
+```bash
+git add assistant.py
+git commit -m "Notify when a press is ignored in presentation or gaming mode
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 3: events socket
+
+**Files:** Modify `assistant.py` (`DEFAULTS`, imports, new block after `notify_ignored()`, `ask()`, `test()`)
+
+- [ ] **Step 1: failing test.** Append to `test()` before `print("ok")`:
+
+```python
+ import socket
+ a, b = socket.socketpair()
+ a.setblocking(False)
+ clients.append(a)
+ emit({"type": "ctx", "used": 1, "max": 2})
+ assert json.loads(b.recv(4096)) == {"type": "ctx", "used": 1, "max": 2}
+ emit({"type": "state", "state": "tool", "tool": "search"})
+ assert now_state == {"state": "tool", "tool": "search"}
+ b.recv(4096)
+ b.close()
+ emit({"type": "state", "state": "idle"}) # the dead client is dropped
+ emit({"type": "state", "state": "idle"})
+ assert a not in clients and now_state == {"state": "idle"}
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "s.sock")
+ serve(path)
+ assert os.stat(path).st_mode & 0o777 == 0o600
+ c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ c.connect(path)
+ f = c.makefile()
+ first = json.loads(f.readline())
+ assert first["type"] == "hello" and first["name"] == CFG["overlay"]["name"]
+ assert first["state"] == "idle" and "ctx_max" in first
+ for _ in range(100): # accept() appends just after hello
+ if clients:
+ break
+ time.sleep(0.01)
+ emit({"type": "level", "who": "user", "value": 0.5})
+ assert json.loads(f.readline()) == {"type": "level", "who": "user", "value": 0.5}
+ c.close()
+```
+
+- [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `NameError: name 'clients' is not defined`.
+
+- [ ] **Step 3: implement.** Add `import socket` (after `import signal`) and `import urllib.parse` (after `import unicodedata`).
+
+In `DEFAULTS`, after `"env": {},` add:
+
+```python
+ "overlay": {"name": "Assistant", "position": "center", "hide_delay": 3.0,
+ "user_color": "accent", "assistant_color": "green"},
+```
+
+After `FRAME = ...` add:
+
+```python
+SOCK = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "desktop-assistant.sock")
+```
+
+After `notify_ignored()` add:
+
+```python
+# The overlay's feed: JSON lines on a Unix socket, one event per line. With
+# no client connected every event is dropped, so the voice loop never waits
+# on a UI.
+clients, clients_lock = [], threading.Lock()
+now_state = {"state": "idle"} # repeated in hello for a late client
+ctx = {"model": None, "max": 0} # context size of the model in use
+
+
+def emit(event):
+ if event["type"] == "state":
+ now_state.clear()
+ now_state.update({k: v for k, v in event.items() if k != "type"})
+ line = (json.dumps(event) + "\n").encode()
+ with clients_lock:
+ for c in clients[:]:
+ try:
+ c.sendall(line) # non-blocking: a stalled client errors
+ except OSError:
+ clients.remove(c)
+ c.close()
+
+
+def hello():
+ return {"type": "hello", **CFG["overlay"], "ctx_max": ctx["max"], **now_state}
+
+
+def serve(path=SOCK):
+ try:
+ os.unlink(path) # left over from a crash
+ except FileNotFoundError:
+ pass
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ old = os.umask(0o177) # 0600: only this user reads the stream
+ try:
+ srv.bind(path)
+ finally:
+ os.umask(old)
+ srv.listen()
+
+ def accept():
+ while True:
+ c, _ = srv.accept()
+ try:
+ c.sendall((json.dumps(hello()) + "\n").encode())
+ except OSError:
+ c.close()
+ continue
+ c.setblocking(False)
+ with clients_lock:
+ clients.append(c)
+ threading.Thread(target=accept, daemon=True).start()
+
+
+def context_size(model):
+ # The router can swap models, so ask again whenever the id changes.
+ if ctx["model"] != model:
+ url = f"{CFG['llm'].removesuffix('/v1')}/props?model={urllib.parse.quote(model)}"
+ try:
+ with urllib.request.urlopen(url, timeout=5) as r:
+ ctx["max"] = json.load(r)["default_generation_settings"]["n_ctx"]
+ except (OSError, KeyError, ValueError):
+ ctx["max"] = 0
+ ctx["model"] = model
+ return ctx["max"]
+```
+
+In `ask()`, replace:
+
+```python
+ body = {"model": llm_model(), "messages": messages, "stream": True,
+```
+
+with:
+
+```python
+ model = llm_model()
+ context_size(model)
+ body = {"model": model, "messages": messages, "stream": True,
+```
+
+and replace:
+
+```python
+ log(f"context: {chunk['usage']['prompt_tokens']} prompt tokens")
+```
+
+with:
+
+```python
+ used = chunk["usage"]["prompt_tokens"]
+ log(f"context: {used} prompt tokens")
+ emit({"type": "ctx", "used": used, "max": ctx["max"]})
+```
+
+- [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`. Then: `python3 -c "import assistant as a; print(a.context_size(a.llm_model()))"`. Expected: `32768` with the resident Gemma.
+
+- [ ] **Step 5: commit**
+
+```bash
+git add assistant.py
+git commit -m "Broadcast assistant events on a Unix socket for the overlay
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 4: emit state and levels from the voice loop
+
+**Files:** Modify `assistant.py` (`speaker()`, new `user_levels()` and `level_emitter()`, `reply()`, `main()`)
+
+- [ ] **Step 1: user levels and the emitter.** After `speaker()` add:
+
+```python
+def user_levels(rec, mine):
+ # The user's waves: the loudness of what arecord wrote since last time.
+ pos = 0
+ while rec.poll() is None and turn == mine:
+ time.sleep(FRAME)
+ try:
+ with open(RAW, "rb") as f:
+ f.seek(pos)
+ data = f.read()
+ except OSError:
+ continue
+ if len(data) >= 2:
+ pos += len(data) // 2 * 2
+ emit({"type": "level", "who": "user", "value": pcm_level(data)})
+
+
+def level_emitter(lq):
+ # The reply's waves, each frame sent when its audio plays rather than when
+ # it was synthesised (synthesis runs ahead of playback).
+ while True:
+ t, due, value = lq.get()
+ while t == turn and (wait := due - time.monotonic()) > 0:
+ time.sleep(min(wait, 0.05))
+ if t == turn:
+ emit({"type": "level", "who": "assistant", "value": value})
+```
+
+- [ ] **Step 2: speaker clock.** Change `def speaker(q):` to `def speaker(q, lq):`. Replace ` play, playing = None, None # aplay and the turn it speaks for` with:
+
+```python
+ play, playing = None, None # aplay and the turn it speaks for
+ start, written = 0.0, 0 # playback clock of the open stream
+```
+
+Replace the end-of-reply block:
+
+```python
+ if item is None: # end of reply
+ if play:
+ play.stdin.close()
+ play.wait()
+ play = None
+ continue
+```
+
+with:
+
+```python
+ if item is None: # end of reply
+ if play:
+ play.stdin.close()
+ play.wait()
+ play = None
+ if t == turn:
+ emit({"type": "state", "state": "idle"})
+ continue
+```
+
+Replace the silence block's write line ` play.stdin.write(bytes(int(sr * item) * 2))` with:
+
+```python
+ play.stdin.write(bytes(int(sr * item) * 2))
+ written += int(sr * item)
+```
+
+Replace:
+
+```python
+ playing, player = t, play
+ # A new stream wakes the suspended sink and the first ~0.2 s is
+ # lost: lead with silence so it eats that, not the first word.
+ play.stdin.write(bytes(int(sr * 0.3) * 2))
+ # The pipe buffer lets synthesis of the next sentence overlap playback.
+ play.stdin.write((audio.clip(-1, 1) * 32767).astype("<i2").tobytes())
+```
+
+with:
+
+```python
+ playing, player = t, play
+ # A new stream wakes the suspended sink and the first ~0.2 s is
+ # lost: lead with silence so it eats that, not the first word.
+ play.stdin.write(bytes(int(sr * 0.3) * 2))
+ start, written = time.monotonic() + 0.3, 0
+ # This sentence plays after everything written so far. If synthesis
+ # fell behind (an underrun), playback restarts from now.
+ # ponytail: no sound-server latency correction; add an offset here
+ # if the waves visibly lead the voice.
+ now = time.monotonic()
+ if start + written / sr < now:
+ start = now - written / sr
+ due = start + written / sr
+ for i, value in enumerate(envelope(audio, sr)):
+ lq.put((t, due + i * FRAME, value))
+ written += len(audio)
+ # The pipe buffer lets synthesis of the next sentence overlap playback.
+ play.stdin.write((audio.clip(-1, 1) * 32767).astype("<i2").tobytes())
+```
+
+- [ ] **Step 3: reply states.** In `reply()`:
+
+Replace:
+
+```python
+ if len(pcm) < 16000 * 2 * 0.3: # under 0.3 s: a stray press
+ return
+```
+
+with:
+
+```python
+ if len(pcm) < 16000 * 2 * 0.3: # under 0.3 s: a stray press
+ emit({"type": "state", "state": "idle"})
+ return
+```
+
+Replace ` t = time.perf_counter()\n lang, text = stt()` (the first two lines of the STT part) with:
+
+```python
+ emit({"type": "state", "state": "transcribing"})
+ t = time.perf_counter()
+ lang, text = stt()
+```
+
+Replace:
+
+```python
+ if not text or text.startswith("[") or turn != mine: # [BLANK_AUDIO] and friends
+ return
+```
+
+with:
+
+```python
+ if turn != mine:
+ return
+ if not text or text.startswith("["): # [BLANK_AUDIO] and friends
+ emit({"type": "state", "state": "idle"})
+ return
+```
+
+After the line ` log(f"you ({lang}, {time.perf_counter() - t:.2f}s): {text}")` add:
+
+```python
+ emit({"type": "state", "state": "thinking"})
+```
+
+In `say(s)`, before `said.append(s)` add:
+
+```python
+ if now_state["state"] != "speaking":
+ emit({"type": "state", "state": "speaking"})
+```
+
+Replace:
+
+```python
+ for c in calls.values():
+ result = run_tool(TOOLS, c["name"], c["arguments"])
+```
+
+with:
+
+```python
+ for c in calls.values():
+ emit({"type": "state", "state": "tool", "tool": c["name"]})
+ result = run_tool(TOOLS, c["name"], c["arguments"])
+```
+
+After that `for` loop (after its last line ` msgs.append({"role": "tool", "tool_call_id": c["id"], "content": result})`) add, at the tool loop's indentation level inside the rounds loop:
+
+```python
+ if turn == mine:
+ emit({"type": "state", "state": "thinking"})
+```
+
+- [ ] **Step 4: main.** In `main()`:
+
+After ` start_whisper()` add ` serve()`.
+
+Replace:
+
+```python
+ q = queue.Queue()
+ threading.Thread(target=speaker, args=(q,), daemon=True).start()
+```
+
+with:
+
+```python
+ q, lq = queue.Queue(), queue.Queue()
+ threading.Thread(target=speaker, args=(q, lq), daemon=True).start()
+ threading.Thread(target=level_emitter, args=(lq,), daemon=True).start()
+```
+
+In the `elif on:` branch, after ` player.kill()` (the barge-in kill) add ` emit({"type": "state", "state": "listening"})`, and after the `rec = subprocess.Popen([...arecord...])` statement add:
+
+```python
+ threading.Thread(target=user_levels, args=(rec, turn), daemon=True).start()
+```
+
+In the `finally:` block, change ` for f in (RAW, WAV): # the user's voice: never keep it` to:
+
+```python
+ for f in (RAW, WAV, SOCK): # the user's voice: never keep it
+```
+
+- [ ] **Step 5:** Run `python3 assistant.py --test` and `python3 -m py_compile assistant.py`. Expected: `ok`.
+
+- [ ] **Step 6: check the stream live.** Restart the running assistant (stop its background task, start `python3 -u assistant.py` again as a harness-owned background command with the log tee'd to the scratchpad). In a second harness-owned background command run `socat -u UNIX-CONNECT:$XDG_RUNTIME_DIR/desktop-assistant.sock - | grep --line-buffered -v '"level"' | tee <scratchpad>/events.log` (levels filtered to keep it readable) and count levels separately with `socat -u UNIX-CONNECT:... - | grep -c level` over one question. Ask the user for a search question. Expected sequence in events.log: `hello`, `listening`, `transcribing`, `thinking`, `ctx`, `tool` with `"tool": "search"`, `thinking`, `ctx`, `speaking`, `idle`. Expected levels: user frames during the press, assistant frames during speech, roughly 30 per second of each. Also check barge-in still works by ear.
+
+- [ ] **Step 7: commit**
+
+```bash
+git add assistant.py
+git commit -m "Emit state, context and audio levels from the voice loop
+
+speaking comes from the reply thread, not the speaker: the tool
+announcement would otherwise replace the tool state while the tool is
+still running.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+---
+
+### Task 5: quickshell position helper
+
+**Files (quickshell repo, `../quickshell`):** Create `assistant/Place.js`, `assistant/test_place.js`
+
+- [ ] **Step 1: failing test.** Create `assistant/test_place.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node assistant/test_place.js
+// Place.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Place.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const P = new Function(src + "\nreturn { at };")();
+
+assert.deepStrictEqual(P.at("center", 1920, 1080, 360, 24), { x: 780, y: 360 });
+assert.deepStrictEqual(P.at("top", 1920, 1080, 360, 24), { x: 780, y: 24 });
+assert.deepStrictEqual(P.at("bottom", 1920, 1080, 360, 24), { x: 780, y: 696 });
+assert.deepStrictEqual(P.at("top-left", 1920, 1080, 360, 24), { x: 24, y: 24 });
+assert.deepStrictEqual(P.at("top-right", 1920, 1080, 360, 24), { x: 1536, y: 24 });
+assert.deepStrictEqual(P.at("bottom-left", 1920, 1080, 360, 24), { x: 24, y: 696 });
+assert.deepStrictEqual(P.at("bottom-right", 1920, 1080, 360, 24), { x: 1536, y: 696 });
+// An unknown position is the centre, not an error.
+assert.deepStrictEqual(P.at("middle", 1920, 1080, 360, 24), { x: 780, y: 360 });
+console.log("ok");
+```
+
+- [ ] **Step 2:** Run `node assistant/test_place.js` from the quickshell repo. Expected: `ENOENT` for `Place.js`.
+
+- [ ] **Step 3: implement.** Create `assistant/Place.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Top-left corner of a size x size square on a W x H screen, margin away
+// from the edges its position names. Anything unknown is the centre.
+function at(position, W, H, size, margin) {
+ const x = position.endsWith("left") ? margin
+ : position.endsWith("right") ? W - size - margin
+ : (W - size) / 2;
+ const y = position.startsWith("top") ? margin
+ : position.startsWith("bottom") ? H - size - margin
+ : (H - size) / 2;
+ return { x, y };
+}
+```
+
+- [ ] **Step 4:** Run `node assistant/test_place.js`. Expected: `ok`.
+
+- [ ] **Step 5:** No commit yet; the component is committed whole in Task 6.
+
+---
+
+### Task 6: the overlay component
+
+**Files (quickshell repo):** Create `assistant/shell.qml`, `assistant/Assistant.qml`, `assistant/README.md`; symlink `assistant/Theme.qml`.
+
+- [ ] **Step 1: theme link.** From the quickshell repo: `ln -s ../shared/Theme.qml assistant/Theme.qml`.
+
+- [ ] **Step 2: shell.qml**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+
+ShellRoot {
+ Assistant {}
+}
+```
+
+- [ ] **Step 3: Assistant.qml**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import QtQuick
+import "Place.js" as Place
+
+// The voice assistant's overlay. desktop-assistant broadcasts its state,
+// context use and audio levels as JSON lines on a Unix socket; this only
+// listens, and keeps reconnecting while the assistant is down.
+Scope {
+ id: root
+
+ // Replaced by the assistant's hello; these render only before it.
+ property string name: "Assistant"
+ property string position: "center"
+ property real hideDelay: 3
+ property string userColor: "accent"
+ property string assistantColor: "green"
+
+ property int ctxUsed: 0
+ property int ctxMax: 0
+ property string state: "idle"
+ property string tool: ""
+ property var levels: [] // newest last, at most 64
+ property bool shown: false
+ property var shownScreen: Quickshell.screens[0]
+
+ // Faded, not switched: the window stays loaded until the fade has run.
+ property real fade: shown ? 1 : 0
+ Behavior on fade { NumberAnimation { duration: 200 } }
+
+ // Colour roles are palette names from udt, so a theme change recolours
+ // the waves live. An unknown name falls back to the accent.
+ function colorOf(role) { return Theme[role] ?? Theme.accent; }
+
+ function handle(line) {
+ let e;
+ try { e = JSON.parse(line); } catch (err) { return; }
+ if (e.type === "hello") {
+ root.name = e.name;
+ root.position = e.position;
+ root.hideDelay = e.hide_delay;
+ root.userColor = e.user_color;
+ root.assistantColor = e.assistant_color;
+ root.ctxMax = e.ctx_max;
+ root.setState(e.state, e.tool ?? "");
+ } else if (e.type === "state") {
+ root.setState(e.state, e.tool ?? "");
+ } else if (e.type === "ctx") {
+ root.ctxUsed = e.used;
+ root.ctxMax = e.max;
+ } else if (e.type === "level") {
+ const l = root.levels.slice(-63);
+ l.push({ who: e.who, value: e.value });
+ root.levels = l;
+ }
+ }
+
+ function setState(s, t) {
+ root.state = s;
+ root.tool = t;
+ if (s === "listening" && !root.shown) {
+ // The monitor in focus when it appears, not wherever it last was.
+ const focused = Hyprland.focusedMonitor?.name;
+ root.shownScreen = Quickshell.screens.find(x => x.name === focused) ?? Quickshell.screens[0];
+ root.levels = [];
+ root.shown = true;
+ }
+ if (s === "idle") hideTimer.restart(); else hideTimer.stop();
+ }
+
+ Timer {
+ id: hideTimer
+ interval: root.hideDelay * 1000
+ onTriggered: root.shown = false
+ }
+
+ Socket {
+ id: sock
+ path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock`
+ connected: true
+ onConnectionStateChanged: if (!connected) root.shown = false
+ parser: SplitParser { onRead: line => root.handle(line) }
+ }
+
+ // The assistant may start after this shell, or restart: keep trying.
+ Timer {
+ interval: 2000
+ repeat: true
+ running: !sock.connected
+ onTriggered: sock.connected = true
+ }
+
+ // A config with no visible window exits, reporting nothing. This 1x1
+ // click-through window holds the shell open while the overlay is hidden.
+ PanelWindow {
+ anchors { top: true; left: true }
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+
+ LazyLoader {
+ active: root.fade > 0
+
+ PanelWindow {
+ screen: root.shownScreen
+ anchors { top: true; bottom: true; left: true; right: true }
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-assistant"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ Item {
+ id: orb
+ readonly property int size: 360
+ readonly property var spot: Place.at(root.position, parent.width, parent.height, size, 24)
+ x: spot.x
+ y: spot.y
+ width: size
+ height: size
+ opacity: root.fade
+
+ // The waves: one radial bar per level, newest at the top and
+ // older ones following clockwise, so the ring moves like a
+ // waveform. Bar colour says who was talking.
+ Canvas {
+ id: waves
+ anchors.fill: parent
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+ const c = width / 2, r0 = 86, reach = 90, n = 64;
+ const ls = root.levels;
+ ctx.lineWidth = 4;
+ ctx.lineCap = "round";
+ ls.forEach((l, i) => {
+ const a = -Math.PI / 2 + (ls.length - 1 - i) * 2 * Math.PI / n;
+ const len = 3 + l.value * reach;
+ ctx.strokeStyle = `${root.colorOf(l.who === "user" ? root.userColor : root.assistantColor)}`;
+ ctx.beginPath();
+ ctx.moveTo(c + r0 * Math.cos(a), c + r0 * Math.sin(a));
+ ctx.lineTo(c + (r0 + len) * Math.cos(a), c + (r0 + len) * Math.sin(a));
+ ctx.stroke();
+ });
+ }
+ Connections {
+ target: root
+ function onLevelsChanged() { waves.requestPaint(); }
+ }
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: 160
+ height: 160
+ radius: 80
+ color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.8)
+ border.color: root.colorOf(root.assistantColor)
+ border.width: 2
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 4
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.name
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize + 2
+ font.bold: true
+ }
+ // nf-fa-wrench, present in Inconsolata Nerd Font.
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: root.state === "tool"
+ text: ""
+ color: Theme.peach
+ font.family: Theme.iconFamily
+ font.pixelSize: 22
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.state === "tool" ? root.tool
+ : root.ctxMax > 0 ? `${root.ctxUsed} / ${root.ctxMax}`
+ : `${root.ctxUsed}`
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 3
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: README.md** for the component:
+
+```markdown
+# assistant
+
+Overlay for [desktop-assistant](https://example.org/desktop-assistant), the
+local voice assistant. It appears when the mic button is pressed and shows
+the assistant's name, its model context use, the tool running, and a ring of
+sound waves that follows the real audio: the user's voice in one palette
+colour, the reply in another. It fades out a few seconds after the reply.
+
+The assistant owns a Unix socket, `$XDG_RUNTIME_DIR/desktop-assistant.sock`,
+and writes one JSON event per line (`hello`, `state`, `ctx`, `level`); this
+component only reads it and reconnects every 2 s while the assistant is down.
+Name, position, fade delay and the two colour roles come from the
+assistant's `config.toml` `[overlay]` table, in the `hello` event. The
+protocol is specified in the desktop-assistant repo,
+`docs/superpowers/specs/2026-09-26-overlay-design.md`.
+
+Run: `qs -p <this dir>`. Test the position maths: `node assistant/test_place.js`.
+```
+
+Replace `https://example.org/desktop-assistant` with the real public URL if the user gives one; otherwise leave the example.org placeholder (no personal host names in this repo).
+
+- [ ] **Step 5: load check.** Run `node assistant/test_place.js` (expect `ok`). Start the component as a harness-owned background command: `qs -p <quickshell repo>/assistant 2>&1 | tee <scratchpad>/qs-assistant.log`. Expected in the log: `Configuration Loaded` and no QML errors (a `Socket` error while the assistant is down is expected and retried). If `Socket`'s `connected`, `connectionStateChanged` or `parser` do not exist under those names, the log names the property: fix it against Quickshell 0.3.1's Socket and note the real names in the component README.
+
+- [ ] **Step 6: visual check with the user.** With the assistant running (Task 4), ask the user to press, speak, and ask a search question. Ask them to confirm by eye: overlay appears on the focused monitor, waves follow their voice in the accent colour and the reply in green, the tool icon and "search" show during the search, the context line reads `N / 32768`, it fades about 3 s after the reply, a barge-in keeps it up, a press during a game shows nothing (only the notification), and it reconnects after the assistant restarts. Fix what they report before committing.
+
+- [ ] **Step 7: commit in the quickshell repo**
+
+```bash
+git add assistant/
+git commit -m "feat(assistant): overlay for the voice assistant
+
+Reads desktop-assistant's event socket: state, context use, the tool
+running, and audio levels drawn as a wave ring around a circle, the
+user's voice and the reply in two palette colours. Standalone like the
+other components; it reconnects while the assistant is down, and the
+assistant runs the same without it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+```
+
+(Check the quickshell repo's recent log for its attribution style first and match it.)
+
+---
+
+### Task 7: autostart and docs
+
+**Files:** the user's `~/.config/hypr/sections/autostart.lua` (live config, not in any repo), desktop-assistant `examples/config.toml`, `README.md`, `AGENTS.md`; quickshell `AGENTS.md` component list.
+
+- [ ] **Step 1: autostart.** Show the user the line to add next to the other quickshell components and add it only after they confirm (outward-facing, their live config):
+
+```lua
+ hl.exec_cmd("qs -p /home/<user>/Programming/GIT/quickshell/assistant")
+```
+
+(written with the real absolute path, matching the neighbouring lines).
+
+- [ ] **Step 2: example config.** In desktop-assistant `examples/config.toml`, before the `[env]` block, add:
+
+```toml
+# The quickshell overlay (quickshell repo, assistant/). udt palette names
+# for the colours.
+# [overlay]
+# name = "Assistant"
+# position = "center" # center, top, bottom, top-left, top-right, bottom-left, bottom-right
+# hide_delay = 3.0 # seconds idle before it fades out
+# user_color = "accent"
+# assistant_color = "green"
+```
+
+- [ ] **Step 3: README.md** (desktop-assistant): add a short "Overlay" section after "Configuration and tools": the socket path, that the assistant runs the same with no overlay, that the overlay lives in the quickshell repo's `assistant/` component, and the `[overlay]` options.
+
+- [ ] **Step 4: quickshell AGENTS.md:** add `assistant/ overlay for the voice assistant, fed by its event socket` to the component list at the top.
+
+- [ ] **Step 5: AGENTS.md findings** (desktop-assistant): a "### Overlay (2026-09-26)" section before "### Barge-in" with what the live checks in Tasks 4 and 6 measured (event sequence, level rate, anything the user reported and how it was fixed). Remove nothing else.
+
+- [ ] **Step 6: commit both repos** (desktop-assistant: examples/config.toml, README.md, AGENTS.md; quickshell: AGENTS.md), each with its own message, and push.