diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-07-08-model-menu-daemon.md | 739 |
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. |
