aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-10-23-assistant-cloud-models.md909
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-blush.md655
-rw-r--r--docs/superpowers/plans/2026-10-23-companion.md1381
3 files changed, 2945 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md
new file mode 100644
index 0000000..6d9628a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md
@@ -0,0 +1,909 @@
+# Assistant cloud models (quickshell side) 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:** Configure cloud providers in the settings window's Assistant page, and pick the model for each chat from the assistant canvas.
+
+**Architecture:** The daemon (desktop-assistant, already done) owns providers, keys and routing. This side only edits `[[cloud]]` tables in its `config.toml` (`Config.js`, line based, as for the other keys), runs `assistant.py --cloud-models <name>` to fetch a provider's ids, and sends `model` with each `ask`. The canvas keeps the chat's model from the daemon's `hello.chat_model` and `conversation.model`, and lists models from its `models` event in a panel like History.
+
+**Tech Stack:** Quickshell 0.3.1 QML, `.pragma library` JS tested with node. Check: `node desktop/modules/assistant/test_config.js` (prints nothing, exits 0). QML is checked live by the user (a detached `qs` does not survive an agent's tool call, AGENTS.md).
+
+Spec: desktop-assistant `docs/superpowers/specs/2026-10-23-cloud-models-design.md`, sections "Settings page" and "Canvas header". Daemon protocol now in place: `ask` takes `model`; `hello` has `chat_model`; `conversation` has `model`; `{"type": "models"}` answers `{"type": "models", "items": [{id, status, cloud: false} | {id: "provider:model", provider, cloud: true}]}`; `assistant.py --cloud-models NAME` prints a JSON array of ids, or exits non-zero with the reason on stderr.
+
+---
+
+## File map
+
+- Modify `desktop/modules/assistant/Config.js`: `clouds`, `setClouds`, `cloudError`.
+- Modify `desktop/modules/assistant/test_config.js`: their checks.
+- Modify `desktop/modules/assistant/AssistantModule.qml`: cloud state, edits, fetch, `save(done)`, `ensureModel` replacing `ensurePreferred`.
+- Modify `desktop/modules/assistant/AssistantPage.qml`: Cloud section, cloud rows with Prefer, Model text.
+- Modify `desktop/assistant/Assistant.qml`: `chatModel`, `preferred`, `wanted`, `cloud`, `models`, `modelsOpen`, `pickModel`, `model` on every ask.
+- Create `desktop/assistant/Models.qml`: the model panel.
+- Modify `desktop/assistant/Card.qml`: header label opens the panel, a Model button outside chat, Ctrl+M, panel slot.
+- Modify `desktop/shell.qml`: `preferred` and `ensureModel` wiring.
+- Modify `desktop/modules/assistant/README.md`, `desktop/assistant/README.md`.
+
+Every new source file carries the repo's GPLv2 header (copy it from `desktop/assistant/History.qml`). No home paths in committed files. No em dashes. Commits GPG-signed (automatic), never `--no-verify`; never stage HANDOFF.md; trailer `Co-Authored-By: <the model you are> <noreply@anthropic.com>`.
+
+---
+
+### Task 1: `[[cloud]]` tables in Config.js
+
+**Files:**
+- Modify: `desktop/modules/assistant/Config.js` (append)
+- Test: `desktop/modules/assistant/test_config.js`
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test_config.js`, the line building `C` exports the new functions:
+
+```js
+const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")();
+```
+
+Append at the end of the file:
+
+```js
+// [[cloud]] tables.
+const withCloud = file + [
+ "# Cloud models.",
+ "[[cloud]]",
+ 'name = "hetzner"',
+ 'url = "https://llm.example.org/v1" # a comment',
+ 'key_cmd = "pass llm/x"',
+ 'models = ["a/b", "c:d"]',
+ "",
+ "[[cloud]]",
+ 'name = "two"',
+ 'url = "u"',
+ 'key_cmd = "k"',
+ "models = []",
+ "",
+].join("\n");
+assert.deepStrictEqual(C.clouds(withCloud), [
+ { name: "hetzner", url: "https://llm.example.org/v1", key_cmd: "pass llm/x", models: ["a/b", "c:d"] },
+ { name: "two", url: "u", key_cmd: "k", models: [] }]);
+assert.deepStrictEqual(C.clouds(file), []);
+assert.deepStrictEqual(C.env(withCloud), C.env(file)); // [env] ends at the table
+// A table this cannot read in full is not offered for editing.
+assert.strictEqual(C.clouds(withCloud.replace('models = ["a/b", "c:d"]', 'models = [\n "a/b",\n]')), null);
+assert.strictEqual(C.clouds(withCloud.replace('name = "two"', 'name = "two"\nextra = 1')), null);
+// Rewritten at the end of the file; everything else stays.
+const one = C.setClouds(withCloud, [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]);
+assert.deepStrictEqual(C.clouds(one), [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]);
+assert.deepStrictEqual(C.env(one), C.env(file));
+assert.deepStrictEqual(C.scalars(one, KEYS), C.scalars(file, KEYS));
+assert.ok(one.includes("# Cloud models.") && one.endsWith('models = ["m"]\n'), one);
+assert.deepStrictEqual(C.clouds(C.setClouds(one, [])), []);
+// What the daemon refuses to start with.
+const ok = { name: "a", url: "u", key_cmd: "k", models: [] };
+assert.strictEqual(C.cloudError([ok]), "");
+assert.strictEqual(C.cloudError([]), "");
+for (const bad of [{ name: "" }, { name: "a:b" }, { url: "" }, { key_cmd: " " }])
+ assert.notStrictEqual(C.cloudError([Object.assign({}, ok, bad)]), "", JSON.stringify(bad));
+assert.notStrictEqual(C.cloudError([ok, ok]), "");
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+`node desktop/modules/assistant/test_config.js`. Expected: `ReferenceError: clouds is not defined`.
+
+- [ ] **Step 3: Implement**
+
+Append to `Config.js`:
+
+```js
+// [[cloud]] tables, the daemon's providers: name, url and key_cmd strings
+// and a one-line models array, as setClouds writes them.
+const CLOUD = /^\s*\[\[cloud\]\]\s*(#.*)?$/;
+
+// A one-line array of strings, ["a", "b"]; null for anything else.
+function strings(rest) {
+ let r = rest.trim();
+ if (!r.startsWith("[")) return null;
+ const out = [];
+ r = r.slice(1).trim();
+ while (!r.startsWith("]")) {
+ if (!r.startsWith('"')) return null;
+ const v = value(r);
+ out.push(v.v);
+ r = r.slice(v.end).trim();
+ if (r.startsWith(",")) r = r.slice(1).trim();
+ else if (!r.startsWith("]")) return null;
+ }
+ const tail = r.slice(1).trim();
+ return tail === "" || tail.startsWith("#") ? out : null;
+}
+
+// [{start, end}] of each [[cloud]] table, its header to the next header.
+function cloudBlocks(lines) {
+ const out = [];
+ lines.forEach((l, i) => {
+ if (!CLOUD.test(l)) return;
+ const e = lines.findIndex((x, j) => j > i && HEADER.test(x));
+ out.push({ start: i, end: e < 0 ? lines.length : e });
+ });
+ return out;
+}
+
+// [{name, url, key_cmd, models}] in file order, or null when a table holds
+// a line this cannot read (a multi-line array, another key): the page then
+// leaves them to be edited by hand rather than rewrite what it did not read.
+function clouds(text) {
+ const lines = text.split("\n");
+ const out = [];
+ for (const b of cloudBlocks(lines)) {
+ const c = { name: "", url: "", key_cmd: "", models: [] };
+ for (let i = b.start + 1; i < b.end; i++) {
+ if (/^\s*(#.*)?$/.test(lines[i])) continue;
+ const m = KEY.exec(lines[i]);
+ if (!m) return null;
+ if (m[3] === "models") {
+ const a = strings(m[4]);
+ if (a === null) return null;
+ c.models = a;
+ } else if (["name", "url", "key_cmd"].includes(m[3]) && m[4].startsWith('"')) {
+ c[m[3]] = value(m[4]).v;
+ } else return null;
+ }
+ out.push(c);
+ }
+ return out;
+}
+
+// Replaces every [[cloud]] table with list's, at the end of the file. The
+// lines inside the old tables go with them; comments above them stay.
+function setClouds(text, list) {
+ const lines = text.split("\n");
+ for (const b of cloudBlocks(lines).reverse()) lines.splice(b.start, b.end - b.start);
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
+ for (const c of list)
+ lines.push("", "[[cloud]]", "name = " + quote(c.name), "url = " + quote(c.url),
+ "key_cmd = " + quote(c.key_cmd),
+ "models = [" + c.models.map(quote).join(", ") + "]");
+ return lines.join("\n") + "\n";
+}
+
+// Why list cannot be saved, "" when it can: the daemon refuses to start on
+// any of these (assistant.py, check_cloud).
+function cloudError(list) {
+ for (const c of list) {
+ if (!c.name.trim() || !c.url.trim() || !c.key_cmd.trim())
+ return "A cloud provider needs a name, a URL and a key command.";
+ if (c.name.includes(":")) return `Cloud name ${c.name} has a colon.`;
+ }
+ const names = list.map(c => c.name);
+ const dup = names.find((n, i) => names.indexOf(n) !== i);
+ return dup !== undefined ? `Cloud name ${dup} is used twice.` : "";
+}
+```
+
+Also extend the file's header comment (the paragraph starting `// The file is edited line by line`): after `and \`KEY = "value"\` lines in [env].` add the sentence `[[cloud]] tables are read when they hold only what setClouds writes, and rewritten whole.`
+
+- [ ] **Step 4: Run, expect success**
+
+`node desktop/modules/assistant/test_config.js`: no output, exit 0 (`echo $?` prints 0).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/assistant/Config.js desktop/modules/assistant/test_config.js
+git commit -m "assistant settings: read and write the daemon's [[cloud]] tables"
+```
+
+(with the trailer, see File map)
+
+---
+
+### Task 2: AssistantModule, cloud state and fetch
+
+**Files:**
+- Modify: `desktop/modules/assistant/AssistantModule.qml`
+
+No node test: QML. Verified in Task 6.
+
+- [ ] **Step 1: Keywords**
+
+`keywords:` gains `"cloud", "provider"`.
+
+- [ ] **Step 2: Cloud state**
+
+After the `function envValue(key) ...` line add:
+
+```qml
+ // The [[cloud]] tables in the draft, null when one cannot be read (the
+ // page then leaves them alone), and the saved ones, which the canvas and
+ // Prefer offer. cloudCount follows the draft's count only, so rows are
+ // not rebuilt on each keystroke (as envKeys).
+ readonly property var clouds: C.clouds(am.draft)
+ readonly property var savedClouds: C.clouds(am.text) ?? []
+ property int cloudCount: 0
+ readonly property string cloudError: am.clouds ? C.cloudError(am.clouds) : ""
+ // name -> the provider's ids, or why they could not be fetched.
+ property var fetched: ({})
+ property string fetching: ""
+```
+
+In the existing `onDraftChanged` handler, add after its two lines:
+
+```qml
+ const n = (C.clouds(am.draft) ?? []).length;
+ if (n !== am.cloudCount) am.cloudCount = n;
+```
+
+`valid` becomes:
+
+```qml
+ readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === ""
+```
+
+- [ ] **Step 3: Edits**
+
+After `function quote(s) { return C.quote(s); }` add:
+
+```qml
+ // patch is {name}, {url}, {key_cmd} or {models}; i === cloudCount adds one.
+ function editCloud(i, patch) {
+ const list = (am.clouds ?? []).map(c => Object.assign({}, c));
+ if (i === list.length) list.push({ name: "", url: "", key_cmd: "", models: [] });
+ Object.assign(list[i], patch);
+ am.draft = C.setClouds(am.draft, list);
+ }
+ function removeCloud(i) { am.draft = C.setClouds(am.draft, am.clouds.filter((c, j) => j !== i)); }
+
+ // The daemon reads the saved config, so Fetch saves the draft first.
+ // The key command runs in that process; the key never reaches here.
+ function fetchModels(name) {
+ am.save(() => {
+ am.fetching = name;
+ am.run([am.python, am.daemonDir + "/assistant.py", "--cloud-models", name], null,
+ (code, out, err) => {
+ am.fetching = "";
+ let got;
+ try { got = code === 0 ? JSON.parse(out) : (err.trim().split("\n").pop() || `exit ${code}`); }
+ catch (e) { got = "the assistant sent an unreadable list"; }
+ am.fetched = Object.assign({}, am.fetched, { [name]: got });
+ });
+ });
+ }
+```
+
+- [ ] **Step 4: `save(done)`**
+
+`save()` becomes `save(done)`, calling `done` once both writes are through; `pending` is set only when something was written:
+
+```qml
+ function save(done) {
+ if (am.text !== am.base) {
+ am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them.";
+ return;
+ }
+ am.error = "";
+ const t = am.draft;
+ const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0;
+ const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => {
+ if (changed) am.pending = true;
+ am.listTools();
+ if (done) done();
+ });
+ if (t === am.text) { tools(); return; }
+ am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => {
+ if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; }
+ am.text = t;
+ am.base = t;
+ tools();
+ });
+ }
+```
+
+The SaveBar's `onSave: am.save()` stays as it is.
+
+- [ ] **Step 5: `ensureModel` replaces `ensurePreferred`**
+
+Replace the whole `ensurePreferred` function and its comment with:
+
+```qml
+ // Called when the canvas opens and when its chat's model changes, with
+ // the chat's model, else the preferred one: loaded if it is a router
+ // model neither loaded nor loading. A cloud id is not in the router's
+ // list, so nothing is swapped for it. One model and one client at a time
+ // is how this desktop runs, so this is the moment to bring the model
+ // back. The list is read every time, so hears is current.
+ function ensureModel(id) {
+ if (am.loading !== "") return;
+ am.listModels(() => {
+ const m = am.models.find(x => x.id === id);
+ if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
+ });
+ }
+```
+
+Then `grep -rn ensurePreferred desktop/` must list only `desktop/shell.qml` (fixed in Task 5).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/assistant/AssistantModule.qml
+git commit -m "assistant settings: cloud providers, fetch their models, load the chat's model"
+```
+
+---
+
+### Task 3: AssistantPage, Cloud section and cloud Prefer
+
+**Files:**
+- Modify: `desktop/modules/assistant/AssistantPage.qml`
+
+- [ ] **Step 1: Model text**
+
+The first `Small` under `Heading { text: "Model" }` gets:
+
+```qml
+ text: "Each chat uses the model picked in the canvas header, else the preferred one. "
+ + "Opening the canvas loads that model if it is a local one and another is in. "
+ + "Load swaps a model in now, for every client, without changing the preference."
+```
+
+- [ ] **Step 2: Cloud models in the Model list**
+
+Right after the router models' `Repeater { model: page.asst.models ... }` (before the `modelError` Small), add:
+
+```qml
+ // The saved cloud models: Prefer only, there is nothing to load.
+ Repeater {
+ model: page.asst.savedClouds.flatMap(c => c.models.map(m => c.name + ":" + m))
+
+ delegate: Rectangle {
+ id: cloudRow
+ required property string modelData
+ readonly property bool preferred: Prefs.assistantModel === modelData
+ objectName: "model:" + modelData
+ width: page.width
+ height: 40
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.35)
+
+ // nf-fa-cloud, present in Inconsolata Nerd Font.
+ Text {
+ id: cloudIcon
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ id: cloudName
+ anchors { left: cloudIcon.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ text: cloudRow.modelData
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Small {
+ anchors { left: cloudName.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ visible: cloudRow.preferred
+ text: "preferred"
+ color: Theme.accent
+ }
+ Button {
+ anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ text: cloudRow.preferred ? "Preferred" : "Prefer"
+ enabled: !cloudRow.preferred
+ onClicked: Prefs.setAssistantModel(cloudRow.modelData)
+ }
+ }
+ }
+```
+
+- [ ] **Step 3: Cloud section**
+
+Insert right before the `Heading { text: "Tool environment" }` line:
+
+```qml
+ // --- cloud ---
+
+ Heading { text: "Cloud" }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Providers with an OpenAI-compatible API, offered in the canvas's model menu. A cloud "
+ + "chat is sent its history, attachments and the results of the tools marked "
+ + "cloud = true; voice is still transcribed here. The key command prints the API key: "
+ + "the assistant runs it at startup and on Fetch, which saves this page first."
+ }
+
+ Small {
+ width: page.width
+ visible: page.asst.clouds === null
+ wrapMode: Text.Wrap
+ text: "config.toml has a [[cloud]] table this page cannot read; edit it by hand."
+ color: Theme.red
+ }
+
+ Repeater {
+ model: page.asst.clouds === null ? 0 : page.asst.cloudCount
+
+ delegate: Rectangle {
+ id: prov
+ required property int index
+ readonly property var c: page.asst.clouds?.[index] ?? { name: "", url: "", key_cmd: "", models: [] }
+ readonly property var got: page.asst.fetched[prov.c.name]
+ objectName: "cloud:" + index
+ width: page.width
+ height: provCol.implicitHeight + 20
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.35)
+
+ Column {
+ id: provCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 6
+
+ Labelled {
+ width: provCol.width
+ label: "Name"
+ Field {
+ anchors.fill: parent
+ value: prov.c.name
+ placeholder: "openrouter"
+ onEdited: t => page.asst.editCloud(prov.index, { name: t.trim() })
+ }
+ }
+ Labelled {
+ width: provCol.width
+ label: "URL"
+ Field {
+ anchors.fill: parent
+ value: prov.c.url
+ placeholder: "https://openrouter.ai/api/v1"
+ onEdited: t => page.asst.editCloud(prov.index, { url: t.trim() })
+ }
+ }
+ Labelled {
+ width: provCol.width
+ label: "Key command"
+ Field {
+ anchors.fill: parent
+ value: prov.c.key_cmd
+ placeholder: "pass show llm/openrouter"
+ onEdited: t => page.asst.editCloud(prov.index, { key_cmd: t.trim() })
+ }
+ }
+ Row {
+ spacing: 6
+ Button {
+ text: page.asst.fetching === prov.c.name ? "Fetching…" : "Fetch models"
+ enabled: page.asst.fetching === "" && page.asst.valid && prov.c.name !== ""
+ onClicked: page.asst.fetchModels(prov.c.name)
+ }
+ Button {
+ text: "Remove"
+ danger: true
+ onClicked: page.asst.removeCloud(prov.index)
+ }
+ }
+ Small {
+ width: provCol.width
+ visible: typeof prov.got === "string"
+ wrapMode: Text.Wrap
+ text: typeof prov.got === "string" ? prov.got : ""
+ color: Theme.red
+ }
+ // The fetched ids and any kept from before, each switched
+ // into models or out of it.
+ Repeater {
+ model: Array.isArray(prov.got)
+ ? [...new Set(prov.got.concat(prov.c.models))].sort() : prov.c.models
+
+ delegate: Item {
+ id: idRow
+ required property string modelData
+ width: provCol.width
+ height: 28
+ Switch {
+ id: idOn
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ checked: prov.c.models.includes(idRow.modelData)
+ onToggled: on => page.asst.editCloud(prov.index, { models: on
+ ? prov.c.models.concat([idRow.modelData])
+ : prov.c.models.filter(m => m !== idRow.modelData) })
+ }
+ Text {
+ anchors { left: idOn.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: idRow.modelData
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ visible: page.asst.cloudError !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.cloudError
+ color: Theme.red
+ }
+
+ Button {
+ text: "Add provider"
+ enabled: page.asst.clouds !== null
+ onClicked: page.asst.editCloud(page.asst.cloudCount, {})
+ }
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/assistant/AssistantPage.qml
+git commit -m "assistant settings: a Cloud section, and Prefer for cloud models"
+```
+
+---
+
+### Task 4: Canvas, the chat's model
+
+**Files:**
+- Modify: `desktop/assistant/Assistant.qml`
+- Create: `desktop/assistant/Models.qml`
+- Modify: `desktop/assistant/Card.qml`
+
+- [ ] **Step 1: State in Assistant.qml**
+
+After `property string swapping: ""` add:
+
+```qml
+ // The model this chat is asked with: its stored pick (hello.chat_model,
+ // conversation.model), "" for none, then the preferred one (set from
+ // shell.qml). "" for both: the daemon uses whatever the router has
+ // loaded. Spec: desktop-assistant,
+ // docs/superpowers/specs/2026-10-23-cloud-models-design.md.
+ property string chatModel: ""
+ property string preferred: ""
+ readonly property string wanted: root.chatModel || root.preferred
+ // The daemon's models event: [{id, status, cloud: false} | {id, provider, cloud: true}].
+ property var models: []
+ readonly property bool cloud: root.models.some(m => m.cloud && m.id === root.wanted)
+ property bool modelsOpen: false
+```
+
+- [ ] **Step 2: Functions**
+
+`close()` resets the panel too:
+
+```qml
+ root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false;
+```
+
+`back()`, after the recording branch:
+
+```qml
+ else if (root.modelsOpen) root.modelsOpen = false;
+```
+
+`showHistory()` gains `root.modelsOpen = false;` next to `root.pickerOpen = false;`.
+
+After `showHistory()` add:
+
+```qml
+ function showModels() {
+ root.historyOpen = root.pickerOpen = false;
+ root.modelsOpen = true;
+ root.send({ type: "models" });
+ }
+ // Applies from the next ask; the daemon stores it with the chat then.
+ // Moving a chat with turns to the cloud sends its history there, so
+ // that is said once, on the switch.
+ function pickModel(id) {
+ const m = root.models.find(x => x.id === id);
+ if (m && m.cloud && !root.cloud && turns.count)
+ root.notice = `History in this chat goes to ${m.provider}.`;
+ root.chatModel = id;
+ root.modelsOpen = false;
+ }
+ function withModel(cmd) {
+ if (root.wanted) cmd.model = root.wanted;
+ return cmd;
+ }
+```
+
+Both asks carry the model. In `ask(text)`:
+
+```qml
+ root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) }));
+```
+
+In the recorder's `onExited`:
+
+```qml
+ root.send(root.withModel({ type: "ask", text: "", audio: root.micPath,
+ files: root.staged.map(f => f.path) }));
+```
+
+The `write` send (To .md) stays without a model: the daemon always writes with the local one.
+
+- [ ] **Step 3: Events**
+
+In `handle()`:
+- `case "hello":` after `root.model = e.model ?? "";` add `root.chatModel = e.chat_model ?? "";` and, at its end before `break`, `root.send({ type: "models" });` (so the cloud marker is right before the panel was ever opened).
+- `case "conversation":` after `root.load(e.id, e.turns, null);` add `root.chatModel = e.model ?? "";`
+- New case, next to `"results"`: `case "models": root.models = e.items; break;`
+
+- [ ] **Step 4: `Models.qml`**
+
+Create `desktop/assistant/Models.qml` (GPLv2 header first, copied from `History.qml`):
+
+```qml
+import QtQuick
+import ".."
+
+// The model panel, over the transcript: the router's models, then the
+// cloud ones, in the daemon's order. A pick applies to this chat from its
+// next question.
+Rectangle {
+ id: panel
+ property var canvas
+
+ color: Qt.alpha(Theme.base, 0.97)
+ radius: 8
+
+ MouseArea { anchors.fill: parent } // keep clicks off the transcript below
+
+ ListView {
+ id: rows
+ anchors.fill: parent
+ clip: true
+ spacing: 2
+ model: panel.canvas.models
+
+ delegate: Rectangle {
+ id: r
+ required property var modelData
+ readonly property bool picked: r.modelData.id === panel.canvas.wanted
+ width: rows.width
+ height: 34
+ radius: 6
+ color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent"
+ border.width: r.picked ? 1 : 0
+ border.color: Qt.alpha(Theme.accent, 0.5)
+
+ HoverHandler { id: hover }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.canvas.pickModel(r.modelData.id)
+ }
+ // nf-fa-cloud, present in Inconsolata Nerd Font.
+ Text {
+ id: icon
+ anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ width: 16
+ text: r.modelData.cloud ? "" : ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ anchors { left: icon.right; leftMargin: 6; right: stat.left; rightMargin: 8
+ verticalCenter: parent.verticalCenter }
+ // A cloud id without its provider: the right column says it.
+ text: r.modelData.cloud ? r.modelData.id.slice(r.modelData.provider.length + 1)
+ : r.modelData.id
+ elide: Text.ElideRight
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: r.picked }
+ }
+ Text {
+ id: stat
+ anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: r.modelData.cloud ? r.modelData.provider : r.modelData.status
+ color: r.modelData.status === "loaded" ? Theme.green : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: rows.count === 0
+ text: "No models: is llama-server running?"
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Card.qml**
+
+`panel` includes the new one:
+
+```qml
+ readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen
+```
+
+Ctrl+M, in `Keys.onPressed`, after the `Qt.Key_H` branch:
+
+```qml
+ } else if (event.key === Qt.Key_M && card.canvas.connected) {
+ card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels();
+```
+
+and the comment above it reads `// Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do.`
+
+Header: replace the single header `Text { ... }` (the one showing "assistant not running" / "loading …" / the model) with:
+
+```qml
+ // The chat's model, a click opens the model panel. nf-fa-cloud for a
+ // cloud one, nf-fa-angle_down after it.
+ Row {
+ id: modelRow
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.connected && !card.canvas.swapping && card.canvas.cloud
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: !card.canvas.connected ? "assistant not running"
+ : card.canvas.swapping ? `loading ${card.canvas.swapping}…`
+ : card.canvas.wanted || card.canvas.model || "assistant"
+ color: card.canvas.connected ? Theme.text : Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.connected
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ MouseArea {
+ anchors.fill: modelRow
+ enabled: card.canvas.connected
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels()
+ }
+```
+
+Outside chat, a Model button left of History. Add before `Button { id: history ...`:
+
+```qml
+ // The model menu outside chat, where there is no header; nf-fa-cloud
+ // when the next question goes to a cloud model.
+ Button {
+ id: modelBtn
+ visible: card.mode !== "chat"
+ width: visible ? implicitWidth : 0
+ anchors { bottom: parent.bottom; right: history.left; rightMargin: visible ? 8 : 0 }
+ height: box.height
+ icon: card.canvas.cloud ? "" : ""
+ text: "Model"
+ enabled: card.canvas.connected
+ onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels()
+ }
+```
+
+and the input `box`'s anchors change `right: history.left` to `right: modelBtn.left`.
+
+In `panelArea`, after `Picker { ... }`:
+
+```qml
+ Models {
+ anchors.fill: parent
+ visible: card.canvas.modelsOpen
+ canvas: card.canvas
+ }
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/assistant/Assistant.qml desktop/assistant/Models.qml desktop/assistant/Card.qml
+git commit -m "assistant: pick the chat's model, local or cloud, from the header"
+```
+
+---
+
+### Task 5: Wiring and docs
+
+**Files:**
+- Modify: `desktop/shell.qml`, `desktop/modules/assistant/README.md`, `desktop/assistant/README.md`
+
+- [ ] **Step 1: shell.qml**
+
+```qml
+ Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears
+ preferred: Prefs.assistantModel }
+```
+
+and the Connections block becomes:
+
+```qml
+ // Opening the assistant, or picking another model for its chat, loads
+ // that model if it is a local one and another is in VRAM. See
+ // modules/assistant/README.md.
+ Connections {
+ target: assistant
+ function onOpenChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); }
+ function onWantedChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); }
+ }
+```
+
+`grep -rn ensurePreferred desktop/` prints nothing.
+
+- [ ] **Step 2: `desktop/modules/assistant/README.md`**
+
+Replace the paragraph starting `Opening the assistant canvas (SUPER+Home) loads the preferred model` with:
+
+```
+Each chat uses the model picked in the canvas's model menu, else the
+preferred one, else whatever is loaded. Opening the canvas, or picking a
+local model for its chat, loads that model when another one is resident,
+and the canvas header reads "loading <model>…" until it is in. This
+desktop runs one model and one client at a time, so that is when the
+model should come back. A cloud model loads nothing.
+```
+
+After the paragraph ending `...which the daemon's \`*.toml\` glob skips.`, add:
+
+```
+**Cloud** edits the daemon's `[[cloud]]` tables: name, URL and key
+command per provider. Fetch saves the page, then runs `assistant.py
+--cloud-models <name>`, which runs the key command and lists the
+provider's ids; the switches choose which the canvas offers. The key
+never reaches this page. A table holding more than the page writes (a
+multi-line array, another key) is left alone, with a note to edit it by
+hand. The saved cloud models also get Prefer in the Model list.
+```
+
+- [ ] **Step 3: `desktop/assistant/README.md`**
+
+Add a `Models.qml` row to the file table:
+
+```
+| `Models.qml` | the model panel: local and cloud models, the chat's pick |
+```
+
+After the mic paragraph, add:
+
+```
+The header's model name (the Model button outside chat, or Ctrl+M) opens
+the model panel: the router's models, then each cloud provider's. A pick
+applies to this chat from its next question and is stored with it by the
+daemon; a new chat starts on the preferred model. Moving a chat with turns
+to a cloud model says once that its history goes to that provider. Voice is
+transcribed by the local model in a cloud chat too.
+```
+
+and in the Keys line, `Ctrl+H toggles the history panel.` becomes `Ctrl+H toggles the history panel, Ctrl+M the model panel.`
+
+- [ ] **Step 4: Check and commit**
+
+`node desktop/modules/assistant/test_config.js` exits 0. Then:
+
+```bash
+git add desktop/shell.qml desktop/modules/assistant/README.md desktop/assistant/README.md
+git commit -m "assistant: load the chat's model on open, docs for cloud models"
+```
+
+---
+
+### Task 6: Live check (the user)
+
+The agent cannot run `qs` itself (AGENTS.md, "A detached qs does not survive"). Ask the user to run `./reload.sh desktop` and check, then fix what they report:
+
+- [ ] Settings, Assistant page: the hetzner card shows name, URL, key command, and its two models switched on. Fetch lists the four ids (pass may ask for the PIN). Switching one off and Save rewrites the `[[cloud]]` table at the end of `config.toml`; Restart applies it. Add provider with an empty name holds Save back with the cloud error.
+- [ ] Model list: the cloud models have Prefer; preferring one marks it.
+- [ ] Canvas, entry state: Model button opens the panel; local models with their state, then the hetzner ones with the cloud icon; the preferred model is outlined.
+- [ ] Pick `Qwen3.8-27B`, ask about the weather: the header shows the cloud icon and the id; the answer uses `weather`.
+- [ ] Pick Gemma in that chat: the notice does not show; next ask answers locally (ctx meter shows `/ 32768` again). Pick the cloud one back: "History in this chat goes to hetzner." shows.
+- [ ] History: reopening the chat shows its stored model in the header.
+- [ ] The header's model name opens the panel on click, and Ctrl+M toggles it.
diff --git a/docs/superpowers/plans/2026-10-23-companion-blush.md b/docs/superpowers/plans/2026-10-23-companion-blush.md
new file mode 100644
index 0000000..4ae1842
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-companion-blush.md
@@ -0,0 +1,655 @@
+# Companion Blush 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:** The companion turns to a listed wallpaper (`watch` frame, 1.5 s) and blushes (`blush3`, 3 s) when the workspace in front of her is empty, at most once every 3 minutes.
+
+**Architecture:** `Mood.js` gains an `admire` state stamped by `admireAt`. A new singleton `desktop/companion/Admired.qml` owns the plain-text wallpaper list (pure helpers in `ListFile.js`) and what wallp has on each monitor. `Companion.qml` checks the trigger on workspace events and whenever she stops being away. The Companion settings page takes the list path (`Prefs.companionBlushFile`), and the wallpaper picker draws a heart toggle on each thumbnail.
+
+**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested under node, Hyprland raw events.
+
+Spec: `docs/superpowers/specs/2026-10-23-companion-blush-design.md`.
+
+Read AGENTS.md "Verifying, and how not to" and "Reloading" before running anything: never start `qs` from a tool call, `pkill -x qs` only, and a new component file needs `./reload.sh desktop` from the user's terminal.
+
+---
+
+## File map
+
+| File | Change |
+| --- | --- |
+| `desktop/companion/ListFile.js` | create: `parse`, `toggle`, `outputs`, pure |
+| `desktop/companion/Mood.js` | `T.admireLook`, `T.admireBlush`, `T.admireCooldown`, the `admire` row, `admireAllowed` |
+| `desktop/companion/test_mood.js` | tests for both |
+| `desktop/Prefs.qml` | `companionBlushFile` + setter |
+| `desktop/companion/Admired.qml` | create: the singleton |
+| `desktop/companion/Companion.qml` | trigger, `admireAt` in the snapshot, the line |
+| `desktop/companion/Lines.js` | `wallpaper` lines |
+| `desktop/modules/companion/CompanionModule.qml` | the path field |
+| `desktop/modules/appearance/WallpaperTab.qml` | the heart |
+| `desktop/companion/README.md`, spec | docs |
+
+---
+
+### Task 1: ListFile.js
+
+**Files:**
+- Create: `desktop/companion/ListFile.js`
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+In `test_mood.js`, after the `const M = ...` block, add:
+
+```js
+const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")();
+```
+
+Before `console.log("ok");`, add:
+
+```js
+// The wallpaper list: paths, blank lines and # comments skipped.
+assert.deepStrictEqual(L.parse(""), []);
+assert.deepStrictEqual(L.parse("# mine\n/a.png\n\n /b.png \n#/c.png\n"), ["/a.png", "/b.png"]);
+// Toggling adds a missing path and removes a listed one, leaving
+// comments and every other line as they were.
+assert.strictEqual(L.toggle("", "/a.png"), "/a.png\n");
+assert.strictEqual(L.toggle("# mine\n/b.png", "/a.png"), "# mine\n/b.png\n/a.png\n");
+assert.strictEqual(L.toggle("# mine\n/a.png\n/b.png\n", "/a.png"), "# mine\n/b.png\n");
+assert.strictEqual(L.toggle("/a.png\n", "/a.png"), "");
+// A commented-out path is not listed, so toggling it adds a live line.
+assert.strictEqual(L.toggle("#/a.png\n", "/a.png"), "#/a.png\n/a.png\n");
+// wallp.conf's outputs, by connector.
+assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~/h.png\n"),
+ { "DP-1": "H", "DP-3": "V" });
+assert.deepStrictEqual(L.outputs(""), {});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: FAIL, `ENOENT: no such file or directory` for `ListFile.js`.
+
+- [ ] **Step 3: Write ListFile.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
+
+// The wallpapers the companion blushes at, as a plain text file: one
+// absolute path per line, blank lines and lines starting with # skipped.
+// Pure, so test_mood.js drives it; Admired.qml reads and writes the file.
+
+function parse(text) {
+ const out = [];
+ for (const l of text.split("\n")) {
+ const p = l.trim();
+ if (p !== "" && p[0] !== "#") out.push(p);
+ }
+ return out;
+}
+
+// text with path's line removed if it is listed, else appended. Comments
+// and every other line stay as they are.
+function toggle(text, path) {
+ const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n");
+ const kept = lines.filter(l => l.trim() !== path);
+ if (kept.length === lines.length) kept.push(path);
+ return kept.length ? kept.join("\n") + "\n" : "";
+}
+
+// wallp.conf's OUTPUT_H and OUTPUT_V, as { connector: "H" | "V" }. An exec
+// loop, because QML's engine has no String.matchAll.
+function outputs(conf) {
+ const out = {};
+ const re = /^\s*OUTPUT_([HV])\s*=\s*(\S+)/gm;
+ let m;
+ while ((m = re.exec(conf)) !== null) out[m[2]] = m[1];
+ return out;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/ListFile.js desktop/companion/test_mood.js
+git commit -m "companion: ListFile.js, the wallpaper list as plain text
+
+Toggling keeps comments and every other line, so a hand-edited list
+survives the picker's heart. wallp.conf's outputs map a monitor to the
+H or V wallpaper.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: the admire state in Mood.js
+
+**Files:**
+- Modify: `desktop/companion/Mood.js` (`T`, `pick`, a new function)
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+In `test_mood.js`:
+
+Add `admireAllowed` to the `M` export list:
+```js
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")();
+```
+
+Add `admireAt: 0,` to `calm`, after `wakeAt: 0,`.
+
+In `ladder`, between the `clicks` and `mock` rows:
+```js
+ ["admire", { admireAt: NOW - 10 }],
+```
+
+Before the `ListFile` tests, add:
+```js
+// Admiring a wallpaper: turned to it, then blushing, then done.
+assert.deepStrictEqual(M.pick(calm({ admireAt: NOW - 10 })), { state: "admire", frame: "watch" });
+assert.strictEqual(frame({ admireAt: NOW - M.T.admireLook }), "blush3");
+assert.strictEqual(state({ admireAt: NOW - M.T.admireLook - M.T.admireBlush }), "base");
+assert.strictEqual(M.admireAllowed(0, NOW), true);
+assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown + 1, NOW), false);
+assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown, NOW), true);
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: FAIL, `ReferenceError: admireAllowed is not defined`.
+
+- [ ] **Step 3: Implement**
+
+In `Mood.js` `T`, after `scorched: 3000,`:
+```js
+ admireLook: 1500, // turned to the wallpaper, before the blush
+ admireBlush: 3000,
+ admireCooldown: 180000,
+```
+
+In `pick`, right after the `clicks` return:
+```js
+ if (s.now - s.admireAt < T.admireLook + T.admireBlush)
+ return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3");
+```
+
+After `mockAllowed`:
+```js
+function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/Mood.js desktop/companion/test_mood.js
+git commit -m "companion: admire, turned to a wallpaper and then blushing
+
+The watch back view for 1.5 s, then blush3 for 3 s, at most every 3
+minutes. Below clicks, so a click during it wins; above the mock and
+typing.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Prefs and the Admired singleton
+
+**Files:**
+- Modify: `desktop/Prefs.qml`
+- Create: `desktop/companion/Admired.qml`
+
+- [ ] **Step 1: Prefs.companionBlushFile**
+
+In `Prefs.qml`, after `setCompanion`:
+```qml
+ // The companion's wallpaper list, "" for the default in
+ // companion/Admired.qml.
+ property string companionBlushFile: ""
+ function setCompanionBlushFile(p) {
+ p = p.trim();
+ if (p === root.companionBlushFile) return;
+ root.companionBlushFile = p;
+ root.write(Object.assign({}, root.doc, { companionBlushFile: p }));
+ }
+```
+
+In `reparse()`, after `root.companion = ...`:
+```qml
+ root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : "";
+```
+
+In `onLoadFailed`, add `root.companionBlushFile = "";` before `root.ready = true;`.
+
+- [ ] **Step 2: Admired.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.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import ".."
+import "ListFile.js" as LF
+
+// The wallpapers the companion blushes at, and what wallp has on each
+// monitor. The companion reads both; the wallpaper picker toggles entries.
+// What is on each monitor is read from wallp's own files here rather than
+// through the appearance module's Wallpapers, which scans every wallpaper
+// on creation, and the companion runs all session.
+Singleton {
+ id: root
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string defaultPath:
+ (Quickshell.env("XDG_CONFIG_HOME") || root.home + "/.config") + "/keystone/companion-blush.txt"
+ readonly property string path:
+ Prefs.companionBlushFile !== "" ? Prefs.companionBlushFile.replace(/^~(?=\/|$)/, root.home) : root.defaultPath
+
+ property string listText: ""
+ readonly property var paths: LF.parse(root.listText)
+ function listed(p) { return p !== "" && root.paths.indexOf(p) >= 0; }
+
+ // The wallpaper on a monitor, by connector name; "" for one wallp does
+ // not set.
+ property var outputs: ({})
+ property string wallH: ""
+ property string wallV: ""
+ function on(monitor) {
+ const w = root.outputs[monitor];
+ return w === "H" ? root.wallH : w === "V" ? root.wallV : "";
+ }
+
+ // ponytail: a list file created by hand after start may not be watched
+ // until a restart (the watch is on a path that did not exist); the
+ // heart writes through this process, which sets listText itself.
+ FileView {
+ path: root.path
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.listText = text()
+ onLoadFailed: root.listText = ""
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wallp.conf"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.outputs = LF.outputs(text())
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wall_h"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.wallH = text().trim()
+ }
+ FileView {
+ path: root.home + "/.config/wallp/wall_v"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.wallV = text().trim()
+ }
+
+ // Through cat rather than FileView.setText, because the list can become
+ // empty and setText("") on a path the view has not loaded writes no
+ // file (AGENTS.md).
+ function toggle(p) {
+ writer.data = LF.toggle(root.listText, p);
+ root.listText = writer.data;
+ writer.command = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", root.path];
+ writer.stdinEnabled = true;
+ writer.running = false;
+ writer.running = true;
+ }
+ Process {
+ id: writer
+ property string data: ""
+ onStarted: {
+ writer.write(writer.data);
+ writer.stdinEnabled = false;
+ }
+ onExited: code => { if (code !== 0) console.warn("Admired: writing", root.path, "failed"); }
+ }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/Prefs.qml desktop/companion/Admired.qml
+git commit -m "companion: Admired, the wallpaper list and what each monitor shows
+
+The list is a plain text file whose path is a preference, the default
+under ~/.config/keystone. Written through cat because an emptied list
+is an empty file, which FileView.setText does not write.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: the trigger in Companion.qml and her lines
+
+**Files:**
+- Modify: `desktop/companion/Companion.qml`
+- Modify: `desktop/companion/Lines.js`
+
+- [ ] **Step 1: Lines**
+
+In `Lines.js`, after `blush:`:
+```js
+ wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
+```
+
+- [ ] **Step 2: Input and snapshot**
+
+In `Companion.qml`, after `property double wakeAt: 0`:
+```qml
+ property double admireAt: 0
+```
+
+In the `Mood.pick({...})` snapshot, change
+`clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,` to
+`clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,`.
+
+- [ ] **Step 3: Events**
+
+Replace the last branch of `onRawEvent`:
+```qml
+ } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ }
+```
+with:
+```qml
+ } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ admireSettle.restart();
+ }
+```
+
+- [ ] **Step 4: The check**
+
+After the `Connections { target: root.asst ... }` block:
+```qml
+ // A listed wallpaper on an empty workspace: she turns to it and blushes.
+ // Checked after the events above (switching to an empty workspace,
+ // closing its last window) and whenever she stops being away: shown,
+ // back from a remap, a hop, a dodge or fullscreen. Toplevels settle a
+ // moment after the event, as fullscreen does.
+ onAwayChanged: if (!root.away) admireSettle.restart()
+ Timer {
+ id: admireSettle
+ interval: 100
+ onTriggered: {
+ const t = Date.now();
+ if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return;
+ if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return;
+ if (!Admired.listed(Admired.on(root.here))) return;
+ root.admireAt = t;
+ root.now = t;
+ admireLine.restart();
+ }
+ }
+ // The line comes with the blush, not the turn.
+ Timer { id: admireLine; interval: Mood.T.admireLook; onTriggered: root.say("wallpaper") }
+```
+
+- [ ] **Step 5: Ask the user to restart, then check the log**
+
+`Admired.qml` is a new file, which hot reload does not index. Ask the user to run `./reload.sh desktop` in their terminal. Then:
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -30`
+Expected: `Configuration Loaded`, no `ReferenceError`, no `TypeError` mentioning `toplevels` or `Admired`.
+
+If `toplevels` is undefined on `HyprlandWorkspace`, replace the window count with
+`Hyprland.toplevels.values.filter(w => w.workspace === Hyprland.focusedWorkspace).length` and re-check.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/companion/Companion.qml desktop/companion/Lines.js
+git commit -m "companion: blushes at a listed wallpaper on an empty workspace
+
+Checked on switching workspace or closing a window, and whenever she
+stops being away, which covers being shown, a remap, a hop to another
+monitor, a dodge and fullscreen. The 3 minute cooldown keeps workspace
+flicking from repeating it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: the path on the Companion settings page
+
+**Files:**
+- Modify: `desktop/modules/companion/CompanionModule.qml`
+
+- [ ] **Step 1: Imports and comment**
+
+Add after `import "../.."`:
+```qml
+import "../appearance"
+import "../../companion"
+```
+Change the comment `// The desktop companion's one setting: whether she is shown. The same` / `// preference SUPER+\ toggles, so the switch follows the key.` to:
+```qml
+// The desktop companion's settings: whether she is shown, the same
+// preference SUPER+\ toggles, so the switch follows the key; and where her
+// wallpaper list is, marked with the heart in the wallpaper picker.
+```
+Add `"wallpaper", "blush"` to `keywords`.
+
+- [ ] **Step 2: Rows**
+
+`Page`'s content holder is a plain `Item`, so wrap the existing `Item { ... }` row in
+```qml
+ Column {
+ width: parent.width
+ spacing: 6
+
+ // (the existing Shown row, unchanged)
+```
+and after that row, inside the `Column`, add:
+```qml
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lbl2
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Wallpaper list"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter }
+ value: Prefs.companionBlushFile
+ placeholder: Admired.defaultPath.replace(Admired.home, "~")
+ onEdited: Prefs.setCompanionBlushFile(value)
+ }
+ }
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+```
+
+- [ ] **Step 3: Check the log**
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15`
+Expected: a `Reloading configuration` line after the edit, no errors. If none followed, ask for `./reload.sh desktop`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/companion/CompanionModule.qml
+git commit -m "companion: the wallpaper list path on her settings page
+
+Empty means the default under ~/.config/keystone, shown as the
+placeholder.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: the heart in the wallpaper picker
+
+**Files:**
+- Modify: `desktop/modules/appearance/WallpaperTab.qml`
+
+- [ ] **Step 1: Glyphs**
+
+Run: `fc-list ':charset=f02d1 f02d5' family | grep -c "Inconsolata Nerd Font"`
+Expected: non-zero (`nf-md-heart` U+F02D1, `nf-md-heart_outline` U+F02D5; checked while planning: Propo and Mono both carry them).
+
+- [ ] **Step 2: Import**
+
+After `import "../.."`:
+```qml
+import "../../companion"
+```
+
+- [ ] **Step 3: The heart**
+
+In the thumbnail `Rectangle`, after its `Column { ... }` (the image and label), add:
+```qml
+ // The companion blushes at the hearted ones
+ // (companion/Admired.qml). After the pick
+ // MouseArea, so it takes the click.
+ Text {
+ readonly property bool on: Admired.listed(modelData)
+ anchors { top: parent.top; right: parent.right; margins: 10 }
+ visible: on || thumbHover.hovered
+ text: on ? "\u{F02D1}" : "\u{F02D5}"
+ color: on ? Theme.red : Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize }
+ MouseArea {
+ anchors { fill: parent; margins: -4 }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Admired.toggle(modelData)
+ }
+ }
+```
+
+- [ ] **Step 4: Check the log**
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15`
+Expected: a reload after the edit and no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/appearance/WallpaperTab.qml
+git commit -m "wallpaper: a heart on each thumbnail, for the companion's list
+
+Shown on hover, and always on a listed one. It toggles the path in the
+list file and leaves the rest of the file as it was.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: docs and the hand check
+
+**Files:**
+- Modify: `desktop/companion/README.md`
+- Modify: `docs/superpowers/specs/2026-10-23-companion-blush-design.md`
+
+- [ ] **Step 1: README**
+
+Add rows to the Files table:
+```
+| `Admired.qml` | the wallpaper list she blushes at, and what wallp has on each monitor |
+| `ListFile.js` | that list as plain text: parse and toggle |
+```
+Under Inputs, add:
+```
+- A listed wallpaper (Settings > Desktop > Companion for the file, the
+ heart in the wallpaper picker for entries) on an empty workspace: she
+ turns to it with `watch` and blushes with `blush3`, at most every 3
+ minutes. Checked on `workspacev2` and `closewindow`, and whenever she
+ stops being away. Her monitor's wallpaper comes from wallp's
+ `wallp.conf` outputs and `wall_h`/`wall_v`.
+```
+
+- [ ] **Step 2: Spec**
+
+In the spec's Trigger section, change `she appears: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, or moved to another monitor with the focus.` to `she stops being away: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, moved to another monitor with the focus, back from a dodge, or out of fullscreen.`
+
+- [ ] **Step 3: Test**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 4: Hand check, by the user**
+
+Ask the user to check, with a wallpaper hearted on DP-1:
+1. switch to an empty workspace on DP-1: back view, then blush, then a line;
+2. again within 3 minutes: nothing;
+3. a workspace with a window: nothing;
+4. an unhearted wallpaper: nothing;
+5. DP-3 with its wallpaper hearted: same as 1;
+6. unheart from the picker, and the list file keeps any comments.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/README.md docs/superpowers/specs/2026-10-23-companion-blush-design.md
+git commit -m "companion: README for the wallpaper blush, spec matches the trigger
+
+The trigger fires whenever she stops being away, which is a dodge and
+fullscreen as well as the three appearances the spec named.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
diff --git a/docs/superpowers/plans/2026-10-23-companion.md b/docs/superpowers/plans/2026-10-23-companion.md
new file mode 100644
index 0000000..b6fb24a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-companion.md
@@ -0,0 +1,1381 @@
+# Desktop Companion 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 chibi 2B in the bottom-right corner of the focused monitor who reacts to notifications, typing, deleting, the assistant, idling and clicks, as specified in `docs/superpowers/specs/2026-10-23-companion-design.md`.
+
+**Architecture:** A component of `desktop/` (`desktop/companion/`), created once in `desktop/shell.qml` and handed the assistant. The state decision is a pure JS library (`Mood.js`) checked with node; `Companion.qml` owns the window, inputs and timers; `Sprite.qml` draws and animates a frame. Typing and BackSpace arrive as Hyprland custom events from the live config in the `hypr-theme` repo. The art lives outside the repo, in `~/.local/share/keystone/companion/2b/`.
+
+**Tech Stack:** Quickshell 0.3.1 (QML, `Quickshell.Hyprland`, `Quickshell.Wayland`), node for the JS checks, bash + ImageMagick 7 for frame preparation, Hyprland 0.56.2 Lua config.
+
+---
+
+## Ground rules for whoever runs this
+
+- Read `AGENTS.md` first. Three of its traps apply directly: the process is `qs`, never `quickshell`; never `pkill -f`; a `qs` started from an agent's tool call dies with the call, so **never start or restart the desktop shell yourself**. The running `desktop` shell hot-reloads on save; check it with `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail`.
+- Hot reload does not follow the `shared/` symlinks. After Task 2 the user restarts with `./reload.sh desktop notifications` from their own terminal (Task 10).
+- A new directory under `desktop/` is only indexed when a file importing it changes; Task 8 changes `desktop/shell.qml`, which does that.
+- Every new source file carries the GPLv2 header used across the repo (copy it from `desktop/assistant/Md.js`, lines 1-10, or the `#` form from `desktop/modules/ai/ai-state.sh`, lines 1-12).
+- No home paths in committed files (a gitleaks hook blocks them): write `~` in docs, `Quickshell.env("HOME")` in QML.
+- No em dashes in any prose, comment or commit message.
+- Commits are GPG-signed (the default here; never disable signing) and end with:
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`
+
+## File map
+
+| File | Status | Responsibility |
+| --- | --- | --- |
+| `~/Programming/GIT/hypr-theme/sections/companion.lua` | create | typing pulse and BackSpace event |
+| `~/Programming/GIT/hypr-theme/hyprland.lua` | modify | require the new section |
+| `~/Programming/GIT/hypr-theme/sections/keybindings.lua` | modify | SUPER+\ toggle |
+| `shared/NotifyConfig.js` | modify | `shown()`: is a balloon on screen |
+| `shared/test_notify_config.js` | modify | checks for `shown()` |
+| `notifications/NotificationBalloon.qml` | modify | use `shown()` |
+| `desktop/companion/Mood.js` | create | which state wins; timings; outfit; cooldowns |
+| `desktop/companion/test_mood.js` | create | node check of `Mood.js` |
+| `desktop/companion/Lines.js` | create | her English lines and the monologue |
+| `desktop/companion/prep-frames` | done | generated images to display frames |
+| `desktop/companion/gen-frames`, `frames.txt` | done | make the frames through sd-server |
+| `desktop/assistant/Assistant.qml` | modify | `signal replied()` |
+| `desktop/companion/Sprite.qml` | create | frame, crossfade, bob, drop, explosion |
+| `desktop/companion/Companion.qml` | create | window, inputs, timers, speech, IPC |
+| `desktop/shell.qml` | modify | create the companion |
+| `desktop/companion/README.md` | create | component notes |
+| `AGENTS.md` | modify | list the component |
+| `docs/superpowers/specs/2026-10-23-companion-design.md` | modify | `lines.js` became `Lines.js` |
+
+---
+
+### Task 1: Hyprland inputs (open checks 1 and 2)
+
+The spec's first two open checks, tested before any QML depends on them. This is the live config, the `hypr-theme` repo that `~/.config/hypr` links to. It has an unrelated uncommitted change in `sections/autostart.lua`: leave it alone and stage only the files below.
+
+**Files:**
+- Create: `~/Programming/GIT/hypr-theme/sections/companion.lua`
+- Modify: `~/Programming/GIT/hypr-theme/hyprland.lua` (after `require("sections.keybindings")`, line 23)
+- Modify: `~/Programming/GIT/hypr-theme/sections/keybindings.lua` (after the `mainMod .. " + Home"` bind, line 68)
+
+- [ ] **Step 1: Write `sections/companion.lua`**
+
+```lua
+-- The desktop companion (quickshell/desktop/companion) reacts to typing and
+-- to deleting. Neither reaches a layer surface that never takes the
+-- keyboard, so the compositor tells it, as custom events on socket2.
+
+-- Typing: a pulse at most once a second, carrying no key data at all.
+if _G.keystoneTyping then _G.keystoneTyping:remove() end
+local lastPulse = 0
+_G.keystoneTyping = hl.on("input.keyboard.key", function()
+ local now = os.time()
+ if now == lastPulse then return end
+ lastPulse = now
+ hl.dispatch(hl.dsp.event("keystone-typing"))
+end)
+
+-- Deleting: BackSpace only, and non_consuming so the key still reaches the
+-- focused window. It is the one key the companion learns about.
+hl.bind("BackSpace", hl.dsp.event("keystone-delete"), { non_consuming = true, description = "Desktop: Companion notices a delete" })
+```
+
+- [ ] **Step 2: Require it**
+
+In `hyprland.lua`, after line 23 (`require("sections.keybindings")`), add:
+
+```lua
+require("sections.companion")
+```
+
+- [ ] **Step 3: Add the toggle bind**
+
+In `sections/keybindings.lua`, after the `mainMod .. " + Home"` line, add:
+
+```lua
+-- code:49, the physical key left of 1: `\` on the Italian layout, a backtick
+-- on the US one. Binding the keysym would move the shortcut with the layout.
+hl.bind(mainMod .. " + code:49", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call companion toggle"), { description = "Desktop: Companion" })
+```
+
+- [ ] **Step 4: Reload Hyprland and read its errors**
+
+Run: `hyprctl reload && sleep 1 && hyprctl configerrors`
+Expected: `ok`, then no error line that mentions `companion`, `code:49`, `BackSpace` or `input.keyboard.key`. Run `hyprctl configerrors` once before Step 1 too, so errors that were already there are not blamed on this task.
+
+- [ ] **Step 5: Watch the events arrive**
+
+Run (it listens 15 s; ask the user to type a few words and press BackSpace in any window meanwhile):
+
+```bash
+timeout 15 socat -u UNIX-CONNECT:"$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" - | grep '^custom>>'
+```
+
+Expected: lines `custom>>keystone-typing`, at most one a second while typing, and `custom>>keystone-delete` per BackSpace. If `socat` is missing, use `nc -U` with the same path. If no `custom>>` line arrives, stop: the QML in Task 8 depends on this exact form, so report what socket2 printed instead.
+
+- [ ] **Step 6: Ask the user to confirm BackSpace still deletes**
+
+In a terminal and in the browser. This is open check 1; it cannot be measured from here. If BackSpace stops deleting, remove the `hl.bind("BackSpace", ...)` line, reload, and report: the mock reaction then needs another source.
+
+- [ ] **Step 7: Commit in hypr-theme**
+
+```bash
+cd ~/Programming/GIT/hypr-theme
+git add sections/companion.lua hyprland.lua sections/keybindings.lua
+git commit -F - <<'EOF'
+companion: typing and delete events, SUPER+\ toggle
+
+The quickshell desktop companion reacts to typing and to BackSpace, and
+a layer surface that never takes the keyboard sees neither, so the
+compositor sends them as custom events: a typing pulse at most once a
+second with no key data, and a non_consuming BackSpace bind so the key
+still deletes. The toggle binds code:49 rather than the backslash
+keysym, which sits on different keys in the it and us layouts.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 2: One "balloon on screen" check for both shells
+
+**Files:**
+- Modify: `shared/NotifyConfig.js` (add after `withheld`, which ends at line 72)
+- Modify: `shared/test_notify_config.js` (the `new Function` line 21, and new asserts before `console.log("ok")`)
+- Modify: `notifications/NotificationBalloon.qml:35-41`
+
+- [ ] **Step 1: Write the failing test**
+
+In `shared/test_notify_config.js`, change line 21 to:
+
+```js
+const C = new Function(src + "\nreturn { defaults, resolve, withheld, soundFor, shown };")();
+```
+
+and insert before the final `console.log("ok");`:
+
+```js
+// On screen: the drawer, snooze, mute, DND and expiry, in one check that
+// the balloons and the desktop companion share.
+const live = { app: "ok", urgency: "normal", expires: 0 };
+assert.strictEqual(C.shown(cfg, live, false, false, 0, 5000), true);
+assert.strictEqual(C.shown(cfg, live, false, true, 0, 5000), false); // drawer open
+assert.strictEqual(C.shown(cfg, live, false, false, 6000, 5000), false); // snoozed
+assert.strictEqual(C.shown(cfg, { app: "spam", urgency: "normal", expires: 0 }, false, false, 0, 5000), false);
+assert.strictEqual(C.shown(cfg, live, true, false, 0, 5000), false); // DND
+const timed = { app: "ok", urgency: "normal", expires: 5000 };
+assert.strictEqual(C.shown(cfg, timed, false, false, 0, 4999), true);
+assert.strictEqual(C.shown(cfg, timed, false, false, 0, 5000), false); // expired
+```
+
+- [ ] **Step 2: Run it to see it fail**
+
+Run: `node shared/test_notify_config.js`
+Expected: FAIL, `ReferenceError: shown is not defined`.
+
+- [ ] **Step 3: Implement `shown`**
+
+In `shared/NotifyConfig.js`, after the closing `}` of `withheld`:
+
+```js
+// Whether a queued notification's balloon is on screen at `now`: the drawer
+// and snooze withhold everything, then mute and DND, then its own expiry.
+// The balloon shell and the desktop companion both ask this, so they cannot
+// disagree about what is showing.
+function shown(cfg, n, dnd, drawerOpen, snoozeUntil, now) {
+ if (drawerOpen || snoozeUntil > now) return false;
+ if (withheld(cfg, n, dnd)) return false;
+ return n.expires === 0 || now < n.expires;
+}
+```
+
+- [ ] **Step 4: Run it to see it pass**
+
+Run: `node shared/test_notify_config.js`
+Expected: `ok`
+
+- [ ] **Step 5: Use it in the balloon**
+
+In `notifications/NotificationBalloon.qml`, replace the `visible: { ... }` block (lines 35-41) with:
+
+```qml
+ visible: NC.shown(Notify.config, b.notification, Notify.dnd, Notify.drawerOpen,
+ Notify.snoozeUntil, b.now)
+```
+
+Keep the comment above it unchanged; it still describes the rule.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/NotifyConfig.js shared/test_notify_config.js notifications/NotificationBalloon.qml
+git commit -F - <<'EOF'
+notify: one "balloon on screen" check, NotifyConfig.shown
+
+The desktop companion needs to know when a balloon is showing in her
+corner, and the rule lived inline in NotificationBalloon's visible
+binding. It moves to the shared library so both shells ask the same
+function: drawer, snooze, mute, DND, then expiry.
+
+Shared files do not hot-reload through their links: restart the
+notifications and desktop shells after pulling this.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 3: Mood.js, the state decision
+
+**Files:**
+- Create: `desktop/companion/Mood.js`
+- Test: `desktop/companion/test_mood.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/companion/test_mood.js` (GPLv2 header first, as in `desktop/assistant/test_md.js`):
+
+```js
+// Run: node desktop/companion/test_mood.js
+// Mood.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 + "/Mood.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const M = new Function(src +
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue };")();
+
+const NOW = 10000000;
+// A snapshot with nothing going on, overridden per case.
+const calm = over => Object.assign({
+ now: NOW, destructAt: 0, dodging: false, shoved: false, recording: false, busy: false,
+ repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0,
+ monologue: false, idle: 0,
+}, over);
+const state = over => M.pick(calm(over)).state;
+const frame = over => M.pick(calm(over)).frame;
+
+// Nothing going on: the rest pose.
+assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" });
+
+// The priority table, top to bottom: with every trigger from a row down
+// active at once, that row wins. Merged so a higher row's values win.
+const ladder = [
+ ["destruct", { destructAt: NOW - 10 }],
+ ["scorched", { destructAt: NOW - M.T.destruct - 10 }],
+ ["dodge", { dodging: true }],
+ ["shoved", { shoved: true }],
+ ["listen", { recording: true }],
+ ["think", { busy: true }],
+ ["wave", { repliedAt: NOW - 10 }],
+ ["clicks", { clicks: { count: 2, at: NOW - 10 } }],
+ ["mock", { mockAt: NOW - 10 }],
+ ["watch", { typingAt: NOW - 10 }],
+ ["stretch", { wakeAt: NOW - 10 }],
+ ["muse", { monologue: true }],
+ ["lying", { idle: 3 }],
+];
+for (let i = 0; i < ladder.length; i++) {
+ const all = Object.assign({}, ...ladder.slice(i).map(l => l[1]).reverse());
+ assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down");
+}
+
+// Shoved has a frame of its own: knocked off balance, glaring at the balloon.
+assert.deepStrictEqual(M.pick(calm({ shoved: true })), { state: "shoved", frame: "shoved" });
+
+// Idle levels and their frames.
+assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "sleepy" });
+assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" });
+assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" });
+
+// Holds end on time.
+assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base");
+assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base");
+assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base");
+assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base");
+assert.strictEqual(state({ destructAt: NOW - M.T.destruct - M.T.scorched }), "base");
+// The mock points first, then smirks.
+assert.strictEqual(frame({ mockAt: NOW - 10 }), "point");
+assert.strictEqual(frame({ mockAt: NOW - M.T.mockHold / 2 - 10 }), "smirk");
+assert.strictEqual(state({ mockAt: NOW - M.T.mockHold }), "base");
+
+// A click burst: within burstGap it grows, past it it starts over.
+let c = { count: 0, at: 0 };
+c = M.click(c, 1000);
+assert.deepStrictEqual(c, { count: 1, at: 1000 });
+c = M.click(c, 1000 + M.T.burstGap);
+assert.strictEqual(c.count, 2);
+c = M.click(c, c.at + M.T.burstGap + 1);
+assert.strictEqual(c.count, 1);
+c = { count: 0, at: 0 };
+for (let i = 0; i < 7; i++) c = M.click(c, 5000 + i * 300);
+assert.strictEqual(c.count, 7);
+// 1 smiles, 2-6 blush harder.
+assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame),
+ ["smile", "blush1", "blush1", "blush2", "blush2", "blush3"]);
+
+// Outfit: the dress until the flash is over, then the leotard for
+// scorched + skirtless, then the dress again.
+assert.strictEqual(M.outfit(0, NOW), "dress");
+assert.strictEqual(M.outfit(NOW - 10, NOW), "dress");
+assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW), "leotard");
+const bare = M.T.destruct + M.T.scorched + M.T.skirtless;
+assert.strictEqual(M.outfit(NOW - bare + 10, NOW), "leotard");
+assert.strictEqual(M.outfit(NOW - bare, NOW), "dress");
+
+// Cooldowns.
+assert.strictEqual(M.mockAllowed(0, NOW), true);
+assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown + 1, NOW), false);
+assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown, NOW), true);
+assert.strictEqual(M.canSpeak(0, NOW), true);
+assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown + 1, NOW), false);
+assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown, NOW), true);
+
+// The monologue: 1 in 4 on boredom, 1 in 3 on being shown, once an hour.
+assert.strictEqual(M.monologue("bored", 0, NOW, 0.24), true);
+assert.strictEqual(M.monologue("bored", 0, NOW, 0.25), false);
+assert.strictEqual(M.monologue("shown", 0, NOW, 0.33), true);
+assert.strictEqual(M.monologue("shown", 0, NOW, 0.34), false);
+assert.strictEqual(M.monologue("other", 0, NOW, 0), false);
+assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false);
+assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true);
+
+console.log("ok");
+```
+
+- [ ] **Step 2: Run it to see it fail**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: FAIL, `ENOENT: no such file or directory, open '.../Mood.js'`.
+
+- [ ] **Step 3: Write `Mood.js`**
+
+`desktop/companion/Mood.js` (GPLv2 header first):
+
+```js
+.pragma library
+
+// Which state the companion is in, from a snapshot of her inputs. Pure, so
+// test_mood.js drives it with plain objects and a fake clock. Companion.qml
+// owns the inputs and the timers; this only decides. Times in milliseconds.
+
+var T = {
+ burstGap: 1500, // a click burst ends after this long without a click
+ clickHold: 2000, // a click reaction lasts this long after the last click
+ mockHold: 2500,
+ mockCooldown: 20000,
+ typingHold: 2000,
+ waveHold: 3000,
+ wakeHold: 1500, // the stretch after idling
+ lineCooldown: 30000,
+ monologueCooldown: 3600000,
+ destruct: 1000, // the glow and the flash; the sprite is hidden under it
+ scorched: 3000,
+ skirtless: 60000,
+};
+
+function st(state, frame) { return { state: state, frame: frame }; }
+
+// The burst after a click at now.
+function click(prev, now) {
+ if (prev.count > 0 && now - prev.at <= T.burstGap) return { count: prev.count + 1, at: now };
+ return { count: 1, at: now };
+}
+
+// Click 1 smiles; 2 to 6 blush harder, in three steps. The 7th is the
+// self-destruct, which Companion.qml starts rather than shows.
+function clickFrame(count) {
+ if (count <= 1) return "smile";
+ if (count <= 3) return "blush1";
+ if (count <= 5) return "blush2";
+ return "blush3";
+}
+
+// The spec's priority table, top to bottom: the first that applies wins.
+// idle is 0 awake, 1 bored, 2 yawning, 3 lying down.
+function pick(s) {
+ const since = s.destructAt ? s.now - s.destructAt : Infinity;
+ if (since < T.destruct) return st("destruct", "base");
+ if (since < T.destruct + T.scorched) return st("scorched", "scorched");
+ if (s.dodging) return st("dodge", "base");
+ if (s.shoved) return st("shoved", "shoved");
+ if (s.recording) return st("listen", "listen");
+ if (s.busy) return st("think", "think");
+ if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave");
+ if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold)
+ return st("clicks", clickFrame(s.clicks.count));
+ if (s.now - s.mockAt < T.mockHold)
+ return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk");
+ if (s.now - s.typingAt < T.typingHold) return st("watch", "watch");
+ if (s.now - s.wakeAt < T.wakeHold) return st("stretch", "stretch");
+ if (s.monologue) return st("muse", "muse");
+ if (s.idle >= 3) return st("lying", "lying");
+ if (s.idle === 2) return st("yawn", "yawn");
+ if (s.idle === 1) return st("bored", "sleepy");
+ return st("base", "base");
+}
+
+// The self-destruct takes the skirt off once the flash is over, for the
+// scorched moment and the skirtless minute after it. Showing her again
+// sets destructAt back to 0, which restores it early.
+function outfit(destructAt, now) {
+ if (!destructAt) return "dress";
+ const since = now - destructAt;
+ return since >= T.destruct && since < T.destruct + T.scorched + T.skirtless ? "leotard" : "dress";
+}
+
+function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; }
+
+function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; }
+
+// Whether the opening monologue plays: a chance per trigger, and never
+// twice within the hour. roll is Math.random(), passed in for the test.
+function monologue(trigger, lastAt, now, roll) {
+ if (now - lastAt < T.monologueCooldown) return false;
+ const chance = trigger === "bored" ? 1 / 4 : trigger === "shown" ? 1 / 3 : 0;
+ return roll < chance;
+}
+```
+
+- [ ] **Step 4: Run it to see it pass**
+
+Run: `node desktop/companion/test_mood.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/Mood.js desktop/companion/test_mood.js
+git commit -F - <<'EOF'
+companion: Mood.js, which state wins
+
+The spec's priority table as a pure function over a snapshot of her
+inputs, with the click burst, the outfit after the self-destruct, the
+mock and line cooldowns and the monologue's odds beside it. Pure so the
+node check can drive it with a fake clock; Companion.qml will own the
+inputs and timers.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 4: Lines.js, what she says
+
+**Files:**
+- Create: `desktop/companion/Lines.js`
+
+- [ ] **Step 1: Write `Lines.js`**
+
+GPLv2 header first, then:
+
+```js
+.pragma library
+
+// What she says, in English, grouped by the reaction that says it.
+// Companion.qml picks one at random per reaction and keeps to one line in
+// 30 s, except for clicks and the self-destruct (Mood.canSpeak).
+
+var lines = {
+ shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."],
+ bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."],
+ wake: ["I'm awake.", "Systems online."],
+ mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"],
+ shoved: ["Hey.", "Rude.", "I was standing here."],
+ ready: ["Answer's ready.", "Your reply came in."],
+ click: ["...Yes?", "Can I help you?"],
+ blush: ["S-stop that.", "...Operator."],
+ dodge: ["...Fine.", "I'll move."],
+ destruct: ["Self-destruct sequence initiated."],
+ aftermath: ["...That was unpleasant."],
+};
+
+// The game's opening, one sentence per bubble.
+var monologue = [
+ "Everything that lives is designed to end.",
+ "We are perpetually trapped in a never-ending spiral of life and death.",
+ "Is this a curse?",
+ "Or some kind of punishment?",
+ "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.",
+];
+
+// A line for kind; roll is Math.random(). "" for a kind with no lines.
+function pick(kind, roll) {
+ const l = lines[kind] || [];
+ return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : "";
+}
+```
+
+- [ ] **Step 2: Check it evaluates**
+
+Run:
+```bash
+node -e 'const s=require("fs").readFileSync("desktop/companion/Lines.js","utf8").replace(/^\.pragma library$/m,""); const L=new Function(s+"\nreturn {lines,monologue,pick};")(); console.log(L.pick("mock",0), "|", L.pick("mock",0.999), "|", JSON.stringify(L.pick("nope",0)), "|", L.monologue.length)'
+```
+Expected: `Typo again? | Were you aiming for that key? | "" | 5`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/companion/Lines.js
+git commit -F - <<'EOF'
+companion: her lines and the opening monologue
+
+Fixed English lines per reaction, terse in the game's register, and the
+opening monologue split at its sentences so each fills one bubble.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 5: prep-frames, and a placeholder base
+
+> **Done before implementation (2026-10-23), skip it.** `prep-frames` was committed with `gen-frames` and `frames.txt` (14a2c75), and every frame exists: 18 dress, 19 leotard, in `~/.local/share/keystone/companion/2b/frames/`. Run `desktop/companion/prep-frames` once to confirm `37 frames`.
+
+**Files:**
+- Create: `desktop/companion/prep-frames` (executable)
+
+- [ ] **Step 1: Write the script**
+
+```bash
+#!/bin/bash
+#
+# (GPLv2 header, as in desktop/modules/ai/ai-state.sh lines 2-12)
+#
+# prep-frames: the companion's generated images to the frames she shows.
+#
+# prep-frames [ART_DIR] default: ~/.local/share/keystone/companion/2b
+#
+# Reads ART_DIR/dress/*.png and ART_DIR/leotard/*.png, 1024x1024 on white,
+# and writes ART_DIR/frames/<outfit>/<name>.png: background removed, trimmed
+# to her, scaled by a quarter. One scale for every frame keeps her the same
+# size whatever the pose, which scaling each to a fixed height would not:
+# lying down would come out huge. Sprite.qml draws them at half size and
+# stands each on its bottom edge.
+#
+# The background goes by flood fill from the four corners: the dark line art
+# stops the fill, so the white hair and the white leotard inside it stay.
+
+set -euo pipefail
+
+art=${1:-$HOME/.local/share/keystone/companion/2b}
+n=0
+for outfit in dress leotard; do
+ [[ -d $art/$outfit ]] || continue
+ mkdir -p "$art/frames/$outfit"
+ for src in "$art/$outfit"/*.png; do
+ [[ -e $src ]] || continue
+ read -r w h < <(magick identify -format '%w %h\n' "$src")
+ magick "$src" -alpha set -fuzz 6% -fill none \
+ -draw "color 0,0 floodfill" -draw "color $((w - 1)),0 floodfill" \
+ -draw "color 0,$((h - 1)) floodfill" -draw "color $((w - 1)),$((h - 1)) floodfill" \
+ -trim +repage -resize 25% "$art/frames/$outfit/$(basename "$src")"
+ n=$((n + 1))
+ done
+done
+echo "prep-frames: $n frames in $art/frames"
+```
+
+Then `chmod +x desktop/companion/prep-frames`.
+
+- [ ] **Step 2: Check it on a copy of the sitting base**
+
+Run:
+```bash
+T=$(mktemp -d) && mkdir "$T/dress" && cp ~/.local/share/keystone/companion/2b/base-sitting.png "$T/dress/base.png" \
+ && desktop/companion/prep-frames "$T" \
+ && magick "$T/frames/dress/base.png" -format '%w %h corner=%[fx:p{0,0}.a] hair=%[fx:p{w/2,h*0.15}.a]\n' info: ; rm -rf "$T"
+```
+Expected: `prep-frames: 1 frames in ...`, then about `173 255 corner=0 hair=1`: transparent corner, opaque hair. A `hair=0` means the fill ate her hair: stop and report.
+
+- [ ] **Step 3: Put a placeholder base in place**
+
+The standing base is still being generated (spec, "She stands"). Until it exists the sitting one stands in, so the component has a frame to show:
+
+```bash
+A=~/.local/share/keystone/companion/2b
+mkdir -p "$A/dress" && [ -e "$A/dress/base.png" ] || cp "$A/base-sitting.png" "$A/dress/base.png"
+desktop/companion/prep-frames
+ls "$A/frames/dress"
+```
+Expected: `base.png` listed. If the user has already delivered the standing base, it is used instead: the `[ -e ]` keeps it.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/companion/prep-frames
+git commit -F - <<'EOF'
+companion: prep-frames, generated images to display frames
+
+Background removed by flood fill from the corners, which the line art
+stops, so white hair survives; trimmed; scaled by one quarter for every
+frame, so she stays one size across poses. The art itself stays outside
+the repo.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 6: The assistant says when a reply arrives
+
+**Files:**
+- Modify: `desktop/assistant/Assistant.qml` (after line 76, `readonly property bool busy: ...`; and the `case "done":` branch, line ~408)
+
+- [ ] **Step 1: Add the signal**
+
+After `readonly property bool busy: root.state !== "idle"`:
+
+```qml
+ // A reply finished (not stopped). The canvas notifies through notifyDone;
+ // the desktop companion waves.
+ signal replied()
+```
+
+- [ ] **Step 2: Emit it**
+
+In `case "done":`, replace
+
+```qml
+ if (!e.stopped && i >= 0) root.notifyDone(turns.get(i).reply);
+```
+
+with
+
+```qml
+ if (!e.stopped && i >= 0) {
+ root.notifyDone(turns.get(i).reply);
+ root.replied();
+ }
+```
+
+- [ ] **Step 3: Check the reload**
+
+Run: `sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -5`
+Expected: a `Reloading configuration...` after the edit's mtime and `Configuration Loaded`, no error. If no reload line follows, see AGENTS.md, Reloading: force one by changing a file that imports the directory.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/assistant/Assistant.qml
+git commit -F - <<'EOF'
+assistant: replied() when a reply finishes
+
+The desktop companion waves when an answer is ready. The moment already
+existed as the done branch that calls notifyDone; it is now a signal
+too, so the companion connects to it instead of reading the turn list.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 7: Sprite.qml
+
+**Files:**
+- Create: `desktop/companion/Sprite.qml`
+
+- [ ] **Step 1: Write it**
+
+GPLv2 header first, then:
+
+```qml
+import QtQuick
+import ".."
+
+// One frame of the companion, drawn at half its pixel size and standing on
+// its bottom edge: a crossfade between frames, a slow bob, the drop out of
+// view and back, and the self-destruct flash. Which frame is Mood.js's
+// business; this only draws it.
+Item {
+ id: s
+
+ // The frame, and what to show when it is missing: the same outfit's
+ // base, never the other outfit's, so a gap in the art cannot put the
+ // skirt back mid-scene.
+ property string source
+ property string fallback
+ // Dropped below the bottom edge and faded out.
+ property bool out: false
+ signal settled(bool out)
+
+ // Two images: the front one stays until the back one has loaded, then
+ // they swap, so a frame change never flashes empty.
+ property bool flip: false
+ readonly property Image front: s.flip ? b : a
+ width: s.front.implicitWidth / 2
+ height: s.front.implicitHeight / 2
+
+ function load(url) {
+ const front = s.flip ? b : a, back = s.flip ? a : b;
+ if (String(front.source) === url) { back.source = ""; return; }
+ if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; }
+ back.source = url;
+ }
+ onSourceChanged: s.load(s.source)
+ Component.onCompleted: s.load(s.source)
+
+ function ready(img) {
+ if (img.status === Image.Error && String(img.source) !== s.fallback) {
+ img.source = s.fallback;
+ return;
+ }
+ if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip;
+ }
+
+ property real drop: 0
+ property real bob: 0
+ NumberAnimation {
+ id: dropAnim
+ target: s; property: "drop"; duration: 320
+ onFinished: s.settled(s.out)
+ }
+ onOutChanged: {
+ dropAnim.stop();
+ dropAnim.to = s.out ? s.height + 24 : 0;
+ dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack;
+ dropAnim.start();
+ }
+ SequentialAnimation on bob {
+ running: !s.out
+ loops: Animation.Infinite
+ NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine }
+ }
+
+ Item {
+ anchors.fill: parent
+ opacity: 1 - s.drop / (s.height + 24)
+ transform: Translate { y: s.drop - s.bob }
+
+ Image {
+ id: a
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 0 : 1
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(a)
+ }
+ Image {
+ id: b
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(b)
+ }
+ }
+
+ // --- self-destruct: the glow swells into a flash, then a ring and
+ // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which
+ // the frame swaps to the leotard under the flash.
+
+ function explode() { boom.restart(); }
+
+ property real burst: 0
+ Rectangle {
+ id: flash
+ anchors.centerIn: parent
+ width: s.height * 1.6; height: width; radius: width / 2
+ color: "white"; opacity: 0; scale: 0
+ }
+ Rectangle {
+ id: ring
+ anchors.centerIn: parent
+ width: s.height * 2; height: width; radius: width / 2
+ color: "transparent"
+ border { width: 3; color: Theme.peach }
+ opacity: 0; scale: 0
+ }
+ Repeater {
+ model: 8
+ Rectangle {
+ required property int index
+ readonly property real angle: index * Math.PI / 4
+ width: 6; height: 6; radius: 3
+ color: index % 2 ? Theme.peach : Theme.yellow
+ x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height
+ y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height
+ opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0
+ }
+ }
+ SequentialAnimation {
+ id: boom
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad }
+ NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 }
+ }
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 }
+ NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic }
+ NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 }
+ NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic }
+ }
+ PropertyAction { target: s; property: "burst"; value: 0 }
+ }
+}
+```
+
+No commit yet: nothing loads it until Task 8, and they are committed together.
+
+---
+
+### Task 8: Companion.qml and the wiring
+
+**Files:**
+- Create: `desktop/companion/Companion.qml`
+- Modify: `desktop/shell.qml` (imports at line 42; the `Assistant { ... }` line ~124)
+
+- [ ] **Step 1: Write `Companion.qml`**
+
+GPLv2 header first, then:
+
+```qml
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import QtQuick
+import ".."
+import "../NotifyConfig.js" as NC
+import "Mood.js" as Mood
+import "Lines.js" as Lines
+
+// The desktop companion: a chibi 2B in the bottom-right corner of the
+// focused monitor. Inputs and timers live here, the decision in Mood.js,
+// the drawing in Sprite.qml. See README.md here and
+// docs/superpowers/specs/2026-10-23-companion-design.md.
+PanelWindow {
+ id: root
+
+ required property var asst
+
+ readonly property string art: Quickshell.env("HOME") + "/.local/share/keystone/companion/2b/frames"
+
+ // SUPER+\, through the IPC below.
+ property bool present: true
+
+ // The monitor she is on trails the focused one: a change plays her exit
+ // here first, and only then moves the window (settled, below).
+ readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
+ property string here: ""
+ property bool hopping: false
+ onFocusedChanged: {
+ if (root.here === "") root.here = root.focused;
+ else if (root.focused !== root.here) root.hopping = true;
+ }
+ Component.onCompleted: root.here = root.focused
+
+ screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0]
+ anchors { right: true; bottom: true }
+ margins { right: 24; bottom: 16 }
+ implicitWidth: 760
+ implicitHeight: 260
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Top
+ WlrLayershell.namespace: "keystone-companion"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ // Her body only: clicks anywhere else, the bubble included, reach the
+ // window below. Out of view the region is empty.
+ mask: Region { item: hit }
+
+ // --- inputs ---------------------------------------------------------
+
+ property double now: Date.now()
+ Timer { interval: 200; repeat: true; running: root.present; onTriggered: root.now = Date.now() }
+
+ property var clicks: ({ count: 0, at: 0 })
+ property double destructAt: 0
+ property bool dodging: false
+ property double repliedAt: 0
+ property double mockAt: 0
+ property double typingAt: 0
+ property double wakeAt: 0
+ property double lastLineAt: 0
+ property double lastMonologueAt: 0
+ property bool monologue: false
+
+ // Bored, yawning, lying down at 1, 3 and 5 minutes. Inhibitors count,
+ // so presentation mode keeps her awake. enabled is set explicitly: left
+ // out, it read false on creation.
+ IdleMonitor { id: idle1; enabled: true; respectInhibitors: true; timeout: 60 }
+ IdleMonitor { id: idle2; enabled: true; respectInhibitors: true; timeout: 180 }
+ IdleMonitor { id: idle3; enabled: true; respectInhibitors: true; timeout: 300 }
+ readonly property int idle: idle3.isIdle ? 3 : idle2.isIdle ? 2 : idle1.isIdle ? 1 : 0
+ property int wasIdle: 0
+ onIdleChanged: {
+ const t = Date.now();
+ if (root.idle === 0 && root.wasIdle > 0) {
+ root.wakeAt = t;
+ root.say("wake");
+ } else if (root.idle === 1 && root.wasIdle === 0) {
+ if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue();
+ else root.say("bored");
+ }
+ root.wasIdle = root.idle;
+ }
+
+ // 2 is real fullscreen; 1 is maximised, which is most windows here and
+ // must not hide her. lastIpcObject is only current after a refresh, and
+ // the island measured that it settles a moment after the event.
+ property int fullscreen: 0
+ Timer {
+ id: fsSettle
+ interval: 60
+ onTriggered: root.fullscreen = Hyprland.activeToplevel?.lastIpcObject?.fullscreen ?? 0
+ }
+
+ Connections {
+ target: Hyprland
+ function onRawEvent(event) {
+ if (event.name === "custom") {
+ // From hypr-theme's sections/companion.lua.
+ const what = event.data.trim();
+ const t = Date.now();
+ if (what === "keystone-typing") {
+ root.typingAt = t;
+ } else if (what === "keystone-delete") {
+ root.typingAt = t;
+ if (Mood.mockAllowed(root.mockAt, t)) {
+ root.mockAt = t;
+ root.say("mock");
+ }
+ }
+ } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ }
+ }
+ }
+
+ Connections {
+ target: root.asst
+ function onReplied() {
+ root.repliedAt = Date.now();
+ root.say("ready");
+ }
+ }
+
+ // Balloons stack bottom-right of their screen. When that is her corner,
+ // any balloon on screen shoves her left past the stack.
+ readonly property bool shoved: {
+ const place = Notify.config.balloons;
+ if (place.screen !== root.here || place.corner !== "bottom-right") return false;
+ return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd,
+ Notify.drawerOpen, Notify.snoozeUntil, root.now));
+ }
+ onShovedChanged: if (root.shoved) root.say("shoved")
+
+ // --- state ----------------------------------------------------------
+
+ readonly property var mood: Mood.pick({
+ now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved,
+ recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt,
+ clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,
+ monologue: root.monologue, idle: root.idle,
+ })
+ readonly property string outfit: Mood.outfit(root.destructAt, root.now)
+ // Anything above the monologue cuts it short.
+ onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue()
+
+ // Hidden: no sprite, no bubble. Away adds the dodge, which hides the
+ // sprite but lets its line show.
+ readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping
+ readonly property bool away: root.hidden || root.dodging
+
+ // --- speech ---------------------------------------------------------
+
+ function say(kind, force) {
+ const t = Date.now();
+ if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return;
+ const line = Lines.pick(kind, Math.random());
+ if (line === "") return;
+ root.lastLineAt = t;
+ bubble.show(line, 3000);
+ }
+
+ // The opening monologue: about 15 characters a second, one sentence
+ // per bubble, 1.5 s between sentences, the last held 4 s.
+ property int monoLine: 0
+ function startMonologue() {
+ if (root.hidden) return;
+ root.monologue = true;
+ root.lastMonologueAt = Date.now();
+ root.lastLineAt = root.lastMonologueAt;
+ root.monoLine = 0;
+ typer.begin(Lines.monologue[0]);
+ }
+ function stopMonologue() {
+ root.monologue = false;
+ typer.stop();
+ typerHold.stop();
+ bubble.hide();
+ }
+ Timer {
+ id: typer
+ property string full: ""
+ interval: 66
+ repeat: true
+ function begin(text) { typer.full = text; bubble.typed(""); typer.restart(); }
+ onTriggered: {
+ const n = bubble.text.length + 1;
+ bubble.typed(typer.full.slice(0, n));
+ if (n < typer.full.length) return;
+ typer.stop();
+ typerHold.interval = root.monoLine === Lines.monologue.length - 1 ? 4000 : 1500;
+ typerHold.restart();
+ }
+ }
+ Timer {
+ id: typerHold
+ onTriggered: {
+ root.monoLine++;
+ if (root.monoLine >= Lines.monologue.length) root.stopMonologue();
+ else typer.begin(Lines.monologue[root.monoLine]);
+ }
+ }
+
+ // --- clicks and the dodge -------------------------------------------
+
+ function clicked() {
+ const t = Date.now();
+ root.now = t;
+ root.clicks = Mood.click(root.clicks, t);
+ if (root.clicks.count >= 7) {
+ root.clicks = { count: 0, at: 0 };
+ root.stopMonologue();
+ root.destructAt = t;
+ sprite.explode();
+ root.say("destruct", true);
+ aftermath.restart();
+ } else if (root.clicks.count === 1) {
+ root.say("click", true);
+ } else if (root.clicks.count === 4) {
+ root.say("blush", true);
+ }
+ }
+ Timer { id: aftermath; interval: Mood.T.destruct + 400; onTriggered: root.say("aftermath", true) }
+
+ // She cannot see the pointer coming, only feel it on her: Quickshell has
+ // no global pointer and Hyprland sends no motion events. A click before
+ // the 0.6 s still reaches her.
+ Timer {
+ id: hover
+ interval: 600
+ onTriggered: {
+ if (Math.random() < 0.5) root.say("dodge");
+ root.dodging = true;
+ dodgeBack.restart();
+ }
+ }
+ Timer { id: dodgeBack; interval: 5000; onTriggered: root.dodging = false }
+
+ // --- showing and hiding ----------------------------------------------
+
+ function appear() {
+ if (root.present) return;
+ root.present = true;
+ root.destructAt = 0; // the skirt is back
+ root.now = Date.now();
+ greet.restart();
+ }
+ function dismiss() {
+ if (!root.present) return;
+ root.stopMonologue();
+ bubble.hide();
+ root.present = false;
+ }
+ // After the drop-in has landed.
+ Timer {
+ id: greet
+ interval: 450
+ onTriggered: {
+ if (Mood.monologue("shown", root.lastMonologueAt, Date.now(), Math.random())) root.startMonologue();
+ else root.say("shown", true);
+ }
+ }
+
+ // qs -p <desktop> ipc call companion toggle -> SUPER+\
+ IpcHandler {
+ target: "companion"
+ function toggle() { if (root.present) root.dismiss(); else root.appear(); }
+ function show() { root.appear(); }
+ function hide() { root.dismiss(); }
+ }
+
+ // The exit has played on the old monitor: move, and the entrance plays.
+ function settled(out) {
+ if (out && root.hopping) {
+ root.here = root.focused;
+ root.hopping = false;
+ }
+ }
+
+ // --- drawing --------------------------------------------------------
+
+ // Where she stands: the bottom-right of the window, or left of the
+ // balloon stack. 336 = the stack's 340 px plus its 12 px margin
+ // (notifications/Balloons.qml), minus this window's 24 px right margin,
+ // plus 8 px of air.
+ Item {
+ id: seat
+ width: sprite.width
+ height: sprite.height
+ anchors.bottom: parent.bottom
+ x: root.width - width - (root.shoved ? 336 : 0)
+ Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
+ }
+
+ Sprite {
+ id: sprite
+ x: seat.x
+ y: seat.y
+ source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png`
+ fallback: `file://${root.art}/${root.outfit}/base.png`
+ out: root.away
+ onSettled: out => root.settled(out)
+ }
+
+ Item {
+ id: hit
+ x: sprite.x
+ y: sprite.y
+ width: root.away ? 0 : sprite.width
+ height: root.away ? 0 : sprite.height
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: hover.restart()
+ onExited: hover.stop()
+ onClicked: { hover.stop(); root.clicked(); }
+ }
+ }
+
+ // Up and to the left of her head, anchored to where she stands rather
+ // than to the sprite, so a dodge line stays where she was.
+ Rectangle {
+ id: bubble
+ property alias text: label.text
+ function show(t, ms) { label.text = t; bubble.opacity = 1; hideTimer.interval = ms; hideTimer.restart(); }
+ function typed(t) { label.text = t; bubble.opacity = 1; hideTimer.stop(); }
+ function hide() { hideTimer.stop(); bubble.opacity = 0; }
+
+ visible: !root.hidden && opacity > 0 && label.text !== ""
+ anchors { right: seat.left; rightMargin: -14; bottom: seat.top; bottomMargin: -22 }
+ width: Math.min(260, label.implicitWidth + 24)
+ height: label.implicitHeight + 16
+ radius: 12
+ color: Qt.alpha(Theme.base, 0.92)
+ border { width: 1; color: Qt.alpha(Theme.text, 0.25) }
+ opacity: 0
+ Behavior on opacity { NumberAnimation { duration: 180 } }
+
+ Timer { id: hideTimer; onTriggered: bubble.opacity = 0 }
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ width: Math.min(236, implicitWidth)
+ wrapMode: Text.WordWrap
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: 13 }
+ }
+ Rectangle {
+ width: 10; height: 10; rotation: 45
+ color: bubble.color
+ anchors { right: parent.right; rightMargin: 18; bottom: parent.bottom; bottomMargin: -5 }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Wire it into the shell**
+
+In `desktop/shell.qml`, after `import "assistant"` (line 42):
+
+```qml
+import "companion"
+```
+
+and after the `Assistant { id: assistant; ... }` line:
+
+```qml
+
+ // The desktop companion, bottom-right of the focused monitor, on SUPER+\.
+ // She reads the assistant's state. See companion/README.md.
+ Companion { asst: assistant }
+```
+
+- [ ] **Step 3: Check the reload and the layer**
+
+Run:
+```bash
+sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -8
+hyprctl layers -j | jq -r '.[] | .levels[]?[] | select(.namespace=="keystone-companion") | "x=\(.x) y=\(.y) w=\(.w) h=\(.h)"'
+```
+Expected: `Configuration Loaded` with no `companion` error or warning after it, and one layer line, `w=760 h=260`, at the bottom-right of the focused monitor. A `ReferenceError` naming `Companion` means the new directory was not indexed: see AGENTS.md, Reloading, and ask the user to run `./reload.sh desktop`.
+
+- [ ] **Step 4: Run both JS checks again**
+
+Run: `node desktop/companion/test_mood.js && node shared/test_notify_config.js`
+Expected: `ok` twice.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/companion/Sprite.qml desktop/companion/Companion.qml desktop/shell.qml
+git commit -F - <<'EOF'
+companion: the window, her inputs and her drawing
+
+Companion.qml is a Top-layer PanelWindow bottom-right of the focused
+monitor, masked to her body so every other click passes through. It
+gathers the inputs (Hyprland's custom typing and delete events, the
+assistant's state and replied signal, the balloons on her screen,
+three IdleMonitors, clicks and a hover), hands a snapshot to Mood.js,
+and speaks from Lines.js. Sprite.qml crossfades frames, bobs, drops
+out of view and back, and plays the self-destruct.
+
+Maximised windows report fullscreen 1 and most windows here are, so
+only 2 hides her. She only feels the pointer once it is on her, so the
+dodge waits 0.6 s and a quick click still lands.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 9: Notes
+
+**Files:**
+- Create: `desktop/companion/README.md`
+- Modify: `AGENTS.md` (the directory listing under "What this is")
+- Modify: `docs/superpowers/specs/2026-10-23-companion-design.md` (`lines.js` to `Lines.js`)
+
+- [ ] **Step 1: Write the README**
+
+```markdown
+# companion
+
+A chibi 2B in the bottom-right corner of the focused monitor, on SUPER+\.
+The design, with what was measured on the way, is
+`docs/superpowers/specs/2026-10-23-companion-design.md`.
+
+## Files
+
+| File | |
+| --- | --- |
+| `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) |
+| `Sprite.qml` | draws a frame: crossfade, bob, drop out and back, explosion |
+| `Mood.js` | which state wins, from a snapshot; all the timings are its `T` |
+| `Lines.js` | her lines and the opening monologue |
+| `prep-frames` | generated images to display frames |
+
+## Art
+
+Not in the repo: the character is Square Enix's and this repo is GPLv2.
+It lives in `~/.local/share/keystone/companion/2b/`:
+
+ dress/<name>.png, leotard/<name>.png generated, 1024x1024 on white
+ frames/<outfit>/<name>.png what she shows, from prep-frames
+ recipe.txt how each was made, and what failed
+
+Run `prep-frames` after adding or replacing any generated image. A missing
+frame falls back to its outfit's `base`.
+
+## Inputs
+
+- Typing and BackSpace come from `sections/companion.lua` in the
+ hypr-theme repo, as `custom>>keystone-typing` and
+ `custom>>keystone-delete` on socket2. Nothing else about the keyboard
+ reaches her.
+- She only feels the pointer while it is over her: Quickshell has no
+ global pointer and Hyprland sends no motion events. Hence the 0.6 s
+ dodge rather than moving away as the pointer approaches.
+- `IdleMonitor.enabled` read false on creation when left out, so each of
+ the three sets it.
+- Fullscreen is `lastIpcObject.fullscreen === 2`. Maximised is 1, and
+ most windows here are maximised.
+- The lock screen covers every layer, so locking needs no code.
+
+## Check
+
+ node desktop/companion/test_mood.js
+```
+
+- [ ] **Step 2: List the component in AGENTS.md**
+
+In the directory listing under "What this is", after the `assistant/` line, add:
+
+```
+ companion/ a chibi 2B, bottom-right of the focused monitor, on SUPER+\
+```
+
+- [ ] **Step 3: Fix the spec's file name**
+
+In `docs/superpowers/specs/2026-10-23-companion-design.md`, replace `lines.js` with `Lines.js` (two places: the file tree and "Fixed English lines from `lines.js`").
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/companion/README.md AGENTS.md docs/superpowers/specs/2026-10-23-companion-design.md
+git commit -F - <<'EOF'
+companion: README, AGENTS listing, Lines.js in the spec
+
+The README records the measurements the code depends on: where typing
+and BackSpace come from, why the dodge waits for the pointer, the
+IdleMonitor enabled default, and fullscreen 2 against maximised 1.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 10: Restart and check by hand
+
+Visual, so the user does it. Ask them to run, from their own terminal:
+
+```
+./reload.sh desktop notifications
+```
+
+(both link the edited `shared/NotifyConfig.js`; hot reload does not follow the link). Then go through, one at a time, and record the results in the README if anything differs:
+
+- [ ] She appears bottom-right of the focused monitor; SUPER+\ hides and shows her, with a greeting line.
+- [ ] Focus DP-3, then DP-1: she drops out on one and in on the other.
+- [ ] `notify-send test hello`: she slides left of the balloon, looks mad, says a line; slides back when it expires.
+- [ ] Type in a terminal: she watches. BackSpace: she points, then smirks; again within 20 s: no second mock.
+- [ ] Ask the assistant something with the canvas closed: think while it works, wave when it replies.
+- [ ] Rest the pointer on her: after about 0.6 s she drops away and returns 5 s later. A quick click instead: a smile.
+- [ ] Seven quick clicks: glow, flash, sparks, "Self-destruct sequence initiated.", then "...That was unpleasant."
+- [ ] A fullscreen video (F11 in the browser) hides her; a maximised window does not. This is the spec's open check 3.
+- [ ] Leave the machine 1 minute: bored (or the monologue); 3: yawn; 5: lying down; touch the mouse: stretch.
+- [ ] Clicks anywhere beside her and on the bubble reach the window underneath.
+
+Until the frames are made, every state shows the base: that is the fallback working, not a bug.
+
+---
+
+### Task 11: The frames (the user's, with sd-webui)
+
+> **Done (2026-10-23).** Made with `desktop/companion/gen-frames` from `frames.txt`, not by hand in the web UI; `shoved` was added as an 18th frame per set. The list below is kept as the record of what the set contains.
+
+Not code: a checklist for generating the art with the recipes in `recipe.txt`. Each image goes into `~/.local/share/keystone/companion/2b/dress/` or `leotard/` under the frame's name, then `desktop/companion/prep-frames`.
+
+- [x] `dress/base.png`: klein edit of `base-sitting.png`, the standing prompt (delivered 2026-10-23, before Task 5, so its placeholder step keeps it)
+- [ ] img2img from `dress/base.png`, strength 0.75: `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy`
+- [ ] klein edits of `dress/base.png`: `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`
+- [ ] klein edit of each dress frame into `leotard/<same name>`: "her skirt is gone, revealing a plain white leotard; change nothing else"
+- [ ] klein edit of `leotard/base.png`: `leotard/scorched`