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

31 KiB
Raw Blame History

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

    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

    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

    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

    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

    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:

    git add openspec/changes/headless-panel/design.md
    git commit -m "docs: acceptance results for headless panel (#4)"