diff --git a/docs/superpowers/plans/2026-10-18-headless-panel.md b/docs/superpowers/plans/2026-10-18-headless-panel.md new file mode 100644 index 0000000..825f68f --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-headless-panel.md @@ -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`; плюс «свой цвет» (``) и «сброс». +- `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, /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, в теле `'
'`; 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=))`), `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