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-05-ai-module.md836
1 files changed, 836 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-05-ai-module.md b/docs/superpowers/plans/2026-10-05-ai-module.md
new file mode 100644
index 0000000..6814d95
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-05-ai-module.md
@@ -0,0 +1,836 @@
+# AI Module 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 drawer module showing the local AI engines (read-only) and the apps using them (start/stop).
+
+**Architecture:** `ai-state.sh` sweeps the stack once and prints six TSV lines. `AiModule.qml` polls it every 3s while the drawer tile exists, and runs start/stop through each project's own control with `Quickshell.execDetached`. Same shape as `desktop/modules/kdeconnect/`.
+
+**Tech Stack:** bash, curl, jq, pgrep; Quickshell 0.3.1 QML.
+
+Spec: `docs/superpowers/specs/2026-10-05-ai-module-design.md`.
+
+Rules from `AGENTS.md` that apply here:
+- GPLv2 header in every source file (copy it from any existing file below).
+- No absolute home paths in committed files; use `$HOME` / `~`.
+- Never start a detached `qs` from a tool call, never `pkill -f`. The user's desktop shell hot-reloads on save; verify with its log, ask the user for anything visual.
+- Never probe llama `/slots` without `autoload=false`: it loads the model into VRAM.
+
+---
+
+### Task 1: `ai-state.sh` with its test
+
+**Files:**
+- Create: `desktop/modules/ai/test-ai-state.sh`
+- Create: `desktop/modules/ai/ai-state.sh`
+- Modify: `docs/superpowers/specs/2026-10-05-ai-module-design.md` (fanfictioner detection wording)
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/ai/test-ai-state.sh`:
+
+```bash
+#!/bin/bash
+#
+# 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.
+#
+# The one runnable check for ai-state.sh. It puts stub curl, pgrep,
+# llamachat and imggen on PATH and a stub assistant.sh beside them, then runs
+# the real script, so nothing here touches the live stack. T_* variables pick
+# the state each stub reports.
+#
+# Usage: ./test-ai-state.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+stub="$(mktemp -d)"
+trap 'rm -rf "$stub"' EXIT
+
+# The last argument is the URL. /slots refuses a probe without
+# autoload=false, because the real server would load the model for it.
+cat > "$stub/curl" <<'STUB'
+#!/bin/bash
+url="${!#}"
+case "$url" in
+ */models)
+ case "${T_LLAMA:-down}" in
+ down) exit 7 ;;
+ unloaded) echo '{"data":[{"id":"A","status":{"value":"unloaded"}}]}' ;;
+ *) echo '{"data":[{"id":"A","status":{"value":"unloaded"}},{"id":"Gemma","status":{"value":"loaded"}}]}' ;;
+ esac ;;
+ */slots)
+ [[ " $* " == *" autoload=false "* ]] || exit 99
+ [[ " $* " == *" model=Gemma "* ]] || exit 22
+ if [[ "$T_LLAMA" == busy ]]; then
+ echo '[{"id":0,"is_processing":false},{"id":1,"is_processing":true}]'
+ else
+ echo '[{"id":0,"is_processing":false}]'
+ fi ;;
+ *) exit 6 ;;
+esac
+STUB
+
+cat > "$stub/pgrep" <<'STUB'
+#!/bin/bash
+case "$*" in
+ *sd-server*)
+ [[ -n "${T_SD:-}" ]] || exit 1
+ echo "4242 sd-server --listen-port 7860 --diffusion-model /data/SD/z_image_turbo-Q8_0.gguf --vae /data/SD/vae/flux1-ae.safetensors" ;;
+ *fanfictioner*)
+ [[ -n "${T_FANFIC:-}" ]] || exit 1
+ echo 31337 ;;
+ *) exit 1 ;;
+esac
+STUB
+
+cat > "$stub/llamachat" <<'STUB'
+#!/bin/bash
+[[ "$1" == --ping && -n "${T_CHAT:-}" ]]
+STUB
+
+# The real imggen exits 0 either way and says which on stdout.
+cat > "$stub/imggen" <<'STUB'
+#!/bin/bash
+[[ "$1" == status ]] || exit 1
+if [[ -n "${T_IMG:-}" ]]; then echo '{"model": "realvis", "ready": true}'; else echo "daemon down"; fi
+STUB
+
+cat > "$stub/assistant.sh" <<'STUB'
+#!/bin/bash
+[[ "$1" == status && -n "${T_ASSIST:-}" ]]
+STUB
+
+chmod +x "$stub"/*
+log="$stub/sd-server.log"
+: > "$log"
+
+pass=0
+fail=0
+
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %q\n got: %q\n' "$name" "$want" "$got"
+ fi
+}
+
+# One line of the protocol, tab joined.
+l() { local IFS=$'\t'; printf '%s\n' "$*"; }
+
+run() {
+ env PATH="$stub:$PATH" AI_ASSISTANT="$stub/assistant.sh" AI_SD_LOG="$log" "$@" \
+ bash "$here/ai-state.sh"
+}
+
+apps_down="$(l app assistant stopped -; l app chat stopped -; l app imggen stopped -; l app fanfic stopped -)"
+
+want="$(l engine llama down idle -; l engine sd down idle -)
+$apps_down"
+check "everything down" "$want" "$(run)"
+run >/dev/null
+check "everything down exits zero" 0 $?
+
+got="$(run T_LLAMA=unloaded | head -n1)"
+check "llama up, no model loaded" "$(l engine llama up idle -)" "$got"
+
+got="$(run T_LLAMA=idle | head -n1)"
+check "llama loaded, idle" "$(l engine llama up idle Gemma)" "$got"
+
+got="$(run T_LLAMA=busy | head -n1)"
+check "llama loaded, generating" "$(l engine llama up busy Gemma)" "$got"
+
+touch "$log"
+got="$(run T_SD=1 | sed -n 2p)"
+check "sd running, log just written" "$(l engine sd up busy z_image_turbo-Q8_0.gguf)" "$got"
+
+touch -d '1 minute ago' "$log"
+got="$(run T_SD=1 | sed -n 2p)"
+check "sd running, log quiet" "$(l engine sd up idle z_image_turbo-Q8_0.gguf)" "$got"
+
+want="$(l app assistant running -; l app chat running -; l app imggen running realvis; l app fanfic running 31337)"
+got="$(run T_ASSIST=1 T_CHAT=1 T_IMG=1 T_FANFIC=1 | tail -n4)"
+check "every app running" "$want" "$got"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
+```
+
+Then `chmod +x desktop/modules/ai/test-ai-state.sh`.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `desktop/modules/ai/test-ai-state.sh`
+Expected: every check FAILs (bash reports `ai-state.sh: No such file or directory`), last line `0 passed, 8 failed` or similar, exit 1.
+
+- [ ] **Step 3: Write the script**
+
+`desktop/modules/ai/ai-state.sh`:
+
+```bash
+#!/bin/bash
+#
+# 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.
+#
+# One sweep of the local AI stack for the drawer's AI page. Always prints
+# these six lines, tab separated, and exits 0:
+#
+# engine<TAB>llama<TAB>up|down<TAB>idle|busy<TAB>model
+# engine<TAB>sd<TAB>up|down<TAB>idle|busy<TAB>model
+# app<TAB>assistant<TAB>running|stopped<TAB>-
+# app<TAB>chat<TAB>running|stopped<TAB>-
+# app<TAB>imggen<TAB>running|stopped<TAB>model
+# app<TAB>fanfic<TAB>running|stopped<TAB>pid
+#
+# An empty value is "-". A probe that fails reads as down or stopped, not as
+# an error: every row is something that may simply not be running.
+#
+# The test stubs curl, pgrep, llamachat and imggen on PATH and points
+# AI_ASSISTANT and AI_SD_LOG at fixtures; see test-ai-state.sh.
+
+set -u
+
+LLAMA="${AI_LLAMA_URL:-http://127.0.0.1:8181}"
+SD_LOG="${AI_SD_LOG:-${XDG_CACHE_HOME:-$HOME/.cache}/sd-server.log}"
+ASSISTANT="${AI_ASSISTANT:-$HOME/Programming/GIT/desktop-assistant/assistant.sh}"
+
+row() { local IFS=$'\t'; printf '%s\n' "$*"; }
+
+llama() {
+ local models id busy=idle
+ models="$(curl -sf -m 2 "$LLAMA/models")" || { row engine llama down idle -; return; }
+ id="$(jq -r 'first(.data[] | select(.status.value == "loaded") | .id) // empty' \
+ <<<"$models" 2>/dev/null)"
+ # autoload=false is not optional: without it this probe loads an unloaded
+ # model into VRAM, and the model can unload between the two requests.
+ if [[ -n "$id" ]] &&
+ curl -sf -m 2 -G --data-urlencode "model=$id" -d autoload=false "$LLAMA/slots" |
+ jq -e 'any(.[]; .is_processing)' >/dev/null 2>&1; then
+ busy=busy
+ fi
+ row engine llama up "$busy" "${id:--}"
+}
+
+sd() {
+ local line words i model=- busy=idle mtime
+ line="$(pgrep -axo sd-server)" || { row engine sd down idle -; return; }
+ read -ra words <<<"$line"
+ for ((i = 1; i < ${#words[@]} - 1; i++)); do
+ case "${words[i]}" in
+ --diffusion-model|-m) model="${words[i + 1]##*/}" ;;
+ esac
+ done
+ # ponytail: sd-server has no progress endpoint, but its log streams
+ # progress bars while generating, so a log written in the last 5s means
+ # busy. A step slower than 5s reads as idle.
+ mtime="$(stat -c %Y "$SD_LOG" 2>/dev/null)" || mtime=0
+ (( $(date +%s) - mtime < 5 )) && busy=busy
+ row engine sd up "$busy" "$model"
+}
+
+apps() {
+ local out pid
+
+ if "$ASSISTANT" status >/dev/null 2>&1; then
+ row app assistant running -
+ else
+ row app assistant stopped -
+ fi
+
+ if llamachat --ping >/dev/null 2>&1; then
+ row app chat running -
+ else
+ row app chat stopped -
+ fi
+
+ # imggen status exits 0 either way: the daemon's JSON when it is up,
+ # "daemon down" when it is not.
+ out="$(imggen status 2>/dev/null)"
+ if [[ "$out" == "{"* ]]; then
+ row app imggen running "$(jq -r '.model // "-"' <<<"$out" 2>/dev/null || echo -)"
+ else
+ row app imggen stopped -
+ fi
+
+ # It runs through env, so the command line is python3 then the script,
+ # by path from ~/bin or by ./fanfictioner from the repo.
+ if pid="$(pgrep -u "$USER" -fo '^[^ ]*python3[^ ]* [^ ]*fanfictioner( |$)')"; then
+ row app fanfic running "$pid"
+ else
+ row app fanfic stopped -
+ fi
+}
+
+llama
+sd
+apps
+exit 0
+```
+
+Then `chmod +x desktop/modules/ai/ai-state.sh`.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `desktop/modules/ai/test-ai-state.sh`
+Expected: `8 passed, 0 failed`, exit 0.
+
+- [ ] **Step 5: Check the fanfictioner pattern against both real launch forms**
+
+The stub pgrep does not exercise the regex, so check it with grep (same ERE dialect):
+
+```bash
+re='^[^ ]*python3[^ ]* [^ ]*fanfictioner( |$)'
+printf '%s\n' 'python3 /home/you/bin/fanfictioner story.txt' \
+ 'python3 ./fanfictioner --base krea2 story.txt' \
+ 'vim fanfictioner' \
+ 'python3 /home/you/bin/fanfictioner-old x' | grep -cE "$re"
+```
+Expected: `2`.
+
+- [ ] **Step 6: Run against the live stack, read-only**
+
+Run: `desktop/modules/ai/ai-state.sh`
+Expected: six lines. llama `up`, model whatever `curl -s localhost:8181/models` reports loaded (or `-`). Then confirm the probe loaded nothing:
+`curl -s localhost:8181/models | jq -r '.data[] | .id + " " + .status.value'` shows the same loaded set as before the run.
+
+- [ ] **Step 7: Fix the spec wording**
+
+In `docs/superpowers/specs/2026-10-05-ai-module-design.md`, the fanfictioner table row's status cell, replace
+`` `pgrep -u $USER -f` on the script path `` with
+`` `pgrep -u $USER -f` on `python3 .../fanfictioner` ``
+(it runs from a `~/bin` symlink or the repo, so no single path matches), and in the `ai-state.sh` bullet replace
+`Paths to `assistant.sh` and the fanfictioner script default to` with `The path to `assistant.sh` defaults to`, and `are overridable` with `is overridable`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/modules/ai/ai-state.sh desktop/modules/ai/test-ai-state.sh docs/superpowers/specs/2026-10-05-ai-module-design.md
+git commit -F - <<'EOF'
+feat(ai): state sweep for the AI stack
+
+Six TSV lines per sweep: llama and sd engines, four apps. llama's slot
+probe passes autoload=false, since a plain /slots?model= loads the model
+into VRAM. sd-server has no progress endpoint, so busy is its log
+having been written in the last 5s.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 2: the module, tile, row and page
+
+**Files:**
+- Create: `desktop/modules/ai/AiModule.qml`
+- Create: `desktop/modules/ai/AiTile.qml`
+- Create: `desktop/modules/ai/AiRow.qml`
+- Create: `desktop/modules/ai/AiPage.qml`
+- Modify: `desktop/shell.qml` (import and grid list)
+
+No unit test: QML here has no harness. Verification is the running shell's log plus the user's eyes (Step 7).
+
+- [ ] **Step 1: `AiModule.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+
+// Not always active: the stack is polled while the drawer is open and not
+// otherwise. The poll's lifetime is the tile's lifetime; see AiTile.qml.
+Module {
+ id: mod
+
+ name: "ai"
+ label: "AI"
+ icon: ""
+ alwaysActive: false
+
+ // Rebuilt from ai-state.sh on every poll, in the script's order. See the
+ // script for the line protocol; the field order here must match it.
+ property var engines: []
+ property var apps: []
+
+ readonly property var runningApps: apps.filter(a => a.running)
+
+ // Something is holding VRAM: a loaded llama model, a running sd-server,
+ // or an app.
+ active: runningApps.length > 0
+ || engines.some(e => e.up && (e.name === "sd" || e.model !== ""))
+
+ readonly property var labels: ({
+ llama: "llama.cpp",
+ sd: "stable-diffusion.cpp",
+ assistant: "Desktop assistant",
+ chat: "Local chat",
+ imggen: "imggen",
+ fanfic: "Fanfictioner",
+ })
+
+ readonly property string assistantSh:
+ Quickshell.env("HOME") + "/Programming/GIT/desktop-assistant/assistant.sh"
+
+ // Each project's own control. Fanfictioner has no start, a run needs a
+ // story file, and its stop needs the pid, see stop().
+ readonly property var controls: ({
+ assistant: { start: [assistantSh, "start"], stop: [assistantSh, "stop"] },
+ chat: { start: ["llamachat", "--daemon"], stop: ["llamachat", "--quit"] },
+ imggen: { start: ["imggen", "start"], stop: ["imggen", "stop"] },
+ })
+
+ function canStart(app) { return controls[app.name] !== undefined; }
+
+ function start(app) {
+ if (canStart(app)) exec(controls[app.name].start);
+ }
+
+ function stop(app) {
+ if (app.name !== "fanfic") {
+ exec(controls[app.name].stop);
+ return;
+ }
+ // The sd-cli children first, then the script, by pid only: a name
+ // match would take unrelated sd-cli and python processes with it.
+ if (!/^[0-9]+$/.test(app.detail)) return;
+ exec(["sh", "-c", 'pkill -TERM -P "$1"; kill -TERM "$1"', "sh", app.detail]);
+ }
+
+ // Detached, so a start outlives a quickshell reload. No exit code comes
+ // back; the next poll shows whether it worked.
+ function exec(cmd) {
+ Quickshell.execDetached(cmd);
+ repoll.restart();
+ }
+
+ property Timer repoll: Timer {
+ interval: 1000
+ onTriggered: mod.refresh()
+ }
+
+ // The tile is created when the drawer panel loads, grid or page, and
+ // destroyed when it unloads, so its presence is the poll's on/off switch.
+ property bool polling: false
+ onPollingChanged: if (polling) refresh()
+
+ function refresh() {
+ stateProc.engines = [];
+ stateProc.apps = [];
+ stateProc.running = false;
+ stateProc.running = true;
+ }
+
+ property Process stateProc: Process {
+ property var engines: []
+ property var apps: []
+ command: ["bash", `${Quickshell.shellDir}/modules/ai/ai-state.sh`]
+ stdout: SplitParser {
+ onRead: line => {
+ const f = line.split("\t");
+ const v = s => s === "-" ? "" : s;
+ if (f[0] === "engine" && f.length === 5)
+ stateProc.engines.push({
+ name: f[1], up: f[2] === "up", busy: f[3] === "busy", model: v(f[4]),
+ });
+ else if (f[0] === "app" && f.length === 4)
+ stateProc.apps.push({
+ name: f[1], running: f[2] === "running", detail: v(f[3]),
+ });
+ }
+ }
+ // A run that printed nothing (bash itself failing) keeps the last
+ // state rather than blanking the page.
+ onExited: {
+ if (stateProc.engines.length + stateProc.apps.length === 0) return;
+ mod.engines = stateProc.engines;
+ mod.apps = stateProc.apps;
+ }
+ }
+
+ property Timer pollTimer: Timer {
+ interval: 3000
+ repeat: true
+ running: mod.polling
+ onTriggered: mod.refresh()
+ }
+
+ tileContent: Component { AiTile { ai: mod } }
+
+ page: Component {
+ Page {
+ title: "AI"
+ AiPage { width: parent.width; ai: mod }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: `AiTile.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The state line under the tile label, and the poll's lifetime: the tile
+// exists exactly while the drawer panel is loaded, so it turns polling on and
+// off.
+Item {
+ id: tile
+
+ required property var ai
+
+ width: parent ? parent.width : implicitWidth
+ implicitHeight: line.implicitHeight
+
+ Component.onCompleted: ai.polling = true
+ Component.onDestruction: ai.polling = false
+
+ Text {
+ id: line
+ width: parent.width
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: tile.ai.active ? Theme.text : Theme.subtext
+ text: {
+ const parts = [];
+ const n = tile.ai.runningApps.length;
+ if (n > 0) parts.push(n === 1 ? "1 app" : n + " apps");
+ const busy = tile.ai.engines.filter(e => e.busy).map(e => e.name);
+ if (busy.length > 0) parts.push(busy.join(", ") + " busy");
+ return parts.length > 0 ? parts.join(" · ") : "Idle";
+ }
+ }
+}
+```
+
+- [ ] **Step 3: `AiRow.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// One engine or app: a state dot, a name, a detail line, and at most one
+// button. An empty action means no button, which is every engine row.
+Rectangle {
+ id: row
+
+ property string title
+ property string detail
+ property bool on: false
+ property bool busy: false
+ property string action: ""
+ property bool danger: false
+ signal triggered
+
+ implicitHeight: Math.max(texts.implicitHeight, btn.visible ? btn.implicitHeight : 0) + 16
+ radius: 8
+ color: row.on ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35)
+
+ Rectangle {
+ id: dot
+ width: 8; height: 8; radius: 4
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ color: row.busy ? Theme.yellow : row.on ? Theme.green : Theme.subtext
+ }
+
+ Column {
+ id: texts
+ anchors {
+ left: dot.right; leftMargin: 10
+ right: btn.visible ? btn.left : parent.right; rightMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: row.title
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.on }
+ color: row.on ? Theme.text : Theme.subtext
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: row.detail
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ }
+
+ Button {
+ id: btn
+ anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ visible: row.action !== ""
+ text: row.action
+ danger: row.danger
+ onClicked: row.triggered()
+ }
+}
+```
+
+- [ ] **Step 4: `AiPage.qml`**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// Engines are read-only: stopping either would take its web UI down with it.
+Column {
+ id: page
+
+ required property var ai
+
+ spacing: 8
+
+ Text {
+ width: page.width
+ text: "Engines"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: page.ai.engines
+
+ AiRow {
+ required property var modelData
+ width: page.width
+ title: page.ai.labels[modelData.name] ?? modelData.name
+ on: modelData.up
+ busy: modelData.busy
+ detail: {
+ if (!modelData.up) return "Not running";
+ if (modelData.model === "")
+ return modelData.name === "llama" ? "No model loaded" : "Running";
+ return (modelData.busy ? "Generating" : "Idle") + " · " + modelData.model;
+ }
+ }
+ }
+
+ Item { width: 1; height: 6 }
+
+ Text {
+ width: page.width
+ text: "Apps"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: page.ai.apps
+
+ AiRow {
+ required property var modelData
+ width: page.width
+ title: page.ai.labels[modelData.name] ?? modelData.name
+ on: modelData.running
+ detail: {
+ if (!modelData.running) return "Stopped";
+ if (modelData.detail === "") return "Running";
+ return "Running · " + (modelData.name === "fanfic" ? "pid " : "") + modelData.detail;
+ }
+ action: modelData.running ? "Stop" : page.ai.canStart(modelData) ? "Start" : ""
+ danger: modelData.running
+ onTriggered: modelData.running ? page.ai.stop(modelData) : page.ai.start(modelData)
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Register in `desktop/shell.qml`**
+
+Add the import in alphabetical position, before `import "modules/appearance"`:
+
+```qml
+import "modules/ai"
+```
+
+and append to the `modules:` list, after `VmModule {},`:
+
+```qml
+ AiModule {},
+```
+
+Editing `shell.qml` also forces the running shell to rescan the new directory (AGENTS.md, Reloading).
+
+- [ ] **Step 6: Check the running shell's log**
+
+The user's desktop shell hot-reloads on save. Run:
+`qs log -t 40` with the desktop instance selected (see `qs log --help` for the instance flag; `qs list` names the running instances).
+Expected: a reload line after the edit, no `ReferenceError`, `TypeError` or `is not a type` mentioning `modules/ai`. The module's code only runs once the drawer opens, so a clean log here is necessary, not sufficient (AGENTS.md: the `mod: mod` trap passed a clean smoke check).
+
+Then: `qs -p desktop ipc call drawer open ai` (reaches the running instance, starts nothing), and read the log again for runtime errors.
+
+- [ ] **Step 7: Ask the user to look**
+
+Ask the user to confirm, with the drawer open on the AI page:
+1. The tile glyph (``, nf-fa-robot) reads as a robot.
+2. Engines show llama's state and model; sd shows Not running unless `sd-webui` is up.
+3. Stop on local chat stops it and the row flips within ~3s; Start brings it back.
+
+Fix whatever they report before committing.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/modules/ai/AiModule.qml desktop/modules/ai/AiTile.qml desktop/modules/ai/AiRow.qml desktop/modules/ai/AiPage.qml desktop/shell.qml
+git commit -F - <<'EOF'
+feat(ai): drawer page for the local AI stack
+
+Engines are read-only, since stopping llama-server or sd-server takes
+its web UI down too. Apps start and stop through their own controls,
+detached so a start outlives a quickshell reload. Fanfictioner has no
+start (a run needs a story) and stops by pid, children first, so its
+sd-cli runs go with it and nothing else does.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 3: documentation
+
+**Files:**
+- Create: `desktop/modules/ai/README.md`
+- Modify: `desktop/README.md` (module list, and the "each carry their own README" sentence)
+
+- [ ] **Step 1: `desktop/modules/ai/README.md`**
+
+```markdown
+# ai
+
+What is using the local AI stack, and start/stop for the projects built on it.
+
+The tile counts running apps and names any engine that is generating, or says
+`Idle`. The page lists the two engines, then the apps.
+
+## Engines
+
+llama.cpp (`llama-server`, router mode, port 8181) and stable-diffusion.cpp
+(`sd-server`, started by `~/bin/sd-webui`). Both rows are read-only: each
+engine also serves a web UI, and stopping it would take that down.
+
+llama's state comes from its API: `/models` for the loaded model, then
+`/slots?model=<id>&autoload=false` for whether a slot is processing. The
+`autoload=false` matters: a plain `/slots?model=` on an unloaded model loads
+it into VRAM, which is how this was found.
+
+sd-server has no progress endpoint. Its log, `~/.cache/sd-server.log`, streams
+progress bars while it generates, so the row reads busy while the log was
+written in the last 5s. A step slower than that reads idle.
+
+## Apps
+
+| App | Status | Start | Stop |
+| --- | --- | --- | --- |
+| desktop-assistant | `assistant.sh status` | `assistant.sh start` | `assistant.sh stop` |
+| local chat | `llamachat --ping` | `llamachat --daemon` | `llamachat --quit` |
+| imggen | `imggen status` | `imggen start` (default model) | `imggen stop` |
+| fanfictioner | `pgrep` on `python3 .../fanfictioner` | none | TERM its children, then it, by pid |
+
+Each runs detached, so no exit code comes back; the next poll shows whether it
+worked. `assistant.sh status` fails when either the assistant or its overlay
+is down, so a dead overlay reads as stopped and Start restarts it.
+
+## Service lifetime
+
+`alwaysActive: false`. `ai-state.sh` runs every 3s while the drawer is open,
+gated on the tile's lifetime the same way kdeconnect is, and 1s after any
+action.
+
+## Test
+
+ ./test-ai-state.sh
+
+Stubs every external command on PATH; touches nothing live.
+```
+
+- [ ] **Step 2: `desktop/README.md`**
+
+In the module list, after the `modules/appearance/` line, add:
+
+```
+ modules/ai/ local AI engines, start/stop for the apps using them
+```
+
+and change the sentence `Sound, mail, vm, network, bluetooth and kdeconnect each carry their own README.` to `Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/modules/ai/README.md desktop/README.md
+git commit -F - <<'EOF'
+docs(ai): module README, drawer module list
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```