aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-23-companion.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-10-23-companion.md')
-rw-r--r--docs/superpowers/plans/2026-10-23-companion.md1373
1 files changed, 1373 insertions, 0 deletions
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..371e9fa
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-companion.md
@@ -0,0 +1,1373 @@
+# 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` | create | generated images to display frames |
+| `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");
+}
+
+// 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", "mad");
+ 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
+
+**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)
+
+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`