plan: headless panel implementation plan
Refs #4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe43d2ea7b
commit
ee6d922636
266
docs/superpowers/plans/2026-10-18-headless-panel.md
Normal file
266
docs/superpowers/plans/2026-10-18-headless-panel.md
Normal file
@ -0,0 +1,266 @@
|
||||
# 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)"
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user