# 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