aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-08 15:38:01 +0200
committerDanilo M. <danix@danix.xyz>2026-07-08 15:38:01 +0200
commit8f015651b8ffc9dfe1ce149ec7d21c46661ce8e7 (patch)
tree5d04ab5d42cc4bd5aaa67ffeb451a1b1db6618eb /docs/superpowers
parent10018d8db7b92bce496301322a1e6ca9b9ede209 (diff)
downloadimggen-8f015651b8ffc9dfe1ce149ec7d21c46661ce8e7.tar.gz
imggen-8f015651b8ffc9dfe1ce149ec7d21c46661ce8e7.zip
Add implementation plan for model menu + daemon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-07-08-model-menu-daemon.md739
1 files changed, 739 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-08-model-menu-daemon.md b/docs/superpowers/plans/2026-07-08-model-menu-daemon.md
new file mode 100644
index 0000000..614c700
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-model-menu-daemon.md
@@ -0,0 +1,739 @@
+# Model Menu + Persistent Daemon 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:** Add a model menu (3 permissive SDXL models), a persistent HTTP daemon that holds one model resident in XPU VRAM for fast prompt iteration, and docker compose lifecycle, to the local Intel Arc image generator.
+
+**Architecture:** `generate.py` is refactored to expose a model registry and reusable `resolve_model`/`build_pipe`/`generate` functions, keeping its one-shot CLI. A new `server.py` imports that core and serves `/status`, `/generate`, `/model` over stdlib HTTP on localhost. A `compose.yaml` runs the daemon as the host user; the `imggen` wrapper gains `start`/`stop`/`status`/`list` subcommands and routes prompts to the daemon (with a one-shot fallback).
+
+**Tech Stack:** Python 3 stdlib (`http.server`, `json`, `argparse`), diffusers `StableDiffusionXLPipeline` + `AutoencoderKL`, torch XPU, docker compose v2, bash.
+
+---
+
+## File Structure
+
+- `generate.py` (modify) — model registry, `resolve_model`, refactored `build_pipe(key)` with variant fallback, unchanged `generate`, `-m/--model` CLI flag.
+- `server.py` (create) — stdlib HTTP daemon holding one resident pipe; imports from `generate.py`.
+- `compose.yaml` (create) — daemon service, runs as host user, GPU + mounts + port.
+- `.env.example` (create) — documents `.env` shape (real `.env` is gitignored, already handled).
+- `imggen` (modify, in `~/bin/`) — subcommand dispatch + daemon client + one-shot fallback + interactive menu.
+- `test_generate.py` (create) — pure-logic asserts (registry, resolve_model).
+- `README.md` (modify) — new usage, lifecycle, VRAM warning.
+- `CLAUDE.md` (modify) — new architecture, corrected defaults.
+
+Note: `.gitignore` already committed (protects `.env`). HF cache chown + `.env` creation are done by the user, not tasks here.
+
+---
+
+## Task 1: Model registry + resolve_model (pure logic, TDD)
+
+**Files:**
+- Modify: `generate.py:27-30` (replace `MODEL`/`VAE` constants)
+- Test: `test_generate.py` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test_generate.py`:
+
+```python
+import generate
+
+
+def test_registry_integrity():
+ assert generate.DEFAULT_MODEL in generate.MODELS
+ for key, repo in generate.MODELS.items():
+ assert isinstance(key, str) and key
+ assert isinstance(repo, str) and "/" in repo
+
+
+def test_resolve_known_key():
+ assert generate.resolve_model("sdxl") == generate.MODELS["sdxl"]
+ assert generate.resolve_model("realvis") == generate.MODELS["realvis"]
+
+
+def test_resolve_unknown_key_raises_listing_valid():
+ try:
+ generate.resolve_model("nope")
+ except SystemExit as e:
+ assert "sdxl" in str(e) and "realvis" in str(e) and "pony" in str(e)
+ else:
+ assert False, "expected SystemExit for unknown key"
+
+
+if __name__ == "__main__":
+ test_registry_integrity()
+ test_resolve_known_key()
+ test_resolve_unknown_key_raises_listing_valid()
+ print("ok")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 test_generate.py`
+Expected: FAIL — `AttributeError: module 'generate' has no attribute 'MODELS'` (or `resolve_model`).
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `generate.py`, replace lines 27-29 (the `MODEL` and `VAE` constants) with:
+
+```python
+MODELS = {
+ "sdxl": "stabilityai/stable-diffusion-xl-base-1.0", # default, general
+ "realvis": "SG161222/RealVisXL_V5.0", # photoreal, permissive
+ "pony": "AstraliteHeart/pony-diffusion-v6-xl", # unrestricted, versatile
+}
+DEFAULT_MODEL = "sdxl"
+# fp16-fix VAE avoids the fp32 upcast that OOMs a 12GB card at decode time.
+VAE = "madebyollin/sdxl-vae-fp16-fix"
+
+
+def resolve_model(key):
+ if key not in MODELS:
+ valid = ", ".join(sorted(MODELS))
+ raise SystemExit(f"unknown model '{key}'. valid: {valid}")
+ return MODELS[key]
+```
+
+Note: importing `generate.py` currently runs nothing at import time (all logic is under `main()` / `if __name__`), so `import generate` is safe for the test. Confirm no top-level executable code was added.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 test_generate.py`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add generate.py test_generate.py
+git commit -S -m "Add model registry and resolve_model with valid-key errors
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 2: Refactor build_pipe to take a model key with variant fallback
+
+**Files:**
+- Modify: `generate.py:33-53` (`build_pipe`)
+
+This is GPU/download code; no cheap unit test. Verified in Task 7 (integration on real Arc). TDD does not apply to the XPU load itself.
+
+- [ ] **Step 1: Rewrite build_pipe**
+
+Replace `build_pipe` (currently lines 33-53) with:
+
+```python
+def build_pipe(model_key):
+ if not torch.xpu.is_available():
+ # Fail loud and specific. A silent CPU fallback would run but take
+ # minutes per image, which is worse than a clear error.
+ print(
+ "ERROR: torch.xpu is not available. The Level Zero runtime is not "
+ "visible inside this container. Check that you passed --device "
+ "/dev/dri and that the base image ships the Intel GPU runtime.",
+ file=sys.stderr,
+ )
+ sys.exit(2)
+
+ repo = resolve_model(model_key)
+ vae = AutoencoderKL.from_pretrained(VAE, torch_dtype=torch.float16)
+ try:
+ # SDXL base publishes an fp16 variant (smaller download).
+ pipe = StableDiffusionXLPipeline.from_pretrained(
+ repo, vae=vae, torch_dtype=torch.float16,
+ use_safetensors=True, variant="fp16",
+ )
+ except (ValueError, OSError, EnvironmentError):
+ # Many fine-tunes do not publish a separate fp16 variant; load the
+ # default files at fp16 dtype instead.
+ pipe = StableDiffusionXLPipeline.from_pretrained(
+ repo, vae=vae, torch_dtype=torch.float16, use_safetensors=True,
+ )
+ return pipe.to("xpu")
+```
+
+- [ ] **Step 2: Verify it still imports (no GPU needed)**
+
+Run: `python3 -c "import generate; print('import ok')"`
+Expected: `import ok` (import does not touch XPU).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add generate.py
+git commit -S -m "Refactor build_pipe to take a model key with fp16-variant fallback
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 3: Add -m/--model flag to the one-shot CLI
+
+**Files:**
+- Modify: `generate.py` (argparse in `main`, the `build_pipe()` call, the load message)
+
+- [ ] **Step 1: Add the argument**
+
+In `main()`, after the `prompt` positional (currently around line 80), add:
+
+```python
+ ap.add_argument(
+ "-m",
+ "--model",
+ default=DEFAULT_MODEL,
+ help=f"Model key: {', '.join(sorted(MODELS))} (default: {DEFAULT_MODEL}).",
+ )
+```
+
+- [ ] **Step 2: Pass the key into build_pipe and the load message**
+
+Replace the load block (currently lines 133-136):
+
+```python
+ print(f"Loading {args.model} + fp16-fix VAE on XPU ...", file=sys.stderr)
+ t0 = time.time()
+ pipe = build_pipe(args.model)
+ print(f"Loaded in {time.time() - t0:.1f}s", file=sys.stderr)
+```
+
+- [ ] **Step 3: Verify the CLI parses (no GPU)**
+
+Run: `python3 generate.py --help`
+Expected: help text shows `-m/--model` with `sdxl, realvis, pony` and default `sdxl`.
+
+Run: `python3 generate.py "x" -m nope 2>&1 | head -1` is NOT valid here (would hit XPU/build first only after arg parse). Instead verify unknown-key handling stays in `resolve_model` (covered by Task 1). Skip runtime call.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add generate.py
+git commit -S -m "Add -m/--model flag to one-shot CLI
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 4: HTTP daemon (server.py)
+
+**Files:**
+- Create: `server.py`
+
+Holds one resident pipe. Reuses `build_pipe`/`generate`/`resolve_model` from `generate.py`. Serial by construction (`http.server` default), which is required (one GPU).
+
+- [ ] **Step 1: Write server.py**
+
+Create `server.py`:
+
+```python
+#!/usr/bin/env python3
+# 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.
+
+# Long-lived daemon that keeps ONE SDXL model resident in XPU VRAM so prompt
+# iteration skips the model-reload tax. Stdlib HTTP only. Serial by design:
+# one GPU, so concurrent generation would OOM. See README.md.
+
+import io
+import json
+import os
+import sys
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+import torch
+
+import generate
+
+PORT = int(os.environ.get("IMGGEN_PORT", "8765"))
+
+STATE = {"model": None, "pipe": None}
+
+
+def load(model_key):
+ if STATE["model"] == model_key and STATE["pipe"] is not None:
+ return
+ if STATE["pipe"] is not None:
+ del STATE["pipe"]
+ STATE["pipe"] = None
+ torch.xpu.empty_cache()
+ STATE["pipe"] = generate.build_pipe(model_key)
+ STATE["model"] = model_key
+
+
+class Handler(BaseHTTPRequestHandler):
+ def _json(self, code, obj):
+ body = json.dumps(obj).encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_json(self):
+ length = int(self.headers.get("Content-Length", "0"))
+ raw = self.rfile.read(length) if length else b"{}"
+ return json.loads(raw) # raises on bad JSON -> caught by caller
+
+ def do_GET(self):
+ if self.path == "/status":
+ self._json(200, {"model": STATE["model"], "ready": STATE["pipe"] is not None})
+ else:
+ self._json(404, {"error": "not found"})
+
+ def do_POST(self):
+ try:
+ data = self._read_json()
+ except (ValueError, json.JSONDecodeError):
+ self._json(400, {"error": "bad JSON"})
+ return
+
+ if self.path == "/model":
+ name = data.get("name")
+ try:
+ generate.resolve_model(name)
+ except SystemExit as e:
+ self._json(400, {"error": str(e)})
+ return
+ try:
+ load(name)
+ except Exception as e: # download / load failure
+ self._json(500, {"error": f"load failed: {e}"})
+ return
+ self._json(200, {"model": STATE["model"], "ready": True})
+ return
+
+ if self.path == "/generate":
+ try:
+ image, elapsed = generate.generate(
+ STATE["pipe"],
+ data["prompt"],
+ data.get("negative", ""),
+ int(data.get("steps", 25)),
+ float(data.get("guidance", 7.0)),
+ data.get("seed"),
+ int(data.get("width", 1024)),
+ int(data.get("height", 1024)),
+ )
+ except torch.OutOfMemoryError:
+ torch.xpu.empty_cache()
+ self._json(500, {"error": "OOM, try fewer steps or smaller size"})
+ return
+ except Exception as e:
+ self._json(500, {"error": str(e)})
+ return
+ buf = io.BytesIO()
+ image.save(buf, format="PNG")
+ body = buf.getvalue()
+ self.send_response(200)
+ self.send_header("Content-Type", "image/png")
+ self.send_header("X-Elapsed", f"{elapsed:.2f}")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ self._json(404, {"error": "not found"})
+
+ def log_message(self, fmt, *args):
+ sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
+
+
+def main():
+ startup = os.environ.get("IMGGEN_MODEL", generate.DEFAULT_MODEL)
+ print(f"Loading {startup} on XPU ...", file=sys.stderr)
+ load(startup)
+ print(f"Ready on 127.0.0.1:{PORT} with model {startup}", file=sys.stderr)
+ HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+
+
+if __name__ == "__main__":
+ main()
+```
+
+Note: bind `0.0.0.0` inside the container; compose publishes only to
+`127.0.0.1` on the host, so it is not exposed to the network.
+
+- [ ] **Step 2: Verify it imports and parses (no GPU, no serve)**
+
+Run: `python3 -c "import ast; ast.parse(open('server.py').read()); print('parse ok')"`
+Expected: `parse ok`
+
+(Cannot import-run `main()` without XPU; syntax check is the cheap gate. Full run is Task 7.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add server.py
+git commit -S -m "Add persistent HTTP daemon holding one model resident in VRAM
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 5: compose.yaml + .env.example + Dockerfile CMD check
+
+**Files:**
+- Create: `compose.yaml`
+- Create: `.env.example`
+- Read (verify): `Dockerfile` (ensure `server.py` gets copied in; add if missing)
+
+- [ ] **Step 1: Verify the Dockerfile copies server.py**
+
+Run: `grep -nE 'COPY|ADD' Dockerfile`
+Expected: a `COPY` that brings in the app files. If it copies `generate.py`
+explicitly (e.g. `COPY generate.py .`), add a sibling line `COPY server.py .`
+right after it. If it copies the whole context (`COPY . .`), no change needed.
+Make the minimal edit to ensure `server.py` lands in the image working dir.
+
+- [ ] **Step 2: Write compose.yaml**
+
+Create `compose.yaml`:
+
+```yaml
+services:
+ imggen:
+ build: .
+ image: imggen:local
+ user: "${IMGGEN_UID:-1000}:${IMGGEN_GID:-100}"
+ group_add:
+ - video # gid for /dev/dri/card* access as non-root
+ devices:
+ - /dev/dri
+ volumes:
+ - ${IMGGEN_OUT:-./out}:/out
+ - ${IMGGEN_CACHE:-./models}:/models
+ environment:
+ - HF_HOME=/models
+ - IMGGEN_MODEL=${IMGGEN_MODEL:-sdxl}
+ - IMGGEN_PORT=${IMGGEN_PORT:-8765}
+ ports:
+ - "127.0.0.1:${IMGGEN_PORT:-8765}:8765"
+ command: ["python3", "server.py"]
+```
+
+- [ ] **Step 3: Write .env.example**
+
+Create `.env.example`:
+
+```
+# Copy to .env (gitignored) and set your real host paths.
+IMGGEN_OUT=/path/to/output
+IMGGEN_CACHE=/path/to/model-cache
+# IMGGEN_PORT=8765
+# IMGGEN_MODEL=sdxl
+# IMGGEN_UID=1000
+# IMGGEN_GID=100
+```
+
+- [ ] **Step 4: Validate compose config (needs .env present)**
+
+Run: `docker compose config >/dev/null && echo "compose ok"`
+Expected: `compose ok`. If it errors on missing `.env` variables, confirm the
+user's `.env` exists (they created it); the `:-` defaults cover the rest.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add compose.yaml .env.example Dockerfile
+git commit -S -m "Add compose service (runs as host user) and .env.example
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 6: Rewrite the imggen wrapper (subcommands + daemon client + fallback)
+
+**Files:**
+- Modify: `~/bin/imggen` (and keep the repo copy `./imggen` in sync — copy after)
+
+The wrapper must `cd` to the repo dir so `docker compose` finds `compose.yaml`
+and `.env`. Set `IMGGEN_DIR` to the repo path.
+
+- [ ] **Step 1: Write the new wrapper**
+
+Replace `~/bin/imggen` with:
+
+```bash
+#!/bin/bash
+# Copyright (C) 2026 Danilo
+# Licensed under the GNU General Public License version 2.
+#
+# Host-side wrapper for local SDXL image generation on Intel Arc.
+# Subcommands manage a persistent daemon (model resident in VRAM); a bare
+# prompt talks to the daemon if up, else falls back to a one-shot run.
+#
+# Usage:
+# imggen start [-m realvis] # spin up daemon, load model, hold VRAM
+# imggen stop # kill daemon, free VRAM
+# imggen status # is it up? which model?
+# imggen list # show model keys
+# imggen "a red bicycle" [-m realvis] [--steps 30] [--seed 42] [-o out.png] [-n "blurry"]
+
+set -euo pipefail
+
+IMGGEN_DIR="${IMGGEN_DIR:-$HOME/Programming/GIT/imggen}"
+cd "$IMGGEN_DIR"
+
+# Load .env for OUT dir + port on the host side.
+if [ -f .env ]; then set -a; . ./.env; set +a; fi
+
+OUT_DIR="${IMGGEN_OUT:-$HOME/Pictures/AI-gen}"
+PORT="${IMGGEN_PORT:-8765}"
+BASE="http://127.0.0.1:${PORT}"
+mkdir -p "$OUT_DIR"
+
+usage() {
+ echo "usage:" >&2
+ echo " imggen start [-m KEY] | stop | status | list" >&2
+ echo " imggen \"<prompt>\" [-m KEY] [--steps N] [--guidance G] [--seed S] [-n NEG] [-o name.png]" >&2
+ exit 1
+}
+
+daemon_up() { curl -sf "${BASE}/status" >/dev/null 2>&1; }
+
+case "${1:-}" in
+ start)
+ shift
+ model="${IMGGEN_MODEL:-sdxl}"
+ if [ "${1:-}" = "-m" ] || [ "${1:-}" = "--model" ]; then model="$2"; fi
+ IMGGEN_MODEL="$model" docker compose up -d
+ echo "daemon starting with model '$model' (VRAM ~9GB held until 'imggen stop')"
+ exit 0
+ ;;
+ stop)
+ docker compose down
+ echo "daemon stopped, VRAM freed"
+ exit 0
+ ;;
+ status)
+ if daemon_up; then curl -s "${BASE}/status"; echo; else echo "daemon down"; fi
+ exit 0
+ ;;
+ list)
+ echo "sdxl general (default)"
+ echo "realvis photoreal, permissive"
+ echo "pony unrestricted, versatile"
+ exit 0
+ ;;
+ "" ) usage ;;
+ -h|--help) usage ;;
+esac
+
+# --- prompt path ---
+PROMPT="$1"; shift
+MODEL=""; NEG=""; STEPS=""; GUID=""; SEED=""; OUT=""; W=""; H=""
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -m|--model) MODEL="$2"; shift 2 ;;
+ -n|--negative) NEG="$2"; shift 2 ;;
+ -s|--steps) STEPS="$2"; shift 2 ;;
+ -g|--guidance) GUID="$2"; shift 2 ;;
+ --seed) SEED="$2"; shift 2 ;;
+ -W|--width) W="$2"; shift 2 ;;
+ -H|--height) H="$2"; shift 2 ;;
+ -o|--out) OUT="$2"; shift 2 ;;
+ *) echo "unknown arg: $1" >&2; usage ;;
+ esac
+done
+
+OUT_NAME="${OUT:-sdxl-$(date +%Y%m%d-%H%M%S).png}"
+OUT_PATH="${OUT_DIR}/${OUT_NAME}"
+
+if daemon_up; then
+ # Interactive menu only when no -m and a TTY is present.
+ if [ -z "$MODEL" ] && [ -t 0 ] && [ -t 1 ]; then
+ echo "which model? 1) sdxl 2) realvis 3) pony" >&2
+ read -rp "choice [1]: " choice
+ case "$choice" in 2) MODEL=realvis ;; 3) MODEL=pony ;; *) MODEL=sdxl ;; esac
+ fi
+ MODEL="${MODEL:-sdxl}"
+
+ # Swap resident model if needed.
+ current="$(curl -s "${BASE}/status" | sed -n 's/.*"model": *"\([^"]*\)".*/\1/p')"
+ if [ "$current" != "$MODEL" ]; then
+ echo "swapping daemon model $current -> $MODEL ..." >&2
+ curl -sf -X POST "${BASE}/model" -H 'Content-Type: application/json' \
+ -d "{\"name\":\"$MODEL\"}" >/dev/null
+ fi
+
+ # Build JSON request. jq not assumed; hand-build (values are ours/simple).
+ req="{\"prompt\":$(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
+ [ -n "$NEG" ] && req="$req,\"negative\":$(printf '%s' "$NEG" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
+ [ -n "$STEPS" ] && req="$req,\"steps\":$STEPS"
+ [ -n "$GUID" ] && req="$req,\"guidance\":$GUID"
+ [ -n "$SEED" ] && req="$req,\"seed\":$SEED"
+ [ -n "$W" ] && req="$req,\"width\":$W"
+ [ -n "$H" ] && req="$req,\"height\":$H"
+ req="$req}"
+
+ if curl -sf -X POST "${BASE}/generate" -H 'Content-Type: application/json' \
+ -d "$req" -o "$OUT_PATH"; then
+ echo "Saved ${OUT_PATH}"
+ else
+ echo "generate failed (see daemon logs: docker compose logs)" >&2
+ rm -f "$OUT_PATH"
+ exit 1
+ fi
+else
+ # One-shot fallback: non-interactive, model defaults to sdxl or -m KEY.
+ echo "daemon down, one-shot run (slower: model loads then exits)" >&2
+ set -- "$PROMPT"
+ [ -n "$MODEL" ] && set -- "$@" -m "$MODEL"
+ [ -n "$NEG" ] && set -- "$@" -n "$NEG"
+ [ -n "$STEPS" ] && set -- "$@" -s "$STEPS"
+ [ -n "$GUID" ] && set -- "$@" -g "$GUID"
+ [ -n "$SEED" ] && set -- "$@" --seed "$SEED"
+ [ -n "$W" ] && set -- "$@" -W "$W"
+ [ -n "$H" ] && set -- "$@" -H "$H"
+ set -- "$@" -o "$OUT_NAME"
+ docker compose run --rm imggen python3 generate.py "$@"
+fi
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `bash -n ~/bin/imggen && echo "syntax ok"`
+Expected: `syntax ok`
+
+- [ ] **Step 3: Test subcommand routing without a daemon (no GPU)**
+
+Run: `~/bin/imggen list`
+Expected: three lines (sdxl / realvis / pony).
+
+Run: `~/bin/imggen status`
+Expected: `daemon down` (daemon not started yet).
+
+- [ ] **Step 4: Sync repo copy and commit**
+
+```bash
+cp ~/bin/imggen ./imggen
+git add imggen
+git commit -S -m "Rewrite wrapper: daemon lifecycle, client, and one-shot fallback
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 7: Integration on real Arc (agent runs on this machine)
+
+**Files:** none (verification task). Prereqs done by user: HF cache chown to
+`danix:users`, `.env` created with real paths.
+
+- [ ] **Step 1: Rebuild the image with server.py**
+
+Run: `cd "$IMGGEN_DIR" && docker compose build`
+Expected: builds `imggen:local`, no error, `server.py` present in image.
+
+- [ ] **Step 2: One-shot SDXL still works (cached weights)**
+
+Run: `~/bin/imggen "a red bicycle against a stone wall" -o test-oneshot.png`
+Expected: daemon down → one-shot path → `Saved .../test-oneshot.png`.
+Verify: `file "$HOME/Pictures/AI-gen/test-oneshot.png"` shows PNG 1024x1024,
+and `ls -l` shows owner `danix:users` (permissions fix works).
+
+- [ ] **Step 3: Daemon lifecycle on SDXL**
+
+Run: `~/bin/imggen start`
+Then: `~/bin/imggen status`
+Expected: `{"model": "sdxl", "ready": true}`.
+
+Run: `~/bin/imggen "a foggy harbour at dawn" -m sdxl -o test-daemon.png`
+Expected: no reload message, `Saved .../test-daemon.png`, owner `danix:users`.
+
+- [ ] **Step 4: Model download + swap (realvis, then pony) — approved pulls**
+
+Run: `~/bin/imggen "a portrait of a fox, photoreal" -m realvis -o test-realvis.png`
+Expected: `swapping daemon model sdxl -> realvis`, ~7GB downloads once to
+`/data/LLM-models/imggen-models`, then `Saved`. If load fails with a
+diffusers-format / variant error, the fp16-variant fallback (Task 2) should
+handle the missing-variant case; a single-file-only repo would need
+`from_single_file` — record the exact error and stop for a fix decision.
+
+Run: `~/bin/imggen "anime character" -m pony -o test-pony.png`
+Expected: swaps to pony, downloads once, `Saved`. Same fallback caveat.
+
+- [ ] **Step 5: Stop frees VRAM**
+
+Run: `~/bin/imggen stop`
+Then: `~/bin/imggen status`
+Expected: `daemon down`. (Optionally confirm VRAM freed via `xpu-smi` if
+available; otherwise container-gone is the signal.)
+
+- [ ] **Step 6: Commit any fixes made during integration**
+
+If Task 2/4 needed adjustment (e.g. single-file load path for pony), commit:
+
+```bash
+git add -A
+git commit -S -m "Fix model load path discovered during Arc integration
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+If nothing changed, skip.
+
+---
+
+## Task 8: Update README.md and CLAUDE.md
+
+**Files:**
+- Modify: `README.md`
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 1: Update README.md**
+
+Add a "Models" section (the three keys + descriptions), a "Daemon / lifecycle"
+section documenting `start`/`stop`/`status`/`list`, the VRAM-locked-while-up
+warning (~9GB held until `stop`), the one-shot fallback behavior, and `.env`
+setup (copy `.env.example` → `.env`, set real paths, `IMGGEN_UID`/`GID`).
+Keep the existing "Development Approach" section at the bottom verbatim.
+
+- [ ] **Step 2: Update CLAUDE.md**
+
+Update to reflect: 3 files → 5 (`imggen`, `generate.py`, `server.py`,
+`compose.yaml`, `.env.example`); daemon is in scope as local burst-iteration
+(still no refiner/Flux/batching); compose replaces raw `docker run`; the
+registry is the source of truth for models; correct the container-boundary
+defaults to note `.env` overrides and that the real paths live there, not in
+the repo. Keep the 12GB / fp16-fix VAE / XPU-only constraints intact.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md CLAUDE.md
+git commit -S -m "Document model menu, daemon lifecycle, and compose setup
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Self-Review notes
+
+- **Spec coverage:** registry (T1), load robustness/variant fallback (T2),
+ `-m` CLI (T3), daemon API status/generate/model (T4), compose + user perms +
+ .env.example (T5), wrapper subcommands/menu/fallback (T6), integration incl.
+ approved pulls + perms check (T7), README + CLAUDE.md (T8). All spec sections
+ mapped.
+- **Type consistency:** `build_pipe(model_key)`, `resolve_model(key)`,
+ `generate(pipe, prompt, negative, steps, guidance, seed, width, height)`
+ used identically in `generate.py`, `server.py`, and the wrapper's JSON keys
+ (`prompt/negative/steps/guidance/seed/width/height`).
+- **Placeholders:** none; every code step has full code. Docs tasks (T8)
+ describe exact sections to write, not "TODO".
+- **Known open risk:** pony/realvis single-file-vs-diffusers load path is only
+ resolvable at download time (T7 step 4), with a defined fallback and a
+ stop-for-decision if it is single-file-only.