feat: панель как фоновая служба без iTerm2 + цвет фона на клиенте #5

Merged
aep merged 16 commits from feat/4-headless-panel into main 2026-10-18 21:58:14 +03:00
20 changed files with 1601 additions and 227 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.superpowers/
.bounded
.DS_Store
__pycache__/

View File

@ -1,15 +1,17 @@
# Архитектура
Два процесса, связанные только файлами в `~/.cache/claude-agents/`: хук внутри каждой сессии
Claude Code пишет события, скрипт iTerm2 их читает и рисует панель.
Три части, связанные файлами в `~/.cache/claude-agents/` и адресом `127.0.0.1:8766`: хук внутри
каждой сессии Claude Code пишет события, служба launchd их читает и отдаёт страницу, скрипт iTerm2
показывает эту страницу в Toolbelt. iTerm2 для работы панели не нужен.
```
claude (каждая сессия) ──hook──▶ agents_panel_hook.sh ──append──▶ ~/.cache/claude-agents/<sid>.jsonl
│
iTerm2 AutoLaunch: claude_agents.py ◀───── read (poll 1.5 s) ────────────┘
├─ + iTerm2 API (вкладки, профиль), ps, tmux, транскрипты ~/.claude/projects, git
launchd: claude_agents.py --headless ◀──── read (poll 1.5 s) ────────────┘
├─ + ps, tmux, транскрипты ~/.claude/projects, git
├─ + api.anthropic.com oauth/usage, prepaid/credits (OAuth из Keychain)
└─ HTTP 127.0.0.1:8766 ──▶ web view tool «Claude» в Toolbelt
└─ HTTP 127.0.0.1:8766 ──▶ web view «Claude» в Toolbelt (регистрирует claude_agents.py в iTerm2)
└─▶ браузер; с iPad — SSH local forward в Termius
```
## Модули
@ -17,19 +19,23 @@ iTerm2 AutoLaunch: claude_agents.py ◀───── read (poll 1.5 s) ──
| Модуль | Ответственность | Зависит от |
|---|---|---|
| `files/agents_panel_hook.sh` | Событие хука → строка jsonl с PID, вкладкой iTerm2 или панелью tmux | `jq`, `ps` |
| `files/claude_agents.py` — сессии | `load_sessions`/`replay`: jsonl → состояние сессий и субагентов; `build_tree` сводит с живыми вкладками, процессами, tmux | файлы хука, iTerm2 API, `ps`, `tmux`, транскрипты, `git` |
| `files/claude_agents.py` — сессии | `load_sessions`/`replay`: jsonl → состояние сессий и субагентов; `build_tree` сводит с процессами и tmux | файлы хука, `ps`, `tmux`, транскрипты, `git` |
| `files/claude_agents.py` — лимиты | `fetch_usage`/`fetch_balance` + `parse_limits`/`parse_balance` | Keychain/`~/.claude/.credentials.json`, `~/.claude.json`, api.anthropic.com, claude.ai |
| `files/claude_agents.py` — UI | `render_*`, `handle` (HTTP), `activate`/`open_worktree`, `theme_poller` | `state`, iTerm2 API |
| `files/claude_agents.py` — UI | `render_*`, `handle` (HTTP), `activate`/`open_worktree` (tmux) | `state` |
| `files/claude_agents.py` — запуск | `run`: `serve` (служба, `--headless`) или `register_toolbelt` (iTerm2) | launchd, `iterm2` |
| `files/launchagent.py` | plist службы `com.andrey.claude-agents` | — |
| `files/settings_hooks.py` | Идемпотентно добавить/убрать хуки в `~/.claude/settings.json` | — |
| `install.sh` / `uninstall.sh` | Разложить/убрать файлы, включить Python API iTerm2, перезапустить панель | `settings_hooks.py` |
| `install.sh` / `uninstall.sh` | Разложить/убрать файлы, поставить/снять службу, включить Python API iTerm2 | `settings_hooks.py`, `launchagent.py`, `launchctl` |
## Границы
- Хук не зависит от панели и не должен влиять на Claude Code: без вывода, всегда `exit 0`.
- Панель только читает данные Claude Code (jsonl, транскрипты, `~/.claude.json`) и ничего туда не пишет;
её единственная запись — удаление собственных логов старше `keep_logs_days`.
- `claude_agents.py` — только стандартная библиотека + `iterm2`; импорт без iTerm2 должен работать.
- Порт занимает только служба; скрипт iTerm2 сервер не поднимает и данные iTerm2 не передаёт.
- `claude_agents.py` — только стандартная библиотека + опционально `iterm2`; импорт без iTerm2 должен работать.
- HTTP слушает только `127.0.0.1`.
- Фон панели выбирается на клиенте и хранится в браузере/web view; сервер о нём не знает.
## Открытые вопросы
- Docstring `claude_agents.py` ссылается на несуществующий `settings-hooks.json` (теперь это `settings_hooks.py` + `install.sh`).
- Автотестов нет; `claude_agents.py` — один файл на 1500 строк.
- После перезагрузки Mac без входа на нём (только SSH) служба не стартует, Keychain заблокирован.
- `claude_agents.py` — один файл на ~1400 строк.

View File

@ -1,55 +1,63 @@
# cc-agents-iterm-plugin
## Что это
Панель «Claude» для iTerm2 Toolbelt. Сверху — дерево активных сессий Claude Code и их субагентов
(название, модель, размер контекста, worktree, статус), снизу — лимиты подписки (5 ч, неделя,
разбивка по продуктам) и баланс extra usage. Для одного пользователя на его Mac: видеть, что
делают все запущенные `claude` и сколько осталось лимитов, и в один клик переходить в нужную
вкладку или панель tmux.
Панель «Claude» — страница на `http://127.0.0.1:8766/`. Сверху — дерево активных сессий Claude Code
и их субагентов (название, модель, размер контекста, worktree, статус), снизу — лимиты подписки
(5 ч, неделя, разбивка по продуктам) и баланс extra usage. Её показывает iTerm2 Toolbelt и
браузер — в том числе Safari на iPad через SSH-туннель Termius. Для одного пользователя на его
Mac: видеть, что делают все запущенные `claude` (в tmux), сколько осталось лимитов, и в один клик
переходить в нужную панель tmux.
## Стек и ограничения
- Python 3.8+, только стандартная библиотека; модуль `iterm2` предоставляет сам iTerm2
(скрипт AutoLaunch). Без зависимостей из pip — пользователь ставит панель `./install.sh`.
- Python 3.8+, только стандартная библиотека; модуль `iterm2` нужен только регистратору Toolbelt
(его даёт iTerm2). Без зависимостей из pip — пользователь ставит панель `./install.sh`.
- Хук — POSIX `sh` + `jq`. Хук никогда не мешает Claude Code: ничего не выводит, всегда `exit 0`.
- Только macOS + iTerm2 с включённым Python API. Токен OAuth берётся из Keychain
(`Claude Code-credentials`) или `~/.claude/.credentials.json`.
- macOS, launchd. Токен OAuth берётся из Keychain (`Claude Code-credentials`) или
`~/.claude/.credentials.json`, поэтому служба работает в GUI-сессии пользователя.
- Сервер слушает только `127.0.0.1` — в панели видны тексты сообщений сессий.
- Установщик не трогает чужие хуки и настройки: правит `~/.claude/settings.json` только через
`settings_hooks.py` (маркер `agents_panel_hook`), перед заменой кладёт копии в
`~/.claude-panel-backup/<дата>/`. Повторный запуск `install.sh` идемпотентен.
`~/.claude-panel-backup/<дата>/`. Повторный запуск `install.sh` идемпотентен. Запуск по SSH
`install.sh`/`uninstall.sh` отклоняют.
## Архитектура
- `files/agents_panel_hook.sh` — хук Claude Code на SessionStart/End, UserPromptSubmit, Stop,
Notification, SubagentStart/Stop, PostModelSwitch, PreToolUse(Agent|Task). Дописывает событие
в `~/.cache/claude-agents/<session_id>.jsonl` вместе с PID процесса `claude`,
`ITERM_SESSION_ID` или сокетом/панелью tmux.
- `files/claude_agents.py` — скрипт iTerm2 AutoLaunch, один файл:
- `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с живыми
сессиями iTerm2, процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены,
из git — worktree; собирает `state["tree"]` (`build_tree`).
- `files/claude_agents.py` — один файл, два режима (`run`):
- `--headless` (`serve`) — служба launchd `com.andrey.claude-agents`, единственный владелец порта:
- `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с
процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены, из git —
worktree; собирает `state["tree"]` (`build_tree`, без данных iTerm2).
- `usage_poller` / `balance_poller` ходят в `api.anthropic.com/api/oauth/usage` и
`.../prepaid/credits` (запасной путь — claude.ai с `sessionKey` из Keychain).
- HTTP-сервер на `127.0.0.1:8766` (`handle`) отдаёт страницу, которую iTerm2 показывает как web view
tool: `/`, `/fragment` (дерево), `/limits`, `/message` (последние сообщения), `/activate`,
`/open-wt`, `/raw` (отладка).
- `theme_poller` подстраивает цвета под профиль iTerm2.
- HTTP-сервер (`handle`): `/`, `/fragment` (дерево), `/limits`, `/message` (последние
сообщения), `/activate` и `/open-wt` (через tmux), `/raw` (отладка).
- без флага (`register_toolbelt`) — скрипт AutoLaunch iTerm2: регистрирует web view «Claude» на
адрес службы; если порт молчит — `launchctl kickstart`. Свой сервер не поднимает.
- `files/launchagent.py` — пишет plist службы (`write <plist> <python> <script> <log> <PATH>`; PATH берётся из оболочки `install.sh`, его наследуют панели tmux, которые открывает служба).
- `files/settings_hooks.py` — добавляет/убирает хуки панели в `settings.json` (`add` / `remove`).
- `files/config.example.json` — все ключи настроек; рабочий файл `~/.config/claude-agents/config.json`
перечитывается на лету, кроме `port`.
- `install.sh` / `uninstall.sh [--purge]` — раскладывают файлы по местам (таблица в README.md).
- `install.sh` / `uninstall.sh [--purge]` — раскладывают файлы, ставят/убирают службу (таблица в README.md).
## Команды
- `./install.sh` — установить или обновить у себя; затем Toolbelt → Claude и перезапуск сессий `claude`.
- `./install.sh` — установить или обновить у себя (за самим Mac); затем Toolbelt → Claude и перезапуск сессий `claude`.
- `./uninstall.sh` — удалить (`--purge` — вместе с настройками и логами).
- `python3 -m unittest discover -s tests -v` — весь набор тестов (stdlib, без pytest; `node` нужен
для теста клиентского JS). Зелёным считается только весь набор.
- `python3 -m py_compile files/*.py && sh -n files/agents_panel_hook.sh && bash -n install.sh && bash -n uninstall.sh` —
синтаксическая проверка; сборки нет.
- `curl -s http://127.0.0.1:8766/raw` — что видит запущенная панель: логи хука, причины скрытия
сессий, ошибки лимитов и баланса.
сессий, ошибки лимитов и баланса. Лог службы — `~/Library/Logs/claude-agents/panel.log`,
состояние — `launchctl print gui/$(id -u)/com.andrey.claude-agents`.
## Тесты
- Автотестов пока нет. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`), так что чистые
функции (`replay`, `build_tree`, `parse_limits`, `parse_balance`, `session_name`, `settings_hooks.add/remove`)
можно покрывать pytest без iTerm2. Сеть, Keychain, `ps` и tmux в тестах мокаются.
- Фикстуры — образцы jsonl хука и ответов API в `tests/fixtures/`.
- `tests/` — `unittest`. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`). Сеть,
Keychain, launchd, `ps` и tmux в тестах подменяются; реальные `~/.claude` и `~/Library` не трогаются.
- `install.sh` и `uninstall.sh` в тестах запускаются только с `SSH_CONNECTION`/`SSH_TTY` (проверка
отказа): без охраны они остановили бы настоящую панель на порту 8766.
## Где лежит код
- Код — в `files/`; хук require-plan защищает его через `.claude/code-paths`. `install.sh` и

View File

@ -1,10 +1,15 @@
# Claude — панель для iTerm2 Toolbelt
# Claude — панель сессий Claude Code
Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree),
снизу — лимиты подписки и баланс extra usage.
снизу — лимиты подписки и баланс extra usage. Панель — страница на `http://127.0.0.1:8766/`:
её показывает iTerm2 Toolbelt, её можно открыть в браузере, в том числе с iPad через SSH-туннель.
Сервер панели — фоновая служба macOS, iTerm2 для неё не нужен.
## Установка
Запускать за самим Mac, не по SSH (из SSH-сессии launchd и iTerm2 не настроить —
установщик откажется):
```bash
tar xzf claude-panel.tar.gz
cd claude-panel
@ -14,27 +19,43 @@ cd claude-panel
Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите
открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное.
Нужно: macOS, iTerm2, python3, jq (`brew install jq`, в macOS 15+ уже есть).
Нужно: macOS, python3, jq (`brew install jq`, в macOS 15+ уже есть); iTerm2 — для Toolbelt.
## Что ставится
| Что | Куда |
|---|---|
| скрипт панели | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
| служба (сервер панели) | `~/.local/share/claude-agents/claude_agents.py` |
| описание службы launchd | `~/Library/LaunchAgents/com.andrey.claude-agents.plist` |
| лог службы | `~/Library/Logs/claude-agents/panel.log` |
| скрипт Toolbelt | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
| хук Claude Code | `~/.claude/hooks/agents_panel_hook.sh` + записи в `~/.claude/settings.json` |
| пример настроек | `~/.config/claude-agents/config.example.json` |
Перед изменением `settings.json` и заменой файлов старые копии кладутся в
`~/.claude-panel-backup/<дата>/`. Ваши остальные хуки и настройки не трогаются.
Служба запускается при входе в систему и перезапускается при падении. Перед изменением
`settings.json` и заменой файлов старые копии кладутся в `~/.claude-panel-backup/<дата>/`.
Ваши остальные хуки и настройки не трогаются.
## С iPad (Termius)
1. В Termius: **Port Forwarding → Local**: хост — ваш Mac, local port `8766`,
bind address `127.0.0.1`, destination `127.0.0.1`, destination port `8766`.
2. Запустите forward и откройте в Safari `http://127.0.0.1:8766` — именно `127.0.0.1`,
не `localhost` (сервер слушает только IPv4).
Сервер доступен только с самого Mac, в сеть не открывается: с других устройств — только через туннель.
Клики работают через tmux, так что переходы и `lf` появляются в том же tmux, что у вас в Termius.
## Работа с панелью
- клик по сессии или агенту — последние сообщения (копятся, пока окно открыто);
- ⌘-клик по сессии — перейти в её вкладку;
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели;
- ⌘-клик по сессии — перейти в её панель tmux;
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
- сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё;
- цвет токенов: жёлтый > 500k, красный > 700k.
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
- цвет токенов: жёлтый > 500k, красный > 700k;
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
## Настройки
@ -43,8 +64,15 @@ cd claude-panel
## Если что-то не так
`http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий,
ошибки лимитов и баланса.
- `http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий,
ошибки лимитов и баланса;
- `~/Library/Logs/claude-agents/panel.log` — лог службы;
- `launchctl print gui/$(id -u)/com.andrey.claude-agents` — состояние службы; ошибки запуска (например, «не найден python») видны только здесь, а не в `panel.log`;
- после переустановки или обновления Python (в том числе `brew upgrade`/`brew cleanup`) запустите
`./install.sh` заново — служба хранит путь к `python3`, и без этого launchd не сможет её запустить;
- «⎇ папка» мигает разделителем и панель сразу закрывается — служба не нашла `lf`. Служба берёт
`PATH` из оболочки, в которой запускали `install.sh`: запустите его заново из терминала, где
`command -v lf` находит файловый менеджер.
## Удаление

View 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)"
```

View File

@ -1,22 +1,19 @@
#!/usr/bin/env python3
"""
Claude — панель для iTerm2 Toolbelt.
Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает
iTerm2 Toolbelt.
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии.
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии.
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в
~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов.
Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их
не сообщил, берутся из транскриптов.
Установка:
1. Хук: скопировать agents_panel_hook.sh в ~/.claude/hooks/ и добавить блок
"hooks" из settings-hooks.json в ~/.claude/settings.json
2. Панель: этот файл — в ~/Library/Application Support/iTerm2/Scripts/AutoLaunch/
3. Toolbelt → Claude (старый скрипт лимитов claude_usage.py из AutoLaunch удалить)
Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd).
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта).
Изменения подхватываются на лету, кроме port (нужен перезапуск службы).
Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
Только стандартная библиотека.
@ -27,9 +24,10 @@ import html
import json
import os
import re
import shlex
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
@ -58,9 +56,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого
CONTEXT_CRIT = 700_000 # … и красное
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree
USE_PROFILE_THEME = True
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree
# лимиты подписки и баланс (нижняя часть панели)
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
@ -78,8 +74,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме
"keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400),
"agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"),
"file_manager": ("FILE_MANAGER", str),
"use_profile_theme": ("USE_PROFILE_THEME", bool),
"theme_variant": ("THEME_VARIANT", lambda v: v if v in ("auto", "dark", "light") else "auto"),
"context_warn": ("CONTEXT_WARN", int),
"context_crit": ("CONTEXT_CRIT", int),
"history_max_chars": ("HISTORY_MAX_CHARS", int),
@ -112,14 +106,14 @@ def load_config():
state["config_error"] = None
except Exception as e:
state["config_error"] = f"config.json: {e}"
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90",
# Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
"font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12}
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}
state = {"theme": dict(DEFAULT_THEME), "live": None, "tree": [], "error": None,
state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None,
"usage": None, "usage_error": None, "usage_updated": None,
"balance": None, "balance_error": None, "balance_via": None}
_app = None
_server = None
@ -837,6 +831,35 @@ def _render_tree() -> str:
return "".join(parts)
# Фон панели выбирается на клиенте (⚙ внизу) и хранится в localStorage этого браузера/web view;
# текст и приглушённый текст подбираются под яркость фона, цвета статусов не меняются.
BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")
LIGHT_BG_TEXT = "#3b4a50" # текст на светлом фоне; на тёмном — fg темы по умолчанию
BG_STORAGE_KEY = "claude-panel-bg"
CLIENT_COLORS_JS = """function panelColors(bg) {
if (typeof bg !== 'string' || !/^#[0-9a-f]{6}$/i.test(bg)) return null;
bg = bg.toLowerCase();
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const lum = h => { const [r, g, b] = rgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
const ratio = (a, c) => (Math.max(a, c) + 0.05) / (Math.min(a, c) + 0.05); // контраст по WCAG
const L = lum(bg); // текст — тот из двух, что контрастнее фону
const fg = ratio(lum('__LIGHT__'), L) > ratio(lum('__DARK__'), L) ? '__LIGHT__' : '__DARK__';
const f = rgb(fg), k = rgb(bg); // dim — как у темы: 60% текста, 40% фона
const dim = '#' + f.map((c, i) => Math.round(c * 0.6 + k[i] * 0.4).toString(16).padStart(2, '0')).join('');
return {bg, fg, dim};
}""".replace("__LIGHT__", LIGHT_BG_TEXT).replace("__DARK__", DEFAULT_THEME["fg"])
def render_bgctl() -> str:
swatches = "".join(f'<span class="sw" data-bg="{c}" title="{c}"><i style="background:{c}"></i></span>'
for c in BG_PRESETS)
return (f'<div id="bgctl"><div id="bgrow">{swatches}'
f'<input type="color" id="bg-custom" title="свой цвет" value="{DEFAULT_THEME["bg"]}">'
f'<span id="bg-reset">сброс</span></div>'
f'<span id="gear-home"><span id="gear" title="Фон панели">⚙</span></span></div>')
def render_page() -> str:
t = state["theme"]
return f"""<!doctype html><html><head><meta charset="utf-8">
@ -863,7 +886,23 @@ def render_page() -> str:
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
.sub {{ color:var(--dim); }}
.split {{ margin:-2px 0 7px; }}
.foot {{ margin-top:2px; opacity:.8; }}
.foot {{ margin-top:2px; opacity:.8; display:flex; align-items:center; }}
.gear-slot {{ margin-left:auto; }}
/* фон на этом устройстве: ⚙ в строке «обновлено», образцы — под блоком лимитов */
#bgctl {{ flex:none; padding:0 10px; }}
#gear-home {{ display:flex; justify-content:flex-end; }}
#gear {{ display:inline-flex; align-items:center; justify-content:center; min-width:36px; min-height:36px;
margin:-10px -8px; cursor:pointer; color:var(--dim); font-size:15px; }}
#gear:hover {{ color:var(--fg); }}
#bgrow {{ display:none; gap:4px; align-items:center; flex-wrap:wrap; padding:0 0 8px; }}
#bgrow.open {{ display:flex; }}
.sw {{ display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; cursor:pointer; }}
.sw i {{ width:24px; height:24px; border-radius:50%; box-sizing:border-box;
border:1px solid color-mix(in srgb, var(--fg) 35%, transparent); }}
#bg-custom {{ width:36px; height:36px; padding:4px; border:none; background:none; cursor:pointer; }}
#bg-reset {{ display:inline-flex; align-items:center; min-height:36px; padding:0 6px; margin-left:auto;
color:var(--dim); cursor:pointer; }}
#bg-reset:hover {{ color:var(--fg); }}
.foot.stale {{ color:var(--yellow); }}
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }}
@ -922,9 +961,21 @@ def render_page() -> str:
.msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }}
.trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
</style></head><body>
</style>
<script>
{CLIENT_COLORS_JS}
function applyBg(hex) {{
const c = panelColors(hex), s = document.documentElement.style;
if (!c) {{ ['--bg', '--fg', '--dim'].forEach(p => s.removeProperty(p)); return false; }}
s.setProperty('--bg', c.bg); s.setProperty('--fg', c.fg); s.setProperty('--dim', c.dim);
return true;
}}
// до отрисовки — чтобы при каждой перезагрузке не мелькала тема по умолчанию
try {{ applyBg(localStorage.getItem('{BG_STORAGE_KEY}')); }} catch (e) {{}}
</script></head><body>
<div id="root">{render_tree()}</div>
<div id="limits">{render_limits()}</div>
{render_bgctl()}
<div id="ov">
<div class="ov-head"><span class="ov-title"></span>
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
@ -943,7 +994,12 @@ def render_page() -> str:
async function refreshLimits() {{
try {{
const r = await fetch('/limits', {{cache: 'no-store'}});
if (r.ok) document.getElementById('limits').innerHTML = await r.text();
if (r.ok) {{
const html = await r.text();
document.getElementById('gear-home').appendChild(gear); // иначе innerHTML уничтожит ⚙
document.getElementById('limits').innerHTML = html;
placeGear();
}}
}} catch (e) {{}}
}}
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
@ -1021,8 +1077,38 @@ def render_page() -> str:
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
setInterval(loadMsg, 2000);
// тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто)
setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
// ---- фон панели на этом устройстве ----
const gear = document.getElementById('gear'), bgrow = document.getElementById('bgrow');
const bgCustom = document.getElementById('bg-custom');
function placeGear() {{
const slot = document.querySelector('#limits .gear-slot');
(slot || document.getElementById('gear-home')).appendChild(gear);
}}
function saveBg(hex) {{
if (!applyBg(hex)) return;
bgCustom.value = hex.toLowerCase();
try {{ localStorage.setItem('{BG_STORAGE_KEY}', hex.toLowerCase()); }} catch (e) {{}}
}}
gear.addEventListener('click', e => {{ e.stopPropagation(); bgrow.classList.toggle('open'); }});
bgrow.addEventListener('click', e => {{
e.stopPropagation();
const sw = e.target.closest('.sw');
if (sw) saveBg(sw.dataset.bg);
}});
bgCustom.addEventListener('input', () => saveBg(bgCustom.value));
document.getElementById('bg-reset').addEventListener('click', () => {{
applyBg(null);
bgCustom.value = '{DEFAULT_THEME["bg"]}';
try {{ localStorage.removeItem('{BG_STORAGE_KEY}'); }} catch (e) {{}}
}});
try {{
const saved = localStorage.getItem('{BG_STORAGE_KEY}');
if (panelColors(saved)) bgCustom.value = saved;
}} catch (e) {{}}
placeGear();
// раз в минуту перезагружаем страницу (если не открыты сообщение и выбор фона) — подхватить новую версию службы
setInterval(() => {{ if (!cur && !bgrow.classList.contains('open')) location.reload(); }}, 60000);
</script>
</body></html>"""
@ -1244,31 +1330,12 @@ def render_limits() -> str:
if state["usage_updated"]:
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>')
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}'
f'<span class="gear-slot"></span></div>')
return "".join(parts)
# ---------- связь с iTerm2 ----------
async def live_sessions(connection):
global _app
if _app is None:
_app = await iterm2.async_get_app(connection)
live = {}
for w in _app.terminal_windows:
for tab in w.tabs:
for sess in tab.sessions:
pane = None
try:
name = await sess.async_get_variable("name")
job = await sess.async_get_variable("jobName")
pane = await sess.async_get_variable("tmuxWindowPane") # tmux -CC: номер панели
except Exception:
name, job = sess.name, ""
live[sess.session_id] = {"name": name or "", "job": job or "",
"tmux_pane": f"%{pane}" if pane is not None else None}
return live
# ---------- действия по клику (через tmux) ----------
def known_roots():
roots = set()
@ -1286,13 +1353,15 @@ _last_open = {} # path -> time последнего открытия (защи
async def open_worktree(uuid, path):
"""Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер.
"""Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер.
Команда и папка задаются настройками новой панели, а не вводом текста в оболочку:
Команда передаётся tmux при создании панели, а не вводом текста в оболочку:
так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
и панель закрывается сама, когда выходите из lf."""
и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются."""
if path not in known_roots() or not os.path.isdir(path):
return # открываем только папки, которые панель сама показала
if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()):
return
now = time.time()
if now - _last_open.get(path, 0) < 3:
return # повторный клик/запрос — уже открываем
@ -1300,28 +1369,10 @@ async def open_worktree(uuid, path):
state["open_log"] = (state.get("open_log") or [])[-9:] + [
{"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}]
if uuid and uuid.startswith("tmux:") and uuid in known_targets():
sock, pane = _tmux_target(uuid)
cmd = f"exec {FILE_MANAGER}"
await asyncio.get_running_loop().run_in_executor(
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd))
return
if _app is None:
return
sess = _app.get_session_by_id(uuid) if uuid and not uuid.startswith("tmux:") else None
if sess is None:
w = _app.current_terminal_window
sess = w.current_tab.current_session if w else None
if sess is None:
return
prof = iterm2.LocalWriteOnlyProfile()
prof.set_use_custom_command("Yes")
# через login-оболочку, чтобы PATH был как обычно (Homebrew и т.п.)
shell = os.environ.get("SHELL") or "/bin/zsh"
prof.set_command(f"{shell} -lc {shlex.quote('cd ' + shlex.quote(path) + ' && exec ' + FILE_MANAGER)}")
new = await sess.async_split_pane(vertical=True, profile_customizations=prof)
if new is not None:
await new.async_activate()
def known_targets():
@ -1340,41 +1391,27 @@ def _tmux(sock, *args):
async def activate(uuid):
if uuid not in known_targets():
return # только то, что панель сама показала
twin = (state.get("tmux_twins") or {}).get(uuid)
if twin and _app is not None: # tmux -CC — переключаемся средствами iTerm2: вкладка и окно спереди
sess = _app.get_session_by_id(twin)
if sess:
await sess.async_activate(select_tab=True, order_window_front=True)
return
if uuid.startswith("tmux:"):
if uuid not in known_targets() or not uuid.startswith("tmux:"):
return # только сессии в tmux, которые панель сама показала
sock, pane = _tmux_target(uuid)
loop = asyncio.get_running_loop()
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
("select-pane", "-t", pane)):
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a))
return
if _app is None:
return
sess = _app.get_session_by_id(uuid)
if sess:
await sess.async_activate(select_tab=True, order_window_front=True)
async def refresher(connection):
async def refresher():
last_cleanup = 0
while True:
try:
load_config()
live = await live_sessions(connection) if connection is not None else None
loop = asyncio.get_running_loop()
sessions = await loop.run_in_executor(None, load_sessions)
alive_now = [x for x in sessions if not x["ended"]]
procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now])
tmux = await loop.run_in_executor(
None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]})
state["tree"] = build_tree(sessions, live, procs, tmux)
state["tree"] = build_tree(sessions, None, procs, tmux)
state["error"] = None
if time.time() - last_cleanup > 3600:
cleanup_logs()
@ -1393,49 +1430,6 @@ def cleanup_logs():
pass
def _hex(c) -> str:
return "#%02x%02x%02x" % (round(c.red), round(c.green), round(c.blue))
def _mix(c1, c2, k: float) -> str:
return "#%02x%02x%02x" % tuple(round(a * k + b * (1 - k)) for a, b in
[(c1.red, c2.red), (c1.green, c2.green), (c1.blue, c2.blue)])
async def theme_poller(connection):
"""Цвета и шрифт — из профиля текущей сессии. Если в профиле включены отдельные цвета
для светлой и тёмной темы, берётся вариант, который iTerm2 сейчас показывает."""
while True:
try:
app = _app or await iterm2.async_get_app(connection)
p = await app.current_terminal_window.current_tab.current_session.async_get_profile()
suffix = ""
if getattr(p, "use_separate_colors_for_light_and_dark_mode", False):
mode = [THEME_VARIANT] if THEME_VARIANT != "auto" else await app.async_get_theme()
suffix = " (Dark)" if "dark" in mode else " (Light)"
def color(key):
return p.get_color_with_key(key + suffix) or p.get_color_with_key(key)
theme = dict(DEFAULT_THEME)
bg, fg = color("Background Color"), color("Foreground Color")
if bg is not None and fg is not None:
theme.update(bg=_hex(bg), fg=_hex(fg), dim=_mix(fg, bg, 0.6))
for name, idx in (("red", 1), ("green", 2), ("yellow", 3)):
c = color(f"Ansi {idx} Color")
if c is not None:
theme[name] = _hex(c)
font = (p.normal_font or "").rsplit(" ", 1)
if len(font) == 2:
theme["font"] = f"'{font[0].split('-')[0]}', '{font[0]}', ui-monospace, monospace"
theme["size"] = min(int(float(font[1])), 13)
theme["source"] = f"profile {p.name!r}{suffix or ''}"
state["theme"] = theme
except Exception as e:
state["theme_error"] = str(e)
await asyncio.sleep(30)
# ---------- HTTP ----------
async def handle(reader, writer):
@ -1443,6 +1437,20 @@ async def handle(reader, writer):
request = (await reader.read(2048)).decode("latin-1")
target = request.split(" ", 2)[1] if request.count(" ") >= 2 else "/"
url = urllib.parse.urlparse(target)
headers = {}
for line in request.split("\r\n")[1:]:
name, sep, value = line.partition(":")
if sep:
headers[name.strip().lower()] = value.strip()
port = writer.get_extra_info("sockname")[1]
# защита от DNS rebinding и чужих страниц в браузере: только свой адрес в Host,
# а действия (/activate, /open-wt) — ещё и не из чужого сайта
if (headers.get("host", "").lower() not in (f"127.0.0.1:{port}", f"localhost:{port}")
or (url.path in ("/activate", "/open-wt")
and headers.get("sec-fetch-site", "").lower() == "cross-site")):
writer.write(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
return
ctype = "text/html; charset=utf-8"
if url.path == "/limits":
body = render_limits()
@ -1463,7 +1471,7 @@ async def handle(reader, writer):
body = "ok"
elif url.path == "/raw":
body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"),
"theme": state["theme"], "theme_error": state.get("theme_error"),
"theme": state["theme"],
"usage_error": state["usage_error"], "balance_via": state["balance_via"],
"balance_error": state["balance_error"],
"log_files": sorted(os.path.basename(p) for p in
@ -1484,23 +1492,70 @@ async def handle(reader, writer):
writer.close()
async def main(connection):
# ---------- запуск ----------
# Два режима одного файла: служба launchd (--headless) держит порт и всё считает,
# скрипт AutoLaunch в iTerm2 только показывает её страницу в Toolbelt.
SERVICE_LABEL = "com.andrey.claude-agents" # метка LaunchAgent и идентификатор web view
async def serve():
"""Сервер панели без iTerm2: страница, дерево сессий, лимиты и баланс. Работает до отмены."""
global _server
load_config()
_server = await asyncio.start_server(handle, "127.0.0.1", PORT)
asyncio.create_task(refresher(connection))
asyncio.create_task(usage_poller())
asyncio.create_task(balance_poller())
if USE_PROFILE_THEME:
asyncio.create_task(theme_poller(connection))
tasks = [asyncio.create_task(c) for c in (refresher(), usage_poller(), balance_poller())]
try:
async with _server:
await _server.serve_forever()
finally:
for t in tasks:
t.cancel()
def port_alive(port, host="127.0.0.1", timeout=0.5) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def kickstart_service():
"""Просит launchd запустить службу панели, если она не запущена."""
try:
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{SERVICE_LABEL}"],
capture_output=True, timeout=10)
except Exception:
pass
async def register_toolbelt(connection):
"""Скрипт AutoLaunch: регистрирует web view со страницей службы; свой сервер не поднимает."""
load_config()
if not port_alive(PORT):
kickstart_service()
for _ in range(20): # до ~5 с ждём, пока служба займёт порт, иначе web view покажет ошибку
if port_alive(PORT):
break
await asyncio.sleep(0.25)
await iterm2.tool.async_register_web_view_tool(
connection,
display_name="Claude",
identifier="com.andrey.claude-agents",
identifier=SERVICE_LABEL,
reveal_if_already_registered=False,
url=f"http://127.0.0.1:{PORT}/",
)
if __name__ == "__main__" and iterm2 is not None:
iterm2.run_forever(main)
def run(argv):
if "--headless" in argv:
asyncio.run(serve())
elif iterm2 is not None:
iterm2.run_forever(register_toolbelt)
else:
raise SystemExit("нужен iTerm2 или флаг --headless")
if __name__ == "__main__":
run(sys.argv[1:])

View File

@ -5,7 +5,6 @@
"agent_idle_min": 15,
"poll_sec": 1.5,
"keep_logs_days": 2,
"use_profile_theme": true,
"port": 8766,
"context_warn": 500000,
"context_crit": 700000,
@ -13,6 +12,5 @@
"usage_refresh_sec": 120,
"balance_refresh_sec": 600,
"show_breakdown": true,
"show_balance": true,
"theme_variant": "auto"
"show_balance": true
}

42
files/launchagent.py Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Пишет описание службы launchd для сервера панели: запуск при входе, перезапуск при падении.
launchagent.py write <plist> <python> <claude_agents.py> <log> <PATH>
"""
import os
import plistlib
import sys
LABEL = "com.andrey.claude-agents" # то же, что SERVICE_LABEL в claude_agents.py
def plist(python, script, log, path):
return {
"Label": LABEL,
"ProgramArguments": [python, script, "--headless"],
# у launchd PATH без Homebrew, а панели tmux, открытые службой (lf), получают её PATH
"EnvironmentVariables": {"PATH": path},
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": log,
"StandardErrorPath": log,
}
def main():
if len(sys.argv) != 7 or sys.argv[1] != "write":
print(__doc__.strip(), file=sys.stderr)
sys.exit(2)
path, python, script, log = (os.path.expanduser(a) for a in sys.argv[2:6])
env_path = sys.argv[6]
os.makedirs(os.path.dirname(path), exist_ok=True)
os.makedirs(os.path.dirname(log), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "wb") as f:
plistlib.dump(plist(python, script, log, env_path), f, fmt=plistlib.FMT_XML)
os.replace(tmp, path)
print(path)
if __name__ == "__main__":
main()

View File

@ -1,8 +1,18 @@
#!/bin/bash
# Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов Claude Code,
# лимиты подписки и баланс. Повторный запуск безопасен — обновляет установленное.
# Установка панели «Claude»: дерево сессий и агентов Claude Code, лимиты подписки и баланс.
# Сервер панели — служба launchd (работает и без iTerm2), iTerm2 показывает её в Toolbelt.
# Повторный запуск безопасен — обновляет установленное.
set -euo pipefail
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
# из SSH-сессии launchd (gui/<uid>) и iTerm2 не настроить, а прежний сервер уже был бы остановлен
[ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] \
|| die "запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить. Если вы и так за Mac (например, в панели tmux, открытой раньше из SSH), запустите в обычной вкладке iTerm2 или после unset SSH_CONNECTION SSH_TTY"
HERE="$(cd "$(dirname "$0")" && pwd)"
FILES="$HERE/files"
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
@ -11,11 +21,12 @@ SETTINGS="$HOME/.claude/settings.json"
CONFIG_DIR="$HOME/.config/claude-agents"
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$"
PORT=8766
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"
GUI="gui/$(id -u)"
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
backup() { mkdir -p "$BACKUP_DIR"; cp -p "$1" "$BACKUP_DIR/"; }
# ---------- проверки ----------
@ -23,11 +34,12 @@ say "Проверяю окружение"
[ "$(uname)" = "Darwin" ] || [ "${CLAUDE_PANEL_ANY_OS:-}" = 1 ] || die "нужен macOS с iTerm2"
python3 -c 'import sys; assert sys.version_info >= (3, 8)' 2>/dev/null \
|| die "нужен python3 ≥ 3.8 (xcode-select --install или brew install python)"
ok "python3: $(python3 -V 2>&1)"
PY="$(command -v python3)" # не sys.executable: версионный путь Homebrew исчезает после brew cleanup
ok "python3: $(python3 -V 2>&1) ($PY)"
if command -v jq >/dev/null; then ok "jq: $(command -v jq)"
else warn "нет jq — хук без него молча ничего не пишет. Установите: brew install jq"; fi
if [ -d "/Applications/iTerm.app" ] || [ -d "$HOME/Applications/iTerm.app" ]; then ok "iTerm2 найден"
else warn "iTerm2 не найден в /Applications — панель будет работать только в нём"; fi
else warn "iTerm2 не найден в /Applications — панель будет доступна только в браузере"; fi
# ---------- хук Claude Code ----------
say "Хук Claude Code"
@ -41,7 +53,7 @@ if [ "$added" = "—" ]; then ok "settings.json: хуки уже на месте
else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi
# ---------- скрипт iTerm2 ----------
say "Скрипт iTerm2"
say "Скрипт iTerm2 (регистрация в Toolbelt)"
mkdir -p "$AUTOLAUNCH"
if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов
backup "$AUTOLAUNCH/claude_usage.py" && rm "$AUTOLAUNCH/claude_usage.py"
@ -57,6 +69,33 @@ install -m 644 "$FILES/config.example.json" "$CONFIG_DIR/config.example.json"
if [ -f "$CONFIG_DIR/config.json" ]; then ok "ваш config.json не тронут"
else ok "пример настроек: $CONFIG_DIR/config.example.json (скопируйте в config.json, чтобы менять)"; fi
# ---------- фоновая служба ----------
say "Фоновая служба"
mkdir -p "$SERVICE_DIR"
install -m 644 "$FILES/claude_agents.py" "$SERVICE_DIR/claude_agents.py"
# PATH из текущей оболочки: панели tmux, открытые службой (lf), должны находить то же, что и в терминале
python3 "$FILES/launchagent.py" write "$PLIST" "$PY" "$SERVICE_DIR/claude_agents.py" "$SERVICE_LOG" "$PATH" >/dev/null
ok "$PLIST"
launchctl bootout "$GUI/$LABEL" >/dev/null 2>&1 || true
# прежняя копия панели (сервер внутри iTerm2) держит порт — останавливаем, чтобы его заняла служба
if command -v lsof >/dev/null; then
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; ok "остановлена прежняя версия панели"; fi
fi
# сразу после bootout bootstrap иногда падает с «Input/output error» — повторяем
for attempt in 1 2 3 4 5; do
launchctl bootstrap "$GUI" "$PLIST" && break
[ "$attempt" = 5 ] && die "launchd не принял службу — смотрите launchctl print $GUI/$LABEL и $SERVICE_LOG"
sleep 1
done
up=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/"; then up=1; break; fi
sleep 1
done
if [ $up = 1 ]; then ok "служба запущена: http://127.0.0.1:$PORT/"
else warn "служба не ответила — смотрите $SERVICE_LOG"; fi
# ---------- Python API в iTerm2 ----------
say "iTerm2"
iterm_running=0
@ -74,28 +113,24 @@ else
ok "Python API уже включён"
fi
# старая копия панели держит порт — останавливаем, чтобы запустилась новая
if command -v lsof >/dev/null; then
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; ok "остановлена прежняя версия панели"; fi
fi
if [ $iterm_running = 1 ] && [ "$api" = 1 ]; then
pkill -f "Scripts/AutoLaunch/claude_agents.py" || true # прежний регистратор, иначе их станет по одному на запуск
if osascript -e 'tell application "iTerm2" to launch API script named "claude_agents"' >/dev/null 2>&1; then
ok "панель запущена"
ok "панель зарегистрирована в Toolbelt"
else
warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py"
warn "не смог зарегистрировать сам — Scripts → AutoLaunch → claude_agents.py"
fi
else
warn "панель запустится сама при следующем запуске iTerm2"
warn "в Toolbelt панель появится при следующем запуске iTerm2"
fi
cat <<EOF
Готово. Осталось:
1. В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
2. Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Готово. Панель работает и без iTerm2: http://127.0.0.1:$PORT/
(с iPad — через SSH-туннель на этот адрес, см. README).
В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Проверка, что всё работает: http://127.0.0.1:$PORT/raw
Проверка: http://127.0.0.1:$PORT/raw · лог службы: $SERVICE_LOG
Удаление: $HERE/uninstall.sh
EOF

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-18

View File

@ -0,0 +1,214 @@
# Design: панель без iTerm2 + фон на клиенте
Issue: #4. Ветка: `feat/4-headless-panel`.
## Намерение
Пользователь смотрит панель с iPad: Termius держит SSH local forward `8766 → 127.0.0.1:8766` на Mac,
страница открыта в Safari рядом с Termius (Split View). Все сессии Claude Code — в обычном tmux,
который работает сам по себе. Сейчас страница пропадает, если на Mac не запущен iTerm2, потому что
сервер поднимает скрипт AutoLaunch iTerm2. Переключение сессий с iPad уже работает — через tmux.
Нужно: страница доступна всегда, пока Mac включён и пользователь вошёл; фон меняется прямо на
странице, потому что тема по умолчанию в Safari раздражает.
Успех: iTerm2 закрыт → в Safari на iPad видно дерево сессий, лимиты и баланс; клики переключают
tmux; фон выставлен под тему Termius и держится между перезагрузками.
## Цели
- Фоновый сервер панели, независимый от iTerm2, автозапуск при входе, перезапуск при падении.
- Один владелец порта 8766; iTerm2-скрипт только регистрирует web view в Toolbelt.
- Шестерёнка ⚙ внизу панели (вариант A из мокапов): образцы фона, «свой цвет», «сброс»;
выбор хранится на устройстве; текст подстраивается под яркость фона.
- `install.sh`/`uninstall.sh` управляют службой; обновление старой установки без ручных шагов.
## Не цели
- Досыл названий вкладок iTerm2 и цветов профиля в фоновый сервер (решено пользователем:
в tmux имена и так из tmux; цвет — шестерёнкой, в Toolbelt тоже).
- Доступ к странице из сети напрямую (только через туннель).
- Мод Claude Code / TUI-версия панели (обсуждались, отложены).
- Запуск службы без входа пользователя в систему на самом Mac (см. «Открытые вопросы»).
## Анти-цели (провал, даже если «работает»)
- Два сервера одновременно или удвоенный опрос лимитов (эндпоинт отвечает 429).
- Порт открыт наружу: в панели видны тексты сообщений сессий.
- Мелькание темы по умолчанию при каждой автоперезагрузке страницы (раз в минуту) после выбора фона.
- Установщик ломает чужие хуки/настройки или требует ручных шагов при обновлении.
## Ограничения
- Python 3.8+, только стандартная библиотека; модуль `iterm2` — только в режиме регистратора.
- macOS, `launchctl`; LaunchAgent в сессии пользователя (Aqua).
- В LaunchAgent минимальный `PATH`: путь к `python3` фиксируется установщиком абсолютным; поиск
`tmux`/`ps` уже идёт через `EXTRA_PATH` в коде.
- Токен лимитов — из Keychain через `security`; служба должна работать в GUI-сессии пользователя,
где Keychain разблокирован.
- Сервер слушает только `127.0.0.1`.
## Решения, принятые с пользователем
- Вариант 0 (SSH-туннель + Safari) — основной сценарий, мод/TUI не делаем.
- Фоновая служба через LaunchAgent; один сервер на порту; iTerm2-скрипт — регистратор.
- Цвет профиля iTerm2 не досылать и не читать (вариант 3): в Toolbelt фон тоже ставится шестерёнкой.
- Место настройки фона — шестерёнка в нижней строке (вариант A).
- Перезагрузка Mac без GUI-входа — не решаем сейчас, запоминаем на будущее.
- `install.sh` / `uninstall.sh` запускаются только за самим Mac (не по SSH). При запуске из SSH-сессии
(`SSH_CONNECTION`/`SSH_TTY` заданы) установщик предупреждает и завершается, ничего не меняя;
в README — строка об этом.
## Как устроено (то, что решено)
### Режимы `claude_agents.py`
Один исходный файл, два режима:
- **Фоновый сервер** (`--headless`, запускает launchd системным `python3`): `asyncio.start_server` на
`127.0.0.1:PORT`, `refresher(None)`, `usage_poller`, `balance_poller`; без `iterm2`, без
`theme_poller`, без регистрации web view. `live=None` → `build_tree` уже судит о сессиях по PID и
tmux. Блок `__main__` запускает этот режим при флаге независимо от наличия `iterm2`.
- **Регистратор** (запуск iTerm2 из AutoLaunch, без флага): проверяет, отвечает ли порт; если нет —
`launchctl kickstart gui/<uid>/<label>`; затем `async_register_web_view_tool` на
`http://127.0.0.1:PORT/` и остаётся жить (`run_forever`), ничего больше не делая.
- `theme_poller`, `live_sessions`, использование `_app` в `activate`/`open_worktree`,
`USE_PROFILE_THEME`, `THEME_VARIANT` и их ключи в `CONFIG_KEYS` удаляются. Старые ключи в чужом
`config.json` просто игнорируются (`load_config` берёт только известные ключи).
- `activate`/`open_worktree` для целей не из tmux становятся no-op.
### LaunchAgent
- Метка `com.andrey.claude-agents` (совпадает с идентификатором web view).
- `ProgramArguments`: абсолютный путь к `python3`, путь к установленному `claude_agents.py`, `--headless`.
- `RunAtLoad=true`, `KeepAlive=true`, `ThrottleInterval` по умолчанию.
- stdout/stderr — в `~/Library/Logs/claude-agents/panel.log`.
### install.sh / uninstall.sh
- install: как раньше хук и `settings.json`; копирует `claude_agents.py` в AutoLaunch (регистратор);
пишет plist в `~/Library/LaunchAgents/`; останавливает всё, что слушает порт (прежний сервер
внутри iTerm2); `launchctl bootout` (если была) + `bootstrap gui/<uid>` + `kickstart -k`; ждёт,
пока порт ответит, и сообщает результат; если iTerm2 запущен с включённым API — перезапускает
скрипт AutoLaunch (как сейчас через `osascript`).
- uninstall: `launchctl bootout`, удаляет plist, остальное как сейчас; `--purge` удаляет и лог службы.
### Фон на клиенте
- В `render_page`: в `<head>` до отрисовки маленький скрипт читает выбор из `localStorage`
(в `try/catch`) и выставляет CSS-переменные `--bg`, `--fg`, `--dim` на `:root` — без мелькания.
- Производные цвета: по относительной яркости фона выбирается светлый или тёмный текст;
`--dim` — смесь текста с фоном (как `_mix` на сервере, коэффициент 0.6). Линии дерева и
разделители уже считаются от `--fg` через `color-mix`, отдельно не трогаются.
- ⚙ — в строке `.foot` блока лимитов. Блок лимитов перерисовывается сервером раз в 15 с через
`/limits`, поэтому ⚙ и строка образцов живут вне перерисовываемого HTML (отдельный элемент
рядом с `#limits` или восстанавливаются после вставки), чтобы раскрытая строка не схлопывалась.
- Образцы: текущий по умолчанию (`#002b36`), нейтральный тёмный (`#1e1e1e`), чёрный (`#000000`),
тёмно-фиолетово-серый (`#2b2b3a`), светлый (`#fdf6e3`). «Свой цвет» — `<input type="color">`
(на iPad открывает системную палитру). «Сброс» удаляет ключ и возвращает тему сервера.
- Тема сервера по умолчанию — `DEFAULT_THEME`, выровненная по нынешнему профилю iTerm2 `Chat`
(снято с работающей панели 2026-10-18): `bg #002b36`, `fg #aacace`, `dim #668a91`,
`green #859900`, `yellow #b58900`, `red #dc322f`, шрифт `'MesloLGLNF', 'MesloLGLNF-Regular',
ui-monospace, monospace`, размер 13. Значения зашиты в код, профиль не читается.
- Если web view Toolbelt не сохраняет `localStorage` между перезапусками iTerm2 — принимаем это:
после перезапуска в Toolbelt тема по умолчанию (она и есть вид профиля `Chat`). Серверного
хранения выбора нет. Проверить поведение в начале реализации и записать результат сюда.
## Оставлено исполнителю
- Точное расположение установленного `claude_agents.py` для службы (та же копия в AutoLaunch или
отдельная) — при условии одного исходного файла в репозитории.
- Размер и вид ⚙ и образцов в пределах требований спеки (пальцем на iPad, ничего лишнего в
свёрнутом виде).
- Тесты: pytest по чистым функциям (`settings_hooks`, генерация plist, расчёт производных цветов,
если он на сервере) и ручная проверка сценариев спеки, которые завязаны на launchd/iPad.
## Риски
- Сохраняется ли `localStorage` в web view Toolbelt iTerm2 между перезапусками iTerm2 — не проверено.
Решено с пользователем: если нет, фон в Toolbelt сбрасывается к теме по умолчанию (вид профиля `Chat`).
- Доступ `security` к элементу Keychain `Claude Code-credentials` из процесса launchd может
потребовать подтверждения пользователя один раз.
- `/usr/bin/python3` без установленных Command Line Tools — заглушка; установщик уже проверяет, что
`python3` реально работает, и фиксирует его путь.
## Открытые вопросы
- После перезагрузки Mac без входа на нём (только SSH с iPad) LaunchAgent не стартует, а Keychain
заблокирован. Пользователь такого не делал, но не исключает; решение отложено.
## Decisions made without discussion
- 2026-10-18 — Сервер остаётся только на `127.0.0.1`. Обоснование: в панели тексты сообщений
сессий; с iPad и так заходят через туннель. Цена ошибки: если понадобится LAN — добавить ключ
`bind` в конфиг.
- 2026-10-18 — Возможности iTerm2 для сессий вне tmux (имена вкладок, переход во вкладку, `lf` в
split iTerm2, `tmux -CC`) убираются целиком, а не только в фоновом режиме. Обоснование: следствие
«один сервер на порту» + «не досылать данные из iTerm2»; пользователь держит всё в tmux. Цена
ошибки: для сессий вне tmux теряется переход по клику; вернуть можно досылом из регистратора.
- 2026-10-18 — Подстраивается только фон, а производные (текст, приглушённый, линии) считаются
автоматически; цвета статусов не меняются. Обоснование: пользователь говорил о фоне. Цена
ошибки: добавить выбор цвета текста.
- 2026-10-18 — Регистратор при неотвечающем порте делает `launchctl kickstart`, а не поднимает свой
сервер. Обоснование: один код сервера, нет гонки за порт. Цена ошибки: если launchd-службы нет
(не переустановлено), в Toolbelt пустая страница до `install.sh`.
- 2026-10-18 — Метка LaunchAgent `com.andrey.claude-agents`, лог в `~/Library/Logs/claude-agents/`.
Цена ошибки: переименование при следующей установке.
- 2026-10-18 (план) — Тесты на `unittest` из stdlib, не pytest. Обоснование: pytest не установлен,
`pip install` запрещён хуком на main и противоречит «только stdlib»; `unittest`-тесты pytest тоже
запускает. Цена ошибки: переписать на pytest-стиль при желании.
- 2026-10-18 (план) — Копия скрипта для службы — `~/.local/share/claude-agents/claude_agents.py`,
отдельно от копии в AutoLaunch. Обоснование: служба не зависит от каталога iTerm2. Цена ошибки:
два места при ручном обновлении (`install.sh` обновляет оба).
- 2026-10-18 (план) — Текст — `#3b4a50` (светлый фон) или `#aacace` (как в профиле, тёмный фон);
выбирается тот, у которого выше коэффициент контраста WCAG `(Lmax+0.05)/(Lmin+0.05)` с фоном
(было: порог яркости > 0.4, на средних тонах давал ~1.5:1 — находка I-1). Цена ошибки:
на отдельных средних фонах контраст всё равно не дотянет до 4.5:1; подобрать другие цвета текста.
- 2026-10-18 (план) — Область нажатия ⚙, образцов и кнопок — не меньше 36×36 CSS px. Цена ошибки:
поправить размер после приёмки на iPad.
- 2026-10-18 (план) — В этой ветке заодно закрываются O-1 (устаревший docstring) и O-3
(`__pycache__/` в `.gitignore`) из issue #3: оба файла и так меняются. Цена ошибки: нет.
- 2026-10-18 (план) — Приёмка (Task 6) выполняется человеком, не сабагентами: она меняет launchd,
`~/.claude/settings.json` и настройки iTerm2 на рабочем Mac.
- 2026-10-18 (исполнение) — R-1 (design #12): `test_old_theme_keys_ignored` и
`test_open_worktree_non_tmux_is_noop` прошли до реализации Task 1 (старые ключи и раньше не давали
ошибки, `_app` был `None`); оставлены как тесты-фиксаторы поведения после удаления. Цена ошибки:
нет — остальные тесты Task 1 падали корректно.
- 2026-10-18 (исполнение) — R-2 (design #13): `build_tree` оставлен с параметром `live` (вызывается с
`None`) и мёртвой веткой `tmux_twins`, потому что план требовал не менять `build_tree`. Цена ошибки:
мёртвый код; уборка отложена (M-9).
- 2026-10-18 (исполнение) — R-3 (design #14): RED для `test_install` показан временной порчей текста
сообщения охраны, а не запуском скриптов без охраны (это задело бы настоящий launchd и порт 8766).
Цена ошибки: тест защищён только проверяемой охраной — закрыто заглушками команд в PATH (M-7).
- 2026-10-18 (финальное ревью) — R-4 (design #15): из находок финального ревью исправлены в ветке
I-1…I-4, M-1, M-2, M-3, M-5, M-7, M-8, M-12; отложены в follow-up M-4 (порт из `config.json` в
`install.sh`/`uninstall.sh`), M-6 (ротация `panel.log`), M-9 (мёртвый код `build_tree`), M-11
(область нажатия 44 pt — оценить на приёмке, Task 6 Step 6). I-3 (нет проверки Host/Sec-Fetch-Site)
существовал до ветки, но исправлен сразу: служба теперь работает постоянно и открывается из
браузера. Цена ошибки: отложенные пункты — мелкие неудобства, не потеря данных.
- 2026-10-18 (финальное ревью) — R-5 (design #16): запрос без заголовка `Host` или с `Host` без порта
получает 403 (сравнение строго с `127.0.0.1:<порт>` / `localhost:<порт>`, порт берётся из сокета).
Обоснование: браузеры, web view iTerm2 и туннель Termius всегда шлют `Host` с портом. Цена ошибки:
клиент без `Host` (голый HTTP/1.0) не откроет панель — ослабить проверку.
- 2026-10-18 (финальное ревью) — R-6 (design #17): тесты к исправлениям написаны без отдельного шага
RED; вместо этого новые тесты прогнаны на коде до исправлений (2e8ab31) — все 6 новых тестов
(I-1, I-3 ×2, I-4, M-2, M-12) там падают. Цена ошибки: нет.
- 2026-10-18 (приёмка) — R-7 (design #18): служба получает `PATH` той оболочки, из которой запущен
`install.sh` (`EnvironmentVariables.PATH` в plist). Обоснование: tmux отдаёт новой панели `PATH`
вызвавшего `split-window`, а у launchd `PATH=/usr/bin:/bin:/usr/sbin:/sbin` — `exec lf` не находился,
панель «⎇ папка» мигала и закрывалась (H-1). Так панели, открытые службой, видят то же, что и
терминал (редактор, превьюеры lf). Цена ошибки: если `install.sh` запущен из урезанного окружения,
`lf` снова не найдётся — README подсказывает перезапустить установщик из обычного терминала.
## Результаты приёмки (Task 6, 2026-10-18)
Проверял человек с iPad (Termius, SSH + tmux) и контроллер из сессии Claude Code на том же Mac.
- Step 1 — ок: служба `com.andrey.claude-agents` запущена launchd (родитель PID 1, python из
`/opt/homebrew/bin/python3`), один слушатель `127.0.0.1:8766`; лимиты и баланс загрузились,
запроса доступа к Keychain не было. Чужой `Host` → 403.
- Step 2 — ок: iTerm2 закрыт (⌘Q через `osascript`), с iPad через туннель Termius видны дерево,
лимиты и баланс; tmux-сервер отвязан от iTerm2 (PPID 1) и пережил закрытие.
- Step 3 — «↗ вкладка» ок; «⎇ папка» сначала мигала разделителем и закрывалась (H-1, причина —
`PATH` службы), после c810f5a и переустановки — ок.
- Step 4 — ок: после `kill` служба снова отвечает 200 примерно через 2 с.
- Step 5 — ок: после `open -a iTerm` поднялся один процесс регистрации, порт держит служба;
Toolbelt → Claude показывает страницу службы (проверено человеком за Mac).
- Step 6 — ок: светлый образец, автоперезагрузка не закрывает строку образцов (I-4), «сброс»,
«свой цвет» открывает палитру iPad. Размер областей нажатия (M-11) жалоб не вызвал.
- Step 7 — ок: выбранный в Toolbelt фон сохраняется после перезапуска iTerm2 (`localStorage` web
view переживает перезапуск).
- Попутно: при проверке человек случайно завершил процесс iTerm2, вместе с которым погас tmux-сервер
(его запускает интеграция `tmux -CC`). Служба этого не заметила — тот же PID, страница отвечает 200.
- Step 8 — ок, три повторных запуска: одна служба, `bootstrap` без «Bootstrap failed» (M-5 не
проявился), один процесс регистрации (M-1), 9 хуков в `settings.json` без дублей.
- Step 9 — ок с `SSH_CONNECTION=test`: отказ «за самим Mac» до каких-либо действий. Наблюдение:
в окне tmux, открытом из Termius, `SSH_CONNECTION` пуст, и охрана не срабатывает (подтверждает
M-3); установка из такого окна проходит нормально, т. к. tmux-сервер живёт в GUI-сессии.
- Step 10 — не выполнялся (по желанию).
- Наблюдение O-3: во время приёмки `./install.sh` один раз запустили из основного checkout (main,
старая версия без охраны) — он перезаписал скрипт AutoLaunch старым сервером; launchd сразу
поднял службу. Исправлено повторным `install.sh` из ветки; после merge неактуально.

View File

@ -0,0 +1,44 @@
# Proposal
## Why
Панель «Claude» сейчас живёт только внутри iTerm2: её HTTP-сервер на `127.0.0.1:8766` поднимает
скрипт AutoLaunch, и когда iTerm2 закрыт, страницы нет. Пользователь смотрит панель с iPad —
Termius держит SSH-туннель к `127.0.0.1:8766` на Mac, страница открыта в Safari, а сами сессии
Claude Code крутятся в tmux, которому iTerm2 не нужен. Панель должна быть доступна, пока Mac
включён и пользователь вошёл в систему, независимо от iTerm2. Заодно тема по умолчанию
(Solarized dark) в Safari на iPad «мозолит глаза», и её нужно менять прямо на странице.
## What Changes
- Новый фоновый режим панели (`--headless`): HTTP-сервер, дерево сессий, лимиты и баланс без
iTerm2. Запускается LaunchAgent'ом при входе в систему, перезапускается при падении.
- Порт 8766 всегда занимает только фоновый сервер. Скрипт AutoLaunch в iTerm2 больше не поднимает
сервер, а только регистрирует web view «Claude» в Toolbelt на тот же адрес.
- **BREAKING**: подстройка цветов и шрифта под профиль iTerm2 убрана; ключи `use_profile_theme`
и `theme_variant` в `config.json` больше ничего не делают.
- **BREAKING**: возможности, которым нужна живая связь с iTerm2, уходят: названия вкладок iTerm2 для
сессий вне tmux, переход во вкладку iTerm2 для сессий вне tmux, переключение панелей `tmux -CC`,
открытие `lf` в разделённой панели iTerm2 для сессий вне tmux. Сессии в обычном tmux работают
как раньше (имена, переход, `lf` — через tmux).
- Новая настройка фона на клиенте: шестерёнка ⚙ в нижней строке панели раскрывает образцы фона,
«свой цвет» и «сброс». Выбор хранится на устройстве (браузере/web view), текст подстраивается.
- `install.sh` ставит и запускает LaunchAgent, `uninstall.sh` его останавливает и удаляет.
## Capabilities
### New Capabilities
- `panel-service`: где и как работает сервер панели — фоновая служба без iTerm2, единственный
владелец порта, регистрация в Toolbelt, установка/удаление.
- `panel-appearance`: как пользователь меняет фон панели на своём устройстве.
### Modified Capabilities
<!-- openspec/specs/ пуст — изменяемых спек нет -->
## Impact
- `files/claude_agents.py`: точка входа с двумя режимами (фоновый сервер / регистратор Toolbelt),
удаление `theme_poller` и чтения профиля, клиентский JS/CSS для фона в `render_page`.
- `install.sh`, `uninstall.sh`: LaunchAgent (`~/Library/LaunchAgents/`), лог службы.
- `files/config.example.json`, README.md: убрать ключи темы, описать фоновую службу и шестерёнку.
- Внешние зависимости не добавляются (только стандартная библиотека Python, `launchctl`).

View File

@ -0,0 +1,65 @@
# Spec Delta
## Purpose
Позволяет пользователю выбрать фон панели прямо на странице, отдельно на каждом устройстве
(Safari на iPad, Toolbelt на Mac), чтобы панель не резала глаза рядом с его терминалом.
## ADDED Requirements
### Requirement: Шестерёнка настройки фона
Нижняя строка панели (рядом с «обновлено ЧЧ:ММ:СС») SHALL содержать значок ⚙. Тап по нему SHALL
раскрывать строку с образцами фона, кнопкой «свой цвет» и кнопкой «сброс»; повторный тап SHALL её
сворачивать. В свёрнутом виде, кроме значка, ничего нового на странице не видно.
#### Scenario: Раскрыть и свернуть
- **WHEN** пользователь нажимает ⚙
- **THEN** появляется строка образцов; при повторном нажатии она скрывается
#### Scenario: Попадание пальцем
- **WHEN** страница открыта в Safari на iPad
- **THEN** ⚙, образцы и кнопки нажимаются пальцем без промахов (не мельче привычных элементов
управления iPadOS)
### Requirement: Выбор фона
Выбор образца или своего цвета SHALL сразу менять фон всей панели, включая окно сообщений. Цвет
основного текста, приглушённого текста, линий дерева и разделителей SHALL подстраиваться под
яркость фона, чтобы оставаться читаемым. Цвета статусов и лимитов (зелёный, жёлтый, красный)
SHALL оставаться прежними.
#### Scenario: Светлый фон
- **WHEN** пользователь выбирает светлый образец
- **THEN** фон становится светлым, а текст — тёмным и читаемым
#### Scenario: Свой цвет
- **WHEN** пользователь нажимает «свой цвет» и выбирает цвет в системной палитре
- **THEN** фон панели становится этим цветом, текст подстраивается
### Requirement: Выбор хранится на устройстве
Выбранный фон SHALL сохраняться средствами того браузера или web view, где он выбран, и SHALL
применяться при каждом открытии и автоматической перезагрузке страницы без мелькания темы по
умолчанию. Выбор на одном устройстве SHALL NOT влиять на другие. Если клиент не сохраняет данные
страницы между своими перезапусками (возможно, web view Toolbelt), после перезапуска клиента
показывается тема по умолчанию; на сервере выбор не хранится.
#### Scenario: Клиент без постоянного хранилища
- **WHEN** выбор фона сделан в Toolbelt, а web view Toolbelt не сохраняет данные между
перезапусками iTerm2, и iTerm2 перезапущен
- **THEN** панель в Toolbelt показывает тему по умолчанию
#### Scenario: Перезагрузка страницы
- **WHEN** пользователь выбрал фон в Safari на iPad и страница перезагрузилась (сама раз в минуту
или вручную)
- **THEN** сразу отображается выбранный фон
#### Scenario: Разные устройства
- **WHEN** в Safari на iPad выбран светлый фон
- **THEN** панель в Toolbelt на Mac сохраняет свой фон
### Requirement: Сброс к теме по умолчанию
«Сброс» SHALL удалять сохранённый выбор и возвращать тему, которую отдаёт сервер. Тема по умолчанию
SHALL выглядеть как нынешний профиль iTerm2 пользователя (`Chat`: фон `#002b36`, текст `#aacace`).
#### Scenario: Сброс
- **WHEN** пользователь нажимает «сброс»
- **THEN** панель показывает тему по умолчанию, и после перезагрузки она остаётся по умолчанию

View File

@ -0,0 +1,111 @@
# Spec Delta
## Purpose
Определяет, где и как работает сервер панели «Claude»: фоновая служба пользователя, не зависящая
от iTerm2, единственный владелец порта панели, тонкая регистрация в Toolbelt и установка/удаление.
## ADDED Requirements
### Requirement: Сервер панели работает без iTerm2
Сервер панели SHALL отдавать страницу, дерево сессий, лимиты и баланс на `127.0.0.1:<port>`
(по умолчанию 8766), пока пользователь вошёл в систему, независимо от того, запущен ли iTerm2.
#### Scenario: iTerm2 не запущен
- **WHEN** пользователь вошёл в систему, iTerm2 не запущен, а сессия Claude Code работает в tmux
- **THEN** `http://127.0.0.1:8766/` отвечает 200 и показывает эту сессию с её субагентами, моделью,
контекстом и worktree, а нижний блок показывает лимиты и баланс
#### Scenario: Имена сессий в tmux
- **WHEN** сессия Claude Code работает в панели tmux
- **THEN** её имя берётся из заголовка панели tmux или из `сессия:окно имя_окна`, как и раньше
### Requirement: Служба стартует при входе и перезапускается при падении
Сервер панели SHALL запускаться автоматически при входе пользователя в систему и SHALL
перезапускаться, если его процесс завершился.
#### Scenario: Вход в систему
- **WHEN** пользователь входит в систему на Mac после перезагрузки
- **THEN** сервер панели запускается без открытия iTerm2 и каких-либо действий пользователя
#### Scenario: Падение процесса
- **WHEN** процесс сервера панели завершился (ошибка, `kill`)
- **THEN** он снова запущен и отвечает на порту в пределах нескольких десятков секунд
### Requirement: Порт панели занимает один процесс
На порту панели SHALL работать ровно один сервер; лимиты и баланс SHALL опрашиваться одним
процессом, чтобы не удваивать запросы к API.
#### Scenario: iTerm2 запускается при работающей службе
- **WHEN** служба уже отвечает на порту и пользователь запускает iTerm2
- **THEN** второй сервер не запускается, в API лимитов идёт один поток запросов, а в Toolbelt
появляется панель «Claude», показывающая ту же страницу
### Requirement: Toolbelt показывает панель фоновой службы
Скрипт панели в iTerm2 SHALL регистрировать web view «Claude» в Toolbelt, указывающий на адрес
фоновой службы, и SHALL NOT поднимать собственный сервер.
#### Scenario: Toolbelt при запущенной службе
- **WHEN** iTerm2 запущен и пользователь выбирает Toolbelt → Claude
- **THEN** панель показывает ту же страницу, что и `http://127.0.0.1:8766/` в браузере
#### Scenario: Служба не отвечает при старте iTerm2
- **WHEN** iTerm2 стартует, а служба на порту не отвечает
- **THEN** скрипт просит launchd запустить службу и всё равно регистрирует web view
### Requirement: Сервер доступен только локально
Сервер панели SHALL слушать только `127.0.0.1`; доступ с других устройств возможен только через
туннель (например, SSH local forward).
#### Scenario: Запрос из локальной сети
- **WHEN** устройство в той же сети обращается к Mac по его LAN-адресу на порт панели
- **THEN** соединение не устанавливается
### Requirement: Действия по клику работают через tmux
Переход к сессии и открытие worktree по клику SHALL выполняться средствами tmux для сессий в tmux,
независимо от iTerm2 и от того, на каком устройстве открыта страница.
#### Scenario: Переход к сессии с iPad
- **WHEN** пользователь с iPad (страница через туннель) нажимает «↗ вкладка» у сессии в tmux
- **THEN** клиент tmux переключается на окно и панель этой сессии
#### Scenario: Открытие worktree с iPad
- **WHEN** пользователь нажимает `⎇ папка` у сессии в tmux
- **THEN** в том же окне tmux открывается новая панель с файловым менеджером в этой папке
### Requirement: Установка и удаление службы
`install.sh` SHALL устанавливать, (пере)запускать службу и останавливать прежний сервер, державший
порт; повторный запуск SHALL быть безопасным. `uninstall.sh` SHALL останавливать и удалять службу.
#### Scenario: Обновление старой установки
- **WHEN** у пользователя стоит прежняя версия (сервер внутри iTerm2) и он запускает `install.sh`
- **THEN** прежний сервер остановлен, служба установлена и отвечает на порту, а iTerm2-скрипт
обновлён до регистратора
#### Scenario: Повторная установка
- **WHEN** `install.sh` запускается второй раз подряд
- **THEN** служба одна, отвечает на порту, записи хуков в `settings.json` не задублированы
#### Scenario: Запуск по SSH
- **WHEN** `install.sh` или `uninstall.sh` запущен из SSH-сессии
- **THEN** скрипт сообщает, что его нужно запускать за самим Mac, и завершается с ненулевым кодом,
ничего не изменив: прежний сервер продолжает работать
#### Scenario: Удаление
- **WHEN** пользователь запускает `uninstall.sh`
- **THEN** служба остановлена, её описание удалено, порт свободен и после повторного входа в систему
служба не запускается
## REMOVED Requirements
### Requirement: Тема панели из профиля iTerm2
**Reason**: Сервер больше не связан с iTerm2; фон задаётся на клиенте (см. `panel-appearance`).
**Migration**: Выставить фон шестерёнкой ⚙ внизу панели; ключи `use_profile_theme` и
`theme_variant` в `config.json` удалить (они игнорируются).
### Requirement: Возможности iTerm2 для сессий вне tmux
**Reason**: Нужна живая связь сервера с iTerm2, а сервер теперь фоновый; пользователь держит все
сессии в tmux.
**Migration**: Запускать `claude` внутри tmux. Для сессий вне tmux имя берётся из заголовка сессии
Claude Code или папки, переход во вкладку и `lf` в разделённой панели iTerm2 недоступны; режим
`tmux -CC` не поддерживается для перехода.

81
tests/test_appearance.py Normal file
View File

@ -0,0 +1,81 @@
import json
import os
import shutil
import subprocess
import sys
import time
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
import claude_agents as ca # noqa: E402
class Page(unittest.TestCase):
def test_page_applies_saved_bg_before_body(self):
html = ca.render_page()
body = html.index("<body>")
self.assertLess(html.index("claude-panel-bg"), body)
self.assertIn(ca.CLIENT_COLORS_JS, html)
self.assertIn("try {", html[:body])
def test_page_has_bg_controls(self):
html = ca.render_page()
for marker in ('id="bgctl"', 'id="gear"', 'id="bgrow"', 'type="color"', 'id="bg-reset"'):
self.assertIn(marker, html)
self.assertEqual(ca.BG_PRESETS, ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3"))
for hex_ in ca.BG_PRESETS:
self.assertIn(f'data-bg="{hex_}"', html)
limits_end = html.index('<div id="limits">') + len('<div id="limits">') + len(ca.render_limits())
self.assertGreater(html.index('id="bgctl"'), limits_end)
def test_reload_skipped_while_colour_row_open(self):
html = ca.render_page()
self.assertIn("if (!cur && !bgrow.classList.contains('open')) location.reload()", html)
self.assertLess(html.index("const gear = document.getElementById('gear'), bgrow"),
html.index("location.reload()"))
def test_limits_refresh_reads_body_before_moving_gear(self):
html = ca.render_page()
start = html.index("async function refreshLimits")
body = html[start:html.index("setInterval(refreshLimits")]
self.assertLess(body.index("await r.text()"), body.index("appendChild(gear)"))
class Limits(unittest.TestCase):
def setUp(self):
self.saved = dict(ca.state)
def tearDown(self):
ca.state.clear()
ca.state.update(self.saved)
def test_limits_foot_has_gear_slot(self):
ca.state["usage_updated"], ca.state["usage_error"] = time.time(), None
self.assertIn('class="gear-slot"', ca.render_limits())
@unittest.skipUnless(shutil.which("node"), "нет node")
class ClientColors(unittest.TestCase):
def test_panel_colors_js(self):
js = ca.CLIENT_COLORS_JS + (
"\nconsole.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000'),"
" panelColors('nope'), panelColors('#002b36')]))")
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout
self.assertEqual(json.loads(out), [
{"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"},
{"bg": "#000000", "fg": "#aacace", "dim": "#66797c"},
None,
{"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"},
])
def test_text_colour_by_contrast(self):
cases = {"#a0a0a0": "#3b4a50", "#999999": "#3b4a50", "#6699cc": "#3b4a50", "#333333": "#aacace"}
js = ca.CLIENT_COLORS_JS + (
"\nconsole.log(JSON.stringify(%s.map(c => panelColors(c).fg)))" % json.dumps(list(cases)))
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout
self.assertEqual(json.loads(out), list(cases.values()))
if __name__ == "__main__":
unittest.main()

88
tests/test_core.py Normal file
View File

@ -0,0 +1,88 @@
import asyncio
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
import claude_agents as ca # noqa: E402
class DefaultTheme(unittest.TestCase):
def test_default_theme_matches_chat_profile(self):
self.assertEqual(ca.DEFAULT_THEME, {
"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13})
class NoITermCoupling(unittest.TestCase):
def test_iterm_coupling_removed(self):
for name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT",
"_app", "_mix", "_hex"):
self.assertFalse(hasattr(ca, name), name)
self.assertNotIn("use_profile_theme", ca.CONFIG_KEYS)
self.assertNotIn("theme_variant", ca.CONFIG_KEYS)
class Config(unittest.TestCase):
def setUp(self):
self.saved = (ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime)
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime = self.saved
self.tmp.cleanup()
def test_old_theme_keys_ignored(self):
path = os.path.join(self.tmp.name, "config.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"use_profile_theme": False, "theme_variant": "dark", "poll_sec": 2}, f)
ca.CONFIG_PATH, ca._config_mtime = path, None
ca.load_config()
self.assertIsNone(ca.state.get("config_error"))
self.assertEqual(ca.POLL_SEC, 2.0)
class Actions(unittest.TestCase):
def setUp(self):
self.calls = []
self.saved = (ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"])
ca._tmux = lambda sock, *args: self.calls.append((sock, *args))
ca._last_open.clear()
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"] = self.saved
self.tmp.cleanup()
def test_activate_tmux_target(self):
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
asyncio.run(ca.activate("tmux:/tmp/s|%3"))
self.assertEqual(self.calls, [("/tmp/s", "switch-client", "-t", "%3"),
("/tmp/s", "select-window", "-t", "%3"),
("/tmp/s", "select-pane", "-t", "%3")])
def test_activate_non_tmux_is_noop(self):
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
asyncio.run(ca.activate("w0t0p0:ABC"))
self.assertEqual(self.calls, [])
def test_open_worktree_non_tmux_is_noop(self):
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
ca.known_roots = lambda: {self.tmp.name}
asyncio.run(ca.open_worktree("w0t0p0:ABC", self.tmp.name))
self.assertEqual(self.calls, [])
def test_open_worktree_tmux_splits(self):
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
ca.known_roots = lambda: {self.tmp.name}
ca.FILE_MANAGER = "lf"
asyncio.run(ca.open_worktree("tmux:/tmp/s|%3", self.tmp.name))
self.assertEqual(self.calls, [("/tmp/s", "split-window", "-h", "-t", "%3",
"-c", self.tmp.name, "exec lf")])
if __name__ == "__main__":
unittest.main()

40
tests/test_install.py Normal file
View File

@ -0,0 +1,40 @@
import os
import subprocess
import tempfile
import unittest
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
STUBBED = ("launchctl", "lsof", "defaults", "osascript", "pgrep", "pkill", "curl")
SSH_ENVS = ({"SSH_CONNECTION": "10.0.0.2 50000 10.0.0.1 22"}, {"SSH_TTY": "/dev/ttys009"})
class RefuseOverSsh(unittest.TestCase):
"""Только охранная проверка: реальная установка в этих тестах не запускается."""
def assert_refused(self, *cmd):
for extra in SSH_ENVS:
with self.subTest(cmd=cmd, env=extra), tempfile.TemporaryDirectory() as home, \
tempfile.TemporaryDirectory() as stubs:
# второй рубеж: даже если охрана не сработает, системные команды — заглушки
for name in STUBBED:
path = os.path.join(stubs, name)
with open(path, "w") as f:
f.write("#!/bin/sh\nexit 1\n")
os.chmod(path, 0o755)
env = {k: v for k, v in os.environ.items() if k not in ("SSH_CONNECTION", "SSH_TTY")}
env.update(HOME=home, PATH=stubs + os.pathsep + env.get("PATH", ""), **extra)
r = subprocess.run(["bash", *cmd], cwd=ROOT, env=env, capture_output=True, text=True)
self.assertNotEqual(r.returncode, 0)
self.assertIn("за самим Mac", r.stdout + r.stderr)
self.assertEqual(os.listdir(home), [])
def test_install_refuses_over_ssh(self):
self.assert_refused("install.sh")
def test_uninstall_refuses_over_ssh(self):
self.assert_refused("uninstall.sh")
self.assert_refused("uninstall.sh", "--purge")
if __name__ == "__main__":
unittest.main()

51
tests/test_launchagent.py Normal file
View File

@ -0,0 +1,51 @@
import os
import plistlib
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, os.path.join(ROOT, "files"))
import claude_agents # noqa: E402
import launchagent # noqa: E402
SCRIPT = os.path.join(ROOT, "files", "launchagent.py")
class Plist(unittest.TestCase):
def test_label_matches_server(self):
self.assertEqual(launchagent.LABEL, "com.andrey.claude-agents")
self.assertEqual(launchagent.LABEL, claude_agents.SERVICE_LABEL)
def test_plist_shape(self):
self.assertEqual(
launchagent.plist("/opt/homebrew/bin/python3", "/x/claude_agents.py", "/l/panel.log",
"/opt/homebrew/bin:/usr/bin:/bin"),
{"Label": "com.andrey.claude-agents",
"ProgramArguments": ["/opt/homebrew/bin/python3", "/x/claude_agents.py", "--headless"],
"EnvironmentVariables": {"PATH": "/opt/homebrew/bin:/usr/bin:/bin"},
"RunAtLoad": True, "KeepAlive": True,
"StandardOutPath": "/l/panel.log", "StandardErrorPath": "/l/panel.log"})
class Cli(unittest.TestCase):
def test_cli_writes_plist_with_spaces(self):
with tempfile.TemporaryDirectory() as tmp:
target = f"{tmp}/LaunchAgents/a.plist"
args = ("/usr/bin/python3", f"{tmp}/Application Support/claude_agents.py",
f"{tmp}/Logs/claude-agents/panel.log", "/opt/homebrew/bin:/usr/bin:/bin")
r = subprocess.run([sys.executable, SCRIPT, "write", target, *args],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
with open(target, "rb") as f:
self.assertEqual(plistlib.load(f), launchagent.plist(*args))
self.assertTrue(os.path.isdir(f"{tmp}/Logs/claude-agents"))
def test_cli_bad_args(self):
r = subprocess.run([sys.executable, SCRIPT, "write"], capture_output=True, text=True)
self.assertEqual(r.returncode, 2)
if __name__ == "__main__":
unittest.main()

227
tests/test_service.py Normal file
View File

@ -0,0 +1,227 @@
import asyncio
import json
import os
import socket
import sys
import tempfile
import types
import unittest
import urllib.error
import urllib.request
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
import claude_agents as ca # noqa: E402
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def http_get(port, path):
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=3) as r:
return r.status, r.read().decode("utf-8")
class PortAlive(unittest.TestCase):
def test_port_alive(self):
s = socket.socket()
s.bind(("127.0.0.1", 0))
s.listen()
port = s.getsockname()[1]
self.assertTrue(ca.port_alive(port))
s.close()
self.assertFalse(ca.port_alive(port))
class Serve(unittest.TestCase):
def setUp(self):
self.port = free_port()
patches = [
mock.patch.object(ca, "PORT", self.port),
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
mock.patch.object(ca, "LOG_DIR", self._tmpdir()),
mock.patch.object(ca, "load_sessions", lambda: []),
mock.patch.object(ca, "fetch_usage", lambda: {}),
mock.patch.object(ca, "fetch_balance", lambda: ({}, "test")),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
def _tmpdir(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
return d.name
async def _with_server(self, check):
task = asyncio.create_task(ca.serve())
loop = asyncio.get_running_loop()
try:
for _ in range(60):
if ca.port_alive(self.port):
break
await asyncio.sleep(0.05)
return await check(loop)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def test_serve_answers(self):
async def check(loop):
status, body = await loop.run_in_executor(None, http_get, self.port, "/")
self.assertEqual(status, 200)
self.assertIn('<div id="root">', body)
status, raw = await loop.run_in_executor(None, http_get, self.port, "/raw")
self.assertIn("tree", json.loads(raw))
asyncio.run(self._with_server(check))
self.assertFalse(ca.port_alive(self.port))
def test_serve_survives_usage_failure(self):
def no_token():
raise FileNotFoundError("no credentials")
async def check(loop):
body = ""
for _ in range(60):
_, body = await loop.run_in_executor(None, http_get, self.port, "/limits")
if "Не найден токен Claude Code" in body:
break
await asyncio.sleep(0.05)
self.assertIn("Не найден токен Claude Code", body)
status, _ = await loop.run_in_executor(None, http_get, self.port, "/")
self.assertEqual(status, 200)
with mock.patch.object(ca, "fetch_usage", no_token):
asyncio.run(self._with_server(check))
def test_host_header_checked(self):
def get(host, path="/", **extra):
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", headers={"Host": host, **extra})
try:
with urllib.request.urlopen(req, timeout=3) as r:
return r.status
except urllib.error.HTTPError as e:
e.close()
return e.code
async def check(loop):
self.assertEqual(await loop.run_in_executor(None, get, "evil.example:%d" % self.port), 403)
self.assertEqual(await loop.run_in_executor(None, get, "127.0.0.1"), 403)
self.assertEqual(await loop.run_in_executor(None, get, "127.0.0.1:%d" % self.port), 200)
self.assertEqual(await loop.run_in_executor(None, get, "LocalHost:%d" % self.port), 200)
self.assertEqual(await loop.run_in_executor(None, get, "evil.example:%d" % self.port, "/raw"), 403)
asyncio.run(self._with_server(check))
def test_cross_site_actions_forbidden(self):
def get(path, site):
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", headers={"Sec-Fetch-Site": site})
try:
with urllib.request.urlopen(req, timeout=3) as r:
return r.status
except urllib.error.HTTPError as e:
e.close()
return e.code
async def check(loop):
for path in ("/activate?id=x", "/open-wt?id=x&path=/tmp"):
self.assertEqual(await loop.run_in_executor(None, get, path, "cross-site"), 403)
with mock.patch.object(ca, "activate", mock.AsyncMock()) as act, \
mock.patch.object(ca, "open_worktree", mock.AsyncMock()) as wt:
asyncio.run(self._with_server(check))
act.assert_not_called()
wt.assert_not_called()
def test_serve_busy_port_raises(self):
s = socket.socket()
s.bind(("127.0.0.1", self.port))
s.listen()
self.addCleanup(s.close)
with self.assertRaises(OSError):
asyncio.run(ca.serve())
class RegisterToolbelt(unittest.TestCase):
def setUp(self):
self.registered = []
self.kicks = []
async def register(connection, **kwargs):
self.registered.append(kwargs)
fake = types.SimpleNamespace(tool=types.SimpleNamespace(async_register_web_view_tool=register))
patches = [
mock.patch.object(ca, "iterm2", fake),
mock.patch.object(ca, "PORT", 8766),
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
mock.patch.object(ca, "kickstart_service", lambda: self.kicks.append(1)),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
def assert_registered(self):
self.assertEqual(self.registered, [{
"display_name": "Claude", "identifier": "com.andrey.claude-agents",
"reveal_if_already_registered": False, "url": "http://127.0.0.1:8766/"}])
def test_register_toolbelt_kickstarts_when_port_dead(self):
async def no_sleep(sec):
pass
with mock.patch.object(ca, "port_alive", lambda *a, **k: False), \
mock.patch.object(ca.asyncio, "sleep", no_sleep):
asyncio.run(ca.register_toolbelt(object()))
self.assertEqual(self.kicks, [1])
self.assert_registered()
def test_register_toolbelt_waits_for_service(self):
alive = iter([False, False, False, True]) # проверка в начале + ожидание после kickstart
sleeps = []
async def fake_sleep(sec):
sleeps.append(sec)
with mock.patch.object(ca, "port_alive", lambda *a, **k: next(alive)), \
mock.patch.object(ca.asyncio, "sleep", fake_sleep):
asyncio.run(ca.register_toolbelt(object()))
self.assertEqual(sleeps, [0.25, 0.25])
self.assert_registered()
def test_register_toolbelt_skips_kickstart_when_alive(self):
with mock.patch.object(ca, "port_alive", lambda *a, **k: True):
asyncio.run(ca.register_toolbelt(object()))
self.assertEqual(self.kicks, [])
self.assert_registered()
class Kickstart(unittest.TestCase):
def test_kickstart_command(self):
with mock.patch.object(ca.subprocess, "run") as run:
ca.kickstart_service()
self.assertEqual(run.call_args.args[0],
["launchctl", "kickstart", f"gui/{os.getuid()}/com.andrey.claude-agents"])
with mock.patch.object(ca.subprocess, "run", side_effect=OSError("no launchctl")):
ca.kickstart_service() # не бросает
class Run(unittest.TestCase):
def test_run_headless_calls_serve(self):
called = []
async def fake_serve():
called.append(1)
with mock.patch.object(ca, "serve", fake_serve):
ca.run(["--headless"])
self.assertEqual(called, [1])
def test_run_without_iterm_exits(self):
with mock.patch.object(ca, "iterm2", None):
with self.assertRaises(SystemExit):
ca.run([])
if __name__ == "__main__":
unittest.main()

View File

@ -1,14 +1,26 @@
#!/bin/bash
# Удаление панели «Claude». С --purge удаляет ещё настройки и логи хука.
# Удаление панели «Claude». С --purge удаляет ещё настройки и логи.
set -euo pipefail
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
[ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] \
|| die "запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить. Если вы и так за Mac (например, в панели tmux, открытой раньше из SSH), запустите в обычной вкладке iTerm2 или после unset SSH_CONNECTION SSH_TTY"
HERE="$(cd "$(dirname "$0")" && pwd)"
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
SETTINGS="$HOME/.claude/settings.json"
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$-uninstall"
PORT=8766
LABEL=com.andrey.claude-agents
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
# служба — первой, иначе launchd поднимет сервер снова
launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 && ok "служба остановлена" || true
rm -f "$PLIST"
rm -rf "$HOME/.local/share/claude-agents"
ok "служба удалена"
if command -v lsof >/dev/null; then
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
@ -25,9 +37,9 @@ fi
rm -f "$HOME/.claude/hooks/agents_panel_hook.sh" && ok "хук удалён"
if [ "${1:-}" = "--purge" ]; then
rm -rf "$HOME/.config/claude-agents" "$HOME/.cache/claude-agents"
rm -rf "$HOME/.config/claude-agents" "$HOME/.cache/claude-agents" "$HOME/Library/Logs/claude-agents"
ok "настройки и логи удалены"
else
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents) оставлены; --purge удалит и их."
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents, ~/Library/Logs/claude-agents) оставлены; --purge удалит и их."
fi
echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2."