cc-agents-iterm-plugin/docs/superpowers/plans/2026-10-18-headless-panel.md
Andrey Epifancev ee6d922636 plan: headless panel implementation plan
Refs #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:37:26 +04:00

267 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Headless Panel 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:** Сервер панели «Claude» работает как фоновая служба launchd без iTerm2, iTerm2 только регистрирует web view, а фон панели настраивается на клиенте шестерёнкой ⚙.
**Architecture:** `files/claude_agents.py` получает два режима одного файла: `--headless` (HTTP-сервер, дерево сессий по PID/tmux, лимиты, баланс) и регистратор Toolbelt (запуск iTerm2 из AutoLaunch). Вся связь с живым iTerm2 (тема профиля, вкладки, split) удаляется. LaunchAgent описывается генератором `files/launchagent.py` и ставится `install.sh`. Фон выбирается в браузере, хранится в `localStorage`, производные цвета считает клиентский JS.
**Tech Stack:** Python 3.8+ (stdlib; тесты — `unittest`), POSIX shell / bash, launchd (`launchctl`), HTML/CSS/JS внутри `render_page`, `node` — только для теста клиентского JS.
**Spec:** `openspec/changes/headless-panel/` — `proposal.md`, `design.md`, `specs/panel-service/spec.md`, `specs/panel-appearance/spec.md`.
**Issue / branch:** #4, `feat/4-headless-panel`, worktree `/Users/andrey.epifancev/projects/cc-agents-iterm-plugin/.claude/worktrees/headless-panel`.
## Global Constraints
- Рантайм — только стандартная библиотека Python 3.8+; модуль `iterm2` импортируется опционально (уже так) и нужен только регистратору.
- Тесты — `unittest` из stdlib, без pytest и pip: `python3 -m unittest discover -s tests -v`. Ни один тест не ходит в сеть, не трогает launchd, Keychain, реальный `~/.claude` и реальный `~/Library`.
- Сервер слушает только `127.0.0.1`; порт по умолчанию `8766` (ключ `port` в `config.json`).
- Метка службы `com.andrey.claude-agents` (та же строка — `identifier` web view); лог службы `~/Library/Logs/claude-agents/panel.log`.
- Тема по умолчанию: `bg #002b36`, `fg #aacace`, `dim #668a91`, `green #859900`, `yellow #b58900`, `red #dc322f`, шрифт `'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace`, размер `13`.
- Образцы фона: `#002b36`, `#1e1e1e`, `#000000`, `#2b2b3a`, `#fdf6e3`; плюс «свой цвет» (`<input type="color">`) и «сброс».
- `install.sh` и `uninstall.sh` отказываются работать, если задан `SSH_CONNECTION` или `SSH_TTY`, ничего не меняя.
- Хук `files/agents_panel_hook.sh` и `files/settings_hooks.py` не меняются.
- Тексты для пользователя — на русском, в стиле существующих (`ok`/`warn`/`die` в установщике, короткие подписи в панели).
## Review Focus
- Служба стартовала, а токена нет / Keychain заблокирован → страница отвечает 200, в блоке лимитов текст ошибки, процесс не падает (тест в Task 2: `test_serve_survives_usage_failure`).
- Порт уже занят чужим процессом при старте службы → `serve()` падает с `OSError`, launchd перезапускает позже; второй сервер не появляется (тест в Task 2: `test_serve_busy_port_raises`).
- Мусор в `localStorage` или хранилище недоступно (приватный режим) → тема по умолчанию, без ошибки JS (тест в Task 5: `panelColors('nope') === null` + `try/catch` вокруг чтения).
- Блок лимитов перерисовывается раз в 15 с → раскрытая строка образцов и ⚙ не исчезают (тесты в Task 5: `gear-slot` в `.foot`, статичный `#bgctl` вне `#limits`).
- Путь к скрипту с пробелами (`Application Support`) в LaunchAgent → передаётся одним аргументом (тест в Task 3: `test_cli_writes_plist_with_spaces`).
---
### Task 1: Тестовая основа и удаление связи с живым iTerm2
**Files:**
- Create: `tests/test_core.py`
- Modify: `files/claude_agents.py` (`CONFIG_KEYS` ~73–91, `DEFAULT_THEME` ~115–117, `state`/`_app` ~119–123, `live_sessions` ~1253–1270, `open_worktree` ~1288–1324, `activate` ~1342–1362, `refresher` ~1365–1384, `_hex`/`_mix`/`theme_poller` ~1396–1438)
- Modify: `files/config.example.json` (убрать `use_profile_theme`, `theme_variant`)
- Modify: `.gitignore` (добавить `__pycache__/`)
**Interfaces:**
- Produces: `async def refresher() -> None` (без аргумента; дерево строится как `build_tree(sessions, None, procs, tmux)`); `activate(uuid)` и `open_worktree(uuid, path)` — сигнатуры прежние, для целей не `tmux:` ничего не делают. Удалены: `live_sessions`, `theme_poller`, `_hex`, `_mix`, `_app`, `USE_PROFILE_THEME`, `THEME_VARIANT`, ключи `use_profile_theme`/`theme_variant` из `CONFIG_KEYS`, ветки `tmux_twins` в `activate`.
- `build_tree` не меняется.
- [ ] **Step 1: Write the failing tests** в `tests/test_core.py` (в начале — `sys.path.insert(0, <repo>/files)`; `import claude_agents as ca`):
- `test_default_theme_matches_chat_profile`: `ca.DEFAULT_THEME == {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91", "green": "#859900", "yellow": "#b58900", "red": "#dc322f", "font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}`.
- `test_iterm_coupling_removed`: для `name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT", "_app", "_mix", "_hex")` — `assertFalse(hasattr(ca, name))`; `"use_profile_theme"` и `"theme_variant"` не в `ca.CONFIG_KEYS`.
- `test_old_theme_keys_ignored`: временный `config.json` с `{"use_profile_theme": false, "theme_variant": "dark", "poll_sec": 2}`; подменить `ca.CONFIG_PATH`, `ca._config_mtime = None`; `ca.load_config()` → `ca.state.get("config_error") is None`, `ca.POLL_SEC == 2.0`. Восстановить `POLL_SEC` и `CONFIG_PATH` в `tearDown`.
- `test_activate_tmux_target`: `ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]`, `ca._tmux` подменён записью вызовов; `asyncio.run(ca.activate("tmux:/tmp/s|%3"))` → вызовы `[("/tmp/s", "switch-client", "-t", "%3"), ("/tmp/s", "select-window", "-t", "%3"), ("/tmp/s", "select-pane", "-t", "%3")]`.
- `test_activate_non_tmux_is_noop`: дерево с `uuid "w0t0p0:ABC"`; `asyncio.run(ca.activate("w0t0p0:ABC"))` — без исключения, вызовов `_tmux` нет.
- `test_open_worktree_non_tmux_is_noop`: `ca.known_roots` подменён на `lambda: {tmpdir}`; `asyncio.run(ca.open_worktree("w0t0p0:ABC", tmpdir))` — без исключения, вызовов `_tmux` нет.
- `test_open_worktree_tmux_splits`: дерево с `uuid "tmux:/tmp/s|%3"`, `known_roots → {tmpdir}`, `ca.FILE_MANAGER = "lf"`; `_last_open.clear()`; вызов → `[("/tmp/s", "split-window", "-h", "-t", "%3", "-c", tmpdir, "exec lf")]`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m unittest tests.test_core -v`
Expected: FAIL на `test_default_theme_matches_chat_profile`, `test_iterm_coupling_removed`, `test_old_theme_keys_ignored` (ключ всё ещё известен), `test_open_worktree_non_tmux_is_noop` может упасть на `_app`; tmux-тесты могут пройти уже сейчас — это нормально.
- [ ] **Step 3: Implement** в `files/claude_agents.py`: обновить `DEFAULT_THEME` значениями из Global Constraints; удалить перечисленное в Interfaces; `refresher()` без параметра (в прежнем `main` — вызов `refresher()` и без запуска `theme_poller`, чтобы модуль оставался рабочим до Task 2); в `activate` и `open_worktree` оставить только ветку `tmux:` и ранний `return` для остального; убрать из `state` ключ `"live"` и из `/raw` поле `theme_error`. Обновить модульный docstring: установка через `install.sh`, фоновая служба, без упоминания `settings-hooks.json` (закрывает O-1 из issue #3). Из `config.example.json` убрать два ключа. В `.gitignore` добавить строку `__pycache__/`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m unittest tests.test_core -v && python3 -m py_compile files/claude_agents.py`
Expected: все `OK`; `grep -n "iterm2\." files/claude_agents.py` показывает только `async_register_web_view_tool` в `main`/регистраторе и `run_forever`.
- [ ] **Step 5: Commit**
```bash
git add .gitignore tests/test_core.py files/claude_agents.py files/config.example.json
git commit -m "refactor: drop live iTerm2 coupling from panel server (#4)"
```
---
### Task 2: Фоновый режим, регистратор Toolbelt и точка входа
**Files:**
- Create: `tests/test_service.py`
- Modify: `files/claude_agents.py` (`main` ~1487–1506 → `serve`, `register_toolbelt`, `port_alive`, `kickstart_service`, `run`)
**Interfaces:**
- Consumes: `refresher()`, `usage_poller()`, `balance_poller()`, `handle` (Task 1 / существующие).
- Produces:
- `SERVICE_LABEL = "com.andrey.claude-agents"` (модульная константа).
- `async def serve() -> None` — `load_config()`, `asyncio.start_server(handle, "127.0.0.1", PORT)` (ошибка bind → `OSError` наружу), задачи `refresher()`, `usage_poller()`, `balance_poller()`, затем работает до отмены; при отмене закрывает сервер.
- `def port_alive(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool` — удаётся ли TCP-подключение.
- `def kickstart_service() -> None` — `subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{SERVICE_LABEL}"], capture_output=True, timeout=10)`, исключения глотаются.
- `async def register_toolbelt(connection) -> None` — `load_config()`; если `not port_alive(PORT)` → `kickstart_service()`; затем `iterm2.tool.async_register_web_view_tool(connection, display_name="Claude", identifier=SERVICE_LABEL, reveal_if_already_registered=False, url=f"http://127.0.0.1:{PORT}/")`. Сервер не поднимает.
- `def run(argv: list) -> None` — `"--headless" in argv` → `asyncio.run(serve())`; иначе если `iterm2 is not None` → `iterm2.run_forever(register_toolbelt)`; иначе `raise SystemExit("нужен iTerm2 или флаг --headless")`. Блок `if __name__ == "__main__": run(sys.argv[1:])`.
- Функция `main` удаляется.
- [ ] **Step 1: Write the failing tests** в `tests/test_service.py` (подмены через `unittest.mock.patch.object`; свободный порт — `socket.bind(("127.0.0.1", 0))`):
- `test_port_alive`: слушающий сокет на свободном порту → `True`; после закрытия → `False`.
- `test_serve_answers`: подменить `PORT` (свободный), `CONFIG_PATH` (несуществующий), `load_sessions → []`, `fetch_usage → {}`, `fetch_balance → ({}, "test")`; внутри `asyncio.run` запустить `serve()` задачей, дождаться `port_alive` (≤ 3 с); GET `/` (через `loop.run_in_executor` + `urllib.request`) → статус 200, в теле `'<div id="root">'`; GET `/raw` → JSON с ключом `"tree"`; отменить задачу, порт освобождён.
- `test_serve_survives_usage_failure`: как выше, но `fetch_usage` бросает `FileNotFoundError`; GET `/limits` (опрос ≤ 3 с) содержит `"Не найден токен Claude Code"`; GET `/` → 200.
- `test_serve_busy_port_raises`: занять порт слушающим сокетом, `PORT` = он; `with self.assertRaises(OSError): asyncio.run(ca.serve())`.
- `test_register_toolbelt_kickstarts_when_port_dead`: фальшивый `iterm2` (`types.SimpleNamespace(tool=SimpleNamespace(async_register_web_view_tool=<async-запись kwargs>))`), `port_alive → False`, `kickstart_service` — запись; `asyncio.run(ca.register_toolbelt(object()))` → kickstart вызван 1 раз; регистрация с `display_name="Claude"`, `identifier="com.andrey.claude-agents"`, `reveal_if_already_registered=False`, `url="http://127.0.0.1:8766/"` (при `PORT = 8766`).
- `test_register_toolbelt_skips_kickstart_when_alive`: `port_alive → True` → kickstart не вызван, регистрация вызвана.
- `test_kickstart_command`: `subprocess.run` подменён; `ca.kickstart_service()` → argv `["launchctl", "kickstart", f"gui/{os.getuid()}/com.andrey.claude-agents"]`; при `subprocess.run`, бросающем `OSError`, исключения наружу нет.
- `test_run_headless_calls_serve`: `serve` подменён async-функцией с флагом; `ca.run(["--headless"])` → флаг выставлен.
- `test_run_without_iterm_exits`: `iterm2 = None`; `with assertRaises(SystemExit): ca.run([])`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m unittest tests.test_service -v`
Expected: FAIL / ERROR — `AttributeError: ... has no attribute 'serve'` и т.п.
- [ ] **Step 3: Implement** перечисленное в Interfaces в `files/claude_agents.py`, рядом с бывшим `main`; добавить `import sys`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m unittest discover -s tests -v`
Expected: все `OK` (включая Task 1).
- [ ] **Step 5: Commit**
```bash
git add tests/test_service.py files/claude_agents.py
git commit -m "feat: headless panel server and Toolbelt registrar entry points (#4)"
```
---
### Task 3: Генератор LaunchAgent
**Files:**
- Create: `files/launchagent.py`
- Create: `tests/test_launchagent.py`
**Interfaces:**
- Produces:
- `LABEL = "com.andrey.claude-agents"`
- `def plist(python: str, script: str, log: str) -> dict` → `{"Label": LABEL, "ProgramArguments": [python, script, "--headless"], "RunAtLoad": True, "KeepAlive": True, "StandardOutPath": log, "StandardErrorPath": log}`
- CLI: `python3 files/launchagent.py write <plist-path> <python> <script> <log>` — создаёт родительский каталог plist и каталог лога, пишет `plistlib.dump(..., fmt=FMT_XML)` атомарно (`.tmp` + `os.replace`, как `settings_hooks.py`), печатает путь. Неизвестная команда / не те аргументы → usage в stderr, код 2.
- [ ] **Step 1: Write the failing tests** в `tests/test_launchagent.py`:
- `test_label_matches_server`: `launchagent.LABEL == "com.andrey.claude-agents" == claude_agents.SERVICE_LABEL`.
- `test_plist_shape`: `plist("/opt/homebrew/bin/python3", "/x/claude_agents.py", "/l/panel.log")` равен словарю из Interfaces.
- `test_cli_writes_plist_with_spaces`: во временном каталоге `subprocess.run([sys.executable, "files/launchagent.py", "write", f"{tmp}/LaunchAgents/a.plist", "/usr/bin/python3", f"{tmp}/Application Support/claude_agents.py", f"{tmp}/Logs/claude-agents/panel.log"])` → код 0; `plistlib.load` файла равен `plist(...)` с теми же аргументами (путь с пробелами — один элемент `ProgramArguments`); каталог `{tmp}/Logs/claude-agents` существует.
- `test_cli_bad_args`: `[..., "launchagent.py", "write"]` → код 2.
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m unittest tests.test_launchagent -v`
Expected: ERROR `ModuleNotFoundError: No module named 'launchagent'`.
- [ ] **Step 3: Implement `files/launchagent.py`** — docstring и стиль как у `files/settings_hooks.py`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m unittest discover -s tests -v`
Expected: все `OK`.
- [ ] **Step 5: Commit**
```bash
git add files/launchagent.py tests/test_launchagent.py
git commit -m "feat: LaunchAgent plist generator for panel service (#4)"
```
---
### Task 4: Установка и удаление службы, документация
**Files:**
- Create: `tests/test_install.py`
- Modify: `install.sh`, `uninstall.sh`
- Modify: `README.md`, `CLAUDE.md` (разделы «Архитектура», «Команды», «Тесты»), `ARCHITECTURE.md` (схема, таблица модулей, границы)
**Interfaces:**
- Consumes: CLI `files/launchagent.py write` (Task 3); `claude_agents.py --headless` (Task 2).
- Produces (пути, на которые опирается Task 6):
- копия службы: `$HOME/.local/share/claude-agents/claude_agents.py`
- plist: `$HOME/Library/LaunchAgents/com.andrey.claude-agents.plist`
- лог: `$HOME/Library/Logs/claude-agents/panel.log`
- копия регистратора: `$AUTOLAUNCH/claude_agents.py` (как раньше)
- [ ] **Step 1: Write the failing tests** в `tests/test_install.py` (каждый — во временном пустом `HOME`, `env = {**os.environ, "HOME": tmp, "SSH_CONNECTION": "10.0.0.2 50000 10.0.0.1 22"}`; второй вариант — `SSH_TTY="/dev/ttys009"` без `SSH_CONNECTION`):
- `test_install_refuses_over_ssh` (оба варианта): `subprocess.run(["bash", "install.sh"], env=env, capture_output=True, text=True)` → `returncode != 0`, в `stdout + stderr` есть `"за самим Mac"`, `os.listdir(tmp) == []`.
- `test_uninstall_refuses_over_ssh` (оба варианта): то же для `uninstall.sh` и `uninstall.sh --purge`.
- [ ] **Step 2: НЕ запускать тест до охранной проверки.** Без неё `install.sh` даже во временном `HOME` выполнит `lsof … kill` по порту 8766 (остановит настоящую панель) и `defaults write com.googlecode.iterm2`, а `uninstall.sh` — тоже kill по порту. Провал теста здесь не проверяется; вместо этого в Step 4 тест должен пройти, а охрана — стоять первой исполняемой проверкой (ревьюер сверяет по диффу). Реальные `install.sh`/`uninstall.sh` в этой задаче не запускать — это Task 6.
- [ ] **Step 3: Implement**
- Оба скрипта: первой исполняемой проверкой после `set -euo pipefail` и объявления функций вывода — если `[ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ]`, вывести `запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить` и `exit 1` (в `install.sh` через `die`; в `uninstall.sh` добавить такую же `die`).
- `install.sh`, новые переменные: `LABEL=com.andrey.claude-agents`, `SERVICE_DIR="$HOME/.local/share/claude-agents"`, `PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"`, `SERVICE_LOG="$HOME/Library/Logs/claude-agents/panel.log"`, `PY="$(python3 -c 'import sys; print(sys.executable)')"`, `GUI="gui/$(id -u)"`.
- Новая секция «Фоновая служба» после секции «Скрипт iTerm2»: `install -m 644 "$FILES/claude_agents.py" "$SERVICE_DIR/claude_agents.py"`; `python3 "$FILES/launchagent.py" write "$PLIST" "$PY" "$SERVICE_DIR/claude_agents.py" "$SERVICE_LOG"`; `launchctl bootout "$GUI/$LABEL"` (ошибку игнорировать); затем существующий блок «старая копия панели держит порт — останавливаем» (перенести сюда, чтобы он шёл после `bootout`); `launchctl bootstrap "$GUI" "$PLIST"` (ошибка → `die` с путём к логу); ждать до 10 с, пока `curl -s -o /dev/null --max-time 1 http://127.0.0.1:$PORT/` не ответит; ответил → `ok "служба запущена: http://127.0.0.1:$PORT/"`, нет → `warn "служба не ответила — смотрите $SERVICE_LOG"`.
- Секция iTerm2: логика `EnableAPIServer` и запуск скрипта через `osascript` остаются; сообщения — «регистрация в Toolbelt», а не «панель запущена».
- Итоговый текст: упомянуть, что панель работает и без iTerm2, адрес и лог.
- `uninstall.sh`: `launchctl bootout "gui/$(id -u)/com.andrey.claude-agents"` (ошибку игнорировать) **до** блока остановки по порту; удалить plist и `$HOME/.local/share/claude-agents`; `--purge` — ещё и `$HOME/Library/Logs/claude-agents`.
- `README.md`: установка только за самим Mac; в таблице «Что ставится» — строки для копии службы, plist, лога; раздел «С iPad»: Termius → Port Forwarding → Local, local port `8766`, bind `127.0.0.1`, destination `127.0.0.1:8766`, открыть `http://127.0.0.1:8766` (именно `127.0.0.1`, не `localhost`); убрать упоминание темы профиля; «Если что-то не так» — лог службы и `launchctl print gui/$(id -u)/com.andrey.claude-agents`.
- `CLAUDE.md` / `ARCHITECTURE.md`: два процесса → служба launchd (сервер) + регистратор в iTerm2 + хук; команда тестов `python3 -m unittest discover -s tests -v`; убрать упоминания `theme_poller` и `live_sessions`.
- [ ] **Step 4: Run tests and syntax checks**
Run: `python3 -m unittest discover -s tests -v && bash -n install.sh && bash -n uninstall.sh`
Expected: все `OK`, синтаксис без ошибок.
- [ ] **Step 5: Commit**
```bash
git add tests/test_install.py install.sh uninstall.sh README.md CLAUDE.md ARCHITECTURE.md
git commit -m "feat: install panel as launchd service, refuse install over SSH (#4)"
```
---
### Task 5: Фон панели на клиенте
**Files:**
- Create: `tests/test_appearance.py`
- Modify: `files/claude_agents.py` (`render_limits` ~1244–1248, `render_page` ~840–1028)
- Modify: `README.md` (раздел «Работа с панелью»: ⚙)
**Interfaces:**
- Consumes: `DEFAULT_THEME` (Task 1).
- Produces:
- `BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")`
- `LIGHT_BG_TEXT = "#3b4a50"`
- `CLIENT_COLORS_JS: str` — JS-код с чистой функцией `panelColors(bg)` → `{bg, fg, dim}` или `null`, если `bg` не `#rrggbb`. `fg` = `LIGHT_BG_TEXT`, если относительная яркость фона по WCAG > 0.4, иначе `DEFAULT_THEME["fg"]`; `dim` = покомпонентно `round(fg * 0.6 + bg * 0.4)` (та же формула, что давала `#668a91` для профиля), строкой `#rrggbb` в нижнем регистре.
- Ключ `localStorage`: `claude-panel-bg`.
- Разметка: `render_limits` добавляет в строку `.foot` пустой `<span class="gear-slot"></span>`; `render_page` после `#limits` выводит статичный `<div id="bgctl">` с `<span id="gear-home">`, внутри которого лежит `<span id="gear">⚙</span>`, и скрытой строкой `<div id="bgrow">` с образцами `<span class="sw" data-bg="#…">` для каждого `BG_PRESETS`, `<input type="color" id="bg-custom">` и `<span id="bg-reset">сброс</span>`.
- [ ] **Step 1: Write the failing tests** в `tests/test_appearance.py`:
- `test_page_applies_saved_bg_before_body`: `html = ca.render_page()`; `html.index("claude-panel-bg") < html.index("<body>")`; `CLIENT_COLORS_JS` целиком входит в `html`; чтение `localStorage` обёрнуто в `try` (в `html` до `<body>` есть `try {`).
- `test_page_has_bg_controls`: в `html` есть `id="bgctl"`, `id="gear"`, `id="bgrow"`, `type="color"`, `id="bg-reset"`, и для каждого `hex` из `BG_PRESETS` — `data-bg="{hex}"`; `id="bgctl"` идёт после закрытия `#limits` (не внутри перерисовываемого блока).
- `test_limits_foot_has_gear_slot`: `ca.state["usage_updated"] = time.time()`, `usage_error = None` → `'class="gear-slot"'` в `ca.render_limits()`; восстановить `state`.
- `test_panel_colors_js` (`@unittest.skipUnless(shutil.which("node"), "нет node")`): `node -e` с `CLIENT_COLORS_JS + "console.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000'), panelColors('nope'), panelColors('#002b36')]))"` → `[{"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"}, {"bg": "#000000", "fg": "#aacace", "dim": "#66797c"}, null, {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"}]`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `python3 -m unittest tests.test_appearance -v`
Expected: ERROR `AttributeError: ... 'CLIENT_COLORS_JS'` / FAIL по разметке.
- [ ] **Step 3: Implement** в `files/claude_agents.py`:
- В `<head>` после `<style>`: `<script>` с `CLIENT_COLORS_JS` и `applyBg(hex)` — ставит `--bg`, `--fg`, `--dim` в `document.documentElement.style`; сразу читает `claude-panel-bg` в `try/catch` и применяет, если `panelColors` не `null`.
- В конце `<body>`-скрипта: тап по `#gear` переключает видимость `#bgrow`; тап по `.sw` → `applyBg` + сохранить; `input` на `#bg-custom` → то же; `#bg-reset` → удалить ключ, снять inline-переменные (`removeProperty`); каждая запись/удаление — в `try/catch`. `placeGear()`: если в `#limits` есть `.gear-slot` — перенести туда существующий узел `#gear` (`appendChild`), иначе вернуть в `#gear-home`; вызывать при загрузке и после каждой вставки в `refreshLimits`.
- CSS: `#gear`, `.sw`, `#bg-reset`, `#bg-custom` — область нажатия не меньше 36×36 CSS px; `.sw` — круг с рамкой `color-mix(in srgb, var(--fg) 35%, transparent)`; `#bgrow` по умолчанию `display:none`, раскрытый — `display:flex; gap:8px; align-items:center`.
- `README.md`: строка про ⚙ — фон панели на этом устройстве, «сброс» — тема по умолчанию.
- [ ] **Step 4: Run tests to verify they pass**
Run: `python3 -m unittest discover -s tests -v`
Expected: все `OK` (`test_panel_colors_js` не пропущен — `node` есть в `/opt/homebrew/bin`).
- [ ] **Step 5: Commit**
```bash
git add tests/test_appearance.py files/claude_agents.py README.md
git commit -m "feat: client-side panel background with gear control (#4)"
```
---
### Task 6: Приёмка на настоящем Mac (выполняет человек)
Задача меняет окружение пользователя (launchd, `~/.claude/settings.json`, iTerm2), поэтому её **не выполняют сабагенты SDD**. Контроллер останавливается после финального ревью ветки и передаёт этот чеклист человеку; результаты (особенно п. 7) записываются в `openspec/changes/headless-panel/design.md` отдельным коммитом.
**Files:**
- Modify: `openspec/changes/headless-panel/design.md` (результат проверки `localStorage` в Toolbelt, найденные отклонения)
- [ ] **Step 1:** За самим Mac, из worktree: `./install.sh` → все строки `✓`, в конце «служба запущена: http://127.0.0.1:8766/». `launchctl print gui/$(id -u)/com.andrey.claude-agents | grep state` → `state = running`. `lsof -nP -iTCP:8766 -sTCP:LISTEN` → один процесс, адрес `127.0.0.1:8766`. Если macOS спросит доступ к Keychain для `security` — разрешить и отметить в design.md.
- [ ] **Step 2:** Полностью закрыть iTerm2 (⌘Q). С iPad через туннель Termius открыть `http://127.0.0.1:8766` → дерево сессий из tmux с моделями, контекстом, worktree; внизу лимиты и баланс.
- [ ] **Step 3:** С iPad: «↗ вкладка» у сессии → tmux переключился на её окно/панель; `⎇ папка` → в том же окне tmux открылась панель с `lf`.
- [ ] **Step 4:** `kill <PID службы>` → в течение ~30 с порт снова отвечает (`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8766/` → `200`).
- [ ] **Step 5:** Запустить iTerm2 → Toolbelt → Claude показывает ту же страницу; `lsof` по-прежнему один слушатель (процесс службы, не iTerm2).
- [ ] **Step 6:** В Safari на iPad: ⚙ → светлый образец → фон светлый, текст тёмный; подождать минуту (автоперезагрузка) — фон держится без мелькания; «сброс» → тема по умолчанию; «свой цвет» открывает палитру iPad.
- [ ] **Step 7:** В Toolbelt: выбрать фон, перезапустить iTerm2 → записать в design.md, сохранился ли выбор.
- [ ] **Step 8:** `./install.sh` ещё раз → одна служба, в `~/.claude/settings.json` хуки панели не задублированы (`grep -c agents_panel_hook ~/.claude/settings.json` — как до повтора).
- [ ] **Step 9:** Из SSH-сессии Termius: `./install.sh` → отказ со словами «за самим Mac», панель продолжает отвечать.
- [ ] **Step 10 (по желанию):** `./uninstall.sh` → `launchctl print gui/$(id -u)/com.andrey.claude-agents` сообщает, что службы нет; порт 8766 свободен; plist удалён. Затем `./install.sh`, чтобы вернуть панель.
- [ ] **Step 11:** Commit записей в design.md:
```bash
git add openspec/changes/headless-panel/design.md
git commit -m "docs: acceptance results for headless panel (#4)"
```