# 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. # # 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. # # 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: # # enginellamaup|downidle|busymodel # enginesdup|downidle|busymodel # appassistantrunning|stopped- # appchatrunning|stopped- # appimggenrunning|stoppedmodel # appfanficrunning|stoppedpid # # 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 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. // // 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. // // 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. // // 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. // // 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 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=&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 EOF ```