Compare commits

...

10 Commits

Author SHA1 Message Date
Andrey Epifancev
6626bb4668 spec: record execution and review rulings for headless panel (#4, M-10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:20:29 +04:00
Andrey Epifancev
1613246b57 fix: Host/Origin check, reload guard, gear flicker, test isolation, toolbelt wait (#4, I-3, I-4, M-2, M-8, M-12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:19:35 +04:00
Andrey Epifancev
ac330995fd fix: install robustness: PATH python3, single registrar, bootstrap retry, SSH hint (#4, I-2, M-1, M-3, M-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:19:08 +04:00
Andrey Epifancev
ed159eadff fix: stub system commands in install tests (#4, M-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:19:08 +04:00
Andrey Epifancev
081b4691fc fix: pick panel text colour by higher WCAG contrast (#4, I-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:49 +04:00
Andrey Epifancev
2e8ab31bbd feat: client-side panel background with gear control (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:00:08 +04:00
Andrey Epifancev
7958314547 feat: install panel as launchd service, refuse install over SSH (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:57:08 +04:00
Andrey Epifancev
88124cf6d8 feat: LaunchAgent plist generator for panel service (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:55:01 +04:00
Andrey Epifancev
43a76eab19 feat: headless panel server and Toolbelt registrar entry points (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:54:29 +04:00
Andrey Epifancev
4686cd1a8f refactor: drop live iTerm2 coupling from panel server (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:53:36 +04:00
15 changed files with 917 additions and 229 deletions

1
.gitignore vendored
View File

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

View File

@ -1,15 +1,17 @@
# Архитектура # Архитектура
Два процесса, связанные только файлами в `~/.cache/claude-agents/`: хук внутри каждой сессии Три части, связанные файлами в `~/.cache/claude-agents/` и адресом `127.0.0.1:8766`: хук внутри
Claude Code пишет события, скрипт iTerm2 их читает и рисует панель. каждой сессии Claude Code пишет события, служба launchd их читает и отдаёт страницу, скрипт iTerm2
показывает эту страницу в Toolbelt. iTerm2 для работы панели не нужен.
``` ```
claude (каждая сессия) ──hook──▶ agents_panel_hook.sh ──append──▶ ~/.cache/claude-agents/<sid>.jsonl claude (каждая сессия) ──hook──▶ agents_panel_hook.sh ──append──▶ ~/.cache/claude-agents/<sid>.jsonl
│ │
iTerm2 AutoLaunch: claude_agents.py ◀───── read (poll 1.5 s) ────────────┘ launchd: claude_agents.py --headless ◀──── read (poll 1.5 s) ────────────┘
├─ + iTerm2 API (вкладки, профиль), ps, tmux, транскрипты ~/.claude/projects, git ├─ + ps, tmux, транскрипты ~/.claude/projects, git
├─ + api.anthropic.com oauth/usage, prepaid/credits (OAuth из Keychain) ├─ + 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/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` — лимиты | `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` | — | | `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: без вывода, всегда `exit 0`.
- Панель только читает данные Claude Code (jsonl, транскрипты, `~/.claude.json`) и ничего туда не пишет; - Панель только читает данные Claude Code (jsonl, транскрипты, `~/.claude.json`) и ничего туда не пишет;
её единственная запись — удаление собственных логов старше `keep_logs_days`. её единственная запись — удаление собственных логов старше `keep_logs_days`.
- `claude_agents.py` — только стандартная библиотека + `iterm2`; импорт без iTerm2 должен работать. - Порт занимает только служба; скрипт iTerm2 сервер не поднимает и данные iTerm2 не передаёт.
- `claude_agents.py` — только стандартная библиотека + опционально `iterm2`; импорт без iTerm2 должен работать.
- HTTP слушает только `127.0.0.1`. - HTTP слушает только `127.0.0.1`.
- Фон панели выбирается на клиенте и хранится в браузере/web view; сервер о нём не знает.
## Открытые вопросы ## Открытые вопросы
- Docstring `claude_agents.py` ссылается на несуществующий `settings-hooks.json` (теперь это `settings_hooks.py` + `install.sh`). - После перезагрузки Mac без входа на нём (только SSH) служба не стартует, Keychain заблокирован.
- Автотестов нет; `claude_agents.py` — один файл на 1500 строк. - `claude_agents.py` — один файл на ~1400 строк.

View File

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

View File

@ -1,10 +1,15 @@
# Claude — панель для iTerm2 Toolbelt # Claude — панель сессий Claude Code
Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree), Сверху — дерево активных сессий 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 ```bash
tar xzf claude-panel.tar.gz tar xzf claude-panel.tar.gz
cd claude-panel cd claude-panel
@ -14,27 +19,43 @@ cd claude-panel
Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите
открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное. открытые сессии `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` | | хук Claude Code | `~/.claude/hooks/agents_panel_hook.sh` + записи в `~/.claude/settings.json` |
| пример настроек | `~/.config/claude-agents/config.example.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.
## Работа с панелью ## Работа с панелью
- клик по сессии или агенту — последние сообщения (копятся, пока окно открыто); - клик по сессии или агенту — последние сообщения (копятся, пока окно открыто);
- ⌘-клик по сессии — перейти в её вкладку; - ⌘-клик по сессии — перейти в её панель tmux;
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели; - клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен; - цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
- сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё; - переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
- цвет токенов: жёлтый > 500k, красный > 700k. - цвет токенов: жёлтый > 500k, красный > 700k;
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
## Настройки ## Настройки
@ -43,8 +64,12 @@ 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 не сможет её запустить.
## Удаление ## Удаление

View File

@ -1,22 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Claude — панель для iTerm2 Toolbelt. Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает
iTerm2 Toolbelt.
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов — Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии. название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии.
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage. Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их
~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов. не сообщил, берутся из транскриптов.
Установка: Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd).
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 удалить)
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json. Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта). Изменения подхватываются на лету, кроме port (нужен перезапуск службы).
Отладка: http://127.0.0.1:8766/raw — разобранное состояние. Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
Только стандартная библиотека. Только стандартная библиотека.
@ -27,9 +24,10 @@ import html
import json import json
import os import os
import re import re
import shlex
import shutil import shutil
import socket
import subprocess import subprocess
import sys
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@ -58,9 +56,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого
CONTEXT_CRIT = 700_000 # … и красное CONTEXT_CRIT = 700_000 # … и красное
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree
USE_PROFILE_THEME = True
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
# лимиты подписки и баланс (нижняя часть панели) # лимиты подписки и баланс (нижняя часть панели)
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429 USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
@ -78,8 +74,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме
"keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400), "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"), "agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"),
"file_manager": ("FILE_MANAGER", str), "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_warn": ("CONTEXT_WARN", int),
"context_crit": ("CONTEXT_CRIT", int), "context_crit": ("CONTEXT_CRIT", int),
"history_max_chars": ("HISTORY_MAX_CHARS", int), "history_max_chars": ("HISTORY_MAX_CHARS", int),
@ -112,14 +106,14 @@ def load_config():
state["config_error"] = None state["config_error"] = None
except Exception as e: except Exception as e:
state["config_error"] = f"config.json: {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", "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, "usage": None, "usage_error": None, "usage_updated": None,
"balance": None, "balance_error": None, "balance_via": None} "balance": None, "balance_error": None, "balance_via": None}
_app = None
_server = None _server = None
@ -837,6 +831,35 @@ def _render_tree() -> str:
return "".join(parts) 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: def render_page() -> str:
t = state["theme"] t = state["theme"]
return f"""<!doctype html><html><head><meta charset="utf-8"> 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); }} .fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
.sub {{ color:var(--dim); }} .sub {{ color:var(--dim); }}
.split {{ margin:-2px 0 7px; }} .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); }} .foot.stale {{ color:var(--yellow); }}
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }} .session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }} .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 {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }} .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; }} .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="root">{render_tree()}</div>
<div id="limits">{render_limits()}</div> <div id="limits">{render_limits()}</div>
{render_bgctl()}
<div id="ov"> <div id="ov">
<div class="ov-head"><span class="ov-title"></span> <div class="ov-head"><span class="ov-title"></span>
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span> <span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
@ -943,7 +994,12 @@ def render_page() -> str:
async function refreshLimits() {{ async function refreshLimits() {{
try {{ try {{
const r = await fetch('/limits', {{cache: 'no-store'}}); 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) {{}} }} catch (e) {{}}
}} }}
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
@ -1021,8 +1077,38 @@ def render_page() -> str:
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }}); document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
setInterval(loadMsg, 2000); 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> </script>
</body></html>""" </body></html>"""
@ -1244,31 +1330,12 @@ def render_limits() -> str:
if state["usage_updated"]: if state["usage_updated"]:
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S") upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры 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) return "".join(parts)
# ---------- связь с iTerm2 ---------- # ---------- действия по клику (через tmux) ----------
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
def known_roots(): def known_roots():
roots = set() roots = set()
@ -1286,13 +1353,15 @@ _last_open = {} # path -> time последнего открытия (защи
async def open_worktree(uuid, path): async def open_worktree(uuid, path):
"""Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер. """Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер.
Команда и папка задаются настройками новой панели, а не вводом текста в оболочку: Команда передаётся tmux при создании панели, а не вводом текста в оболочку:
так не возникает гонки «текст отправлен раньше, чем оболочка готова», так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
и панель закрывается сама, когда выходите из lf.""" и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются."""
if path not in known_roots() or not os.path.isdir(path): if path not in known_roots() or not os.path.isdir(path):
return # открываем только папки, которые панель сама показала return # открываем только папки, которые панель сама показала
if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()):
return
now = time.time() now = time.time()
if now - _last_open.get(path, 0) < 3: if now - _last_open.get(path, 0) < 3:
return # повторный клик/запрос — уже открываем return # повторный клик/запрос — уже открываем
@ -1300,28 +1369,10 @@ async def open_worktree(uuid, path):
state["open_log"] = (state.get("open_log") or [])[-9:] + [ state["open_log"] = (state.get("open_log") or [])[-9:] + [
{"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}] {"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) sock, pane = _tmux_target(uuid)
cmd = f"exec {FILE_MANAGER}" cmd = f"exec {FILE_MANAGER}"
await asyncio.get_running_loop().run_in_executor( await asyncio.get_running_loop().run_in_executor(
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd)) 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(): def known_targets():
@ -1340,41 +1391,27 @@ def _tmux(sock, *args):
async def activate(uuid): async def activate(uuid):
if uuid not in known_targets(): if uuid not in known_targets() or not uuid.startswith("tmux:"):
return # только то, что панель сама показала return # только сессии в tmux, которые панель сама показала
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:"):
sock, pane = _tmux_target(uuid) sock, pane = _tmux_target(uuid)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane), for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
("select-pane", "-t", pane)): ("select-pane", "-t", pane)):
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a)) 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 last_cleanup = 0
while True: while True:
try: try:
load_config() load_config()
live = await live_sessions(connection) if connection is not None else None
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
sessions = await loop.run_in_executor(None, load_sessions) sessions = await loop.run_in_executor(None, load_sessions)
alive_now = [x for x in sessions if not x["ended"]] 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]) procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now])
tmux = await loop.run_in_executor( tmux = await loop.run_in_executor(
None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]}) 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 state["error"] = None
if time.time() - last_cleanup > 3600: if time.time() - last_cleanup > 3600:
cleanup_logs() cleanup_logs()
@ -1393,49 +1430,6 @@ def cleanup_logs():
pass 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 ---------- # ---------- HTTP ----------
async def handle(reader, writer): async def handle(reader, writer):
@ -1443,6 +1437,20 @@ async def handle(reader, writer):
request = (await reader.read(2048)).decode("latin-1") request = (await reader.read(2048)).decode("latin-1")
target = request.split(" ", 2)[1] if request.count(" ") >= 2 else "/" target = request.split(" ", 2)[1] if request.count(" ") >= 2 else "/"
url = urllib.parse.urlparse(target) 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" ctype = "text/html; charset=utf-8"
if url.path == "/limits": if url.path == "/limits":
body = render_limits() body = render_limits()
@ -1463,7 +1471,7 @@ async def handle(reader, writer):
body = "ok" body = "ok"
elif url.path == "/raw": elif url.path == "/raw":
body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"), 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"], "usage_error": state["usage_error"], "balance_via": state["balance_via"],
"balance_error": state["balance_error"], "balance_error": state["balance_error"],
"log_files": sorted(os.path.basename(p) for p in "log_files": sorted(os.path.basename(p) for p in
@ -1484,23 +1492,70 @@ async def handle(reader, writer):
writer.close() 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 global _server
load_config() load_config()
_server = await asyncio.start_server(handle, "127.0.0.1", PORT) _server = await asyncio.start_server(handle, "127.0.0.1", PORT)
asyncio.create_task(refresher(connection)) tasks = [asyncio.create_task(c) for c in (refresher(), usage_poller(), balance_poller())]
asyncio.create_task(usage_poller()) try:
asyncio.create_task(balance_poller()) async with _server:
if USE_PROFILE_THEME: await _server.serve_forever()
asyncio.create_task(theme_poller(connection)) 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( await iterm2.tool.async_register_web_view_tool(
connection, connection,
display_name="Claude", display_name="Claude",
identifier="com.andrey.claude-agents", identifier=SERVICE_LABEL,
reveal_if_already_registered=False, reveal_if_already_registered=False,
url=f"http://127.0.0.1:{PORT}/", url=f"http://127.0.0.1:{PORT}/",
) )
if __name__ == "__main__" and iterm2 is not None: def run(argv):
iterm2.run_forever(main) 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, "agent_idle_min": 15,
"poll_sec": 1.5, "poll_sec": 1.5,
"keep_logs_days": 2, "keep_logs_days": 2,
"use_profile_theme": true,
"port": 8766, "port": 8766,
"context_warn": 500000, "context_warn": 500000,
"context_crit": 700000, "context_crit": 700000,
@ -13,6 +12,5 @@
"usage_refresh_sec": 120, "usage_refresh_sec": 120,
"balance_refresh_sec": 600, "balance_refresh_sec": 600,
"show_breakdown": true, "show_breakdown": true,
"show_balance": true, "show_balance": true
"theme_variant": "auto"
} }

39
files/launchagent.py Normal file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Пишет описание службы launchd для сервера панели: запуск при входе, перезапуск при падении.
launchagent.py write <plist> <python> <claude_agents.py> <log>
"""
import os
import plistlib
import sys
LABEL = "com.andrey.claude-agents" # то же, что SERVICE_LABEL в claude_agents.py
def plist(python, script, log):
return {
"Label": LABEL,
"ProgramArguments": [python, script, "--headless"],
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": log,
"StandardErrorPath": log,
}
def main():
if len(sys.argv) != 6 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:])
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), f, fmt=plistlib.FMT_XML)
os.replace(tmp, path)
print(path)
if __name__ == "__main__":
main()

View File

@ -1,8 +1,18 @@
#!/bin/bash #!/bin/bash
# Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов Claude Code, # Установка панели «Claude»: дерево сессий и агентов Claude Code, лимиты подписки и баланс.
# лимиты подписки и баланс. Повторный запуск безопасен — обновляет установленное. # Сервер панели — служба launchd (работает и без iTerm2), iTerm2 показывает её в Toolbelt.
# Повторный запуск безопасен — обновляет установленное.
set -euo pipefail 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)" HERE="$(cd "$(dirname "$0")" && pwd)"
FILES="$HERE/files" FILES="$HERE/files"
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch" AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
@ -11,11 +21,12 @@ SETTINGS="$HOME/.claude/settings.json"
CONFIG_DIR="$HOME/.config/claude-agents" CONFIG_DIR="$HOME/.config/claude-agents"
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$" BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$"
PORT=8766 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/"; } 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" [ "$(uname)" = "Darwin" ] || [ "${CLAUDE_PANEL_ANY_OS:-}" = 1 ] || die "нужен macOS с iTerm2"
python3 -c 'import sys; assert sys.version_info >= (3, 8)' 2>/dev/null \ python3 -c 'import sys; assert sys.version_info >= (3, 8)' 2>/dev/null \
|| die "нужен python3 ≥ 3.8 (xcode-select --install или brew install python)" || 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)" if command -v jq >/dev/null; then ok "jq: $(command -v jq)"
else warn "нет jq — хук без него молча ничего не пишет. Установите: brew install jq"; fi else warn "нет jq — хук без него молча ничего не пишет. Установите: brew install jq"; fi
if [ -d "/Applications/iTerm.app" ] || [ -d "$HOME/Applications/iTerm.app" ]; then ok "iTerm2 найден" 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 ---------- # ---------- хук Claude Code ----------
say "Хук Claude Code" say "Хук Claude Code"
@ -41,7 +53,7 @@ if [ "$added" = "—" ]; then ok "settings.json: хуки уже на месте
else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi
# ---------- скрипт iTerm2 ---------- # ---------- скрипт iTerm2 ----------
say "Скрипт iTerm2" say "Скрипт iTerm2 (регистрация в Toolbelt)"
mkdir -p "$AUTOLAUNCH" mkdir -p "$AUTOLAUNCH"
if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов
backup "$AUTOLAUNCH/claude_usage.py" && rm "$AUTOLAUNCH/claude_usage.py" backup "$AUTOLAUNCH/claude_usage.py" && rm "$AUTOLAUNCH/claude_usage.py"
@ -57,6 +69,32 @@ install -m 644 "$FILES/config.example.json" "$CONFIG_DIR/config.example.json"
if [ -f "$CONFIG_DIR/config.json" ]; then ok "ваш config.json не тронут" if [ -f "$CONFIG_DIR/config.json" ]; then ok "ваш config.json не тронут"
else ok "пример настроек: $CONFIG_DIR/config.example.json (скопируйте в config.json, чтобы менять)"; fi 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"
python3 "$FILES/launchagent.py" write "$PLIST" "$PY" "$SERVICE_DIR/claude_agents.py" "$SERVICE_LOG" >/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 ---------- # ---------- Python API в iTerm2 ----------
say "iTerm2" say "iTerm2"
iterm_running=0 iterm_running=0
@ -74,28 +112,24 @@ else
ok "Python API уже включён" ok "Python API уже включён"
fi 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 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 if osascript -e 'tell application "iTerm2" to launch API script named "claude_agents"' >/dev/null 2>&1; then
ok "панель запущена" ok "панель зарегистрирована в Toolbelt"
else else
warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py" warn "не смог зарегистрировать сам — Scripts → AutoLaunch → claude_agents.py"
fi fi
else else
warn "панель запустится сама при следующем запуске iTerm2" warn "в Toolbelt панель появится при следующем запуске iTerm2"
fi fi
cat <<EOF cat <<EOF
Готово. Осталось: Готово. Панель работает и без iTerm2: http://127.0.0.1:$PORT/
1. В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude. (с iPad — через SSH-туннель на этот адрес, см. README).
2. Перезапустите открытые сессии claude — хуки подхватываются при старте сессии. В 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 Удаление: $HERE/uninstall.sh
EOF EOF

View File

@ -145,11 +145,36 @@ tmux; фон выставлен под тему Termius и держится ме
- 2026-10-18 (план) — Копия скрипта для службы — `~/.local/share/claude-agents/claude_agents.py`, - 2026-10-18 (план) — Копия скрипта для службы — `~/.local/share/claude-agents/claude_agents.py`,
отдельно от копии в AutoLaunch. Обоснование: служба не зависит от каталога iTerm2. Цена ошибки: отдельно от копии в AutoLaunch. Обоснование: служба не зависит от каталога iTerm2. Цена ошибки:
два места при ручном обновлении (`install.sh` обновляет оба). два места при ручном обновлении (`install.sh` обновляет оба).
- 2026-10-18 (план) — Текст на светлом фоне `#3b4a50`, порог — относительная яркость фона по WCAG - 2026-10-18 (план) — Текст — `#3b4a50` (светлый фон) или `#aacace` (как в профиле, тёмный фон);
> 0.4; на тёмном фоне текст `#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. Цена ошибки: - 2026-10-18 (план) — Область нажатия ⚙, образцов и кнопок — не меньше 36×36 CSS px. Цена ошибки:
поправить размер после приёмки на iPad. поправить размер после приёмки на iPad.
- 2026-10-18 (план) — В этой ветке заодно закрываются O-1 (устаревший docstring) и O-3 - 2026-10-18 (план) — В этой ветке заодно закрываются O-1 (устаревший docstring) и O-3
(`__pycache__/` в `.gitignore`) из issue #3: оба файла и так меняются. Цена ошибки: нет. (`__pycache__/` в `.gitignore`) из issue #3: оба файла и так меняются. Цена ошибки: нет.
- 2026-10-18 (план) — Приёмка (Task 6) выполняется человеком, не сабагентами: она меняет launchd, - 2026-10-18 (план) — Приёмка (Task 6) выполняется человеком, не сабагентами: она меняет launchd,
`~/.claude/settings.json` и настройки iTerm2 на рабочем Mac. `~/.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) там падают. Цена ошибки: нет.

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()

49
tests/test_launchagent.py Normal file
View File

@ -0,0 +1,49 @@
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"),
{"Label": "com.andrey.claude-agents",
"ProgramArguments": ["/opt/homebrew/bin/python3", "/x/claude_agents.py", "--headless"],
"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")
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 #!/bin/bash
# Удаление панели «Claude». С --purge удаляет ещё настройки и логи хука. # Удаление панели «Claude». С --purge удаляет ещё настройки и логи.
set -euo pipefail 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)" HERE="$(cd "$(dirname "$0")" && pwd)"
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch" AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
SETTINGS="$HOME/.claude/settings.json" SETTINGS="$HOME/.claude/settings.json"
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$-uninstall" BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$-uninstall"
PORT=8766 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 if command -v lsof >/dev/null; then
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true) 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 "хук удалён" rm -f "$HOME/.claude/hooks/agents_panel_hook.sh" && ok "хук удалён"
if [ "${1:-}" = "--purge" ]; then 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 "настройки и логи удалены" ok "настройки и логи удалены"
else else
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents) оставлены; --purge удалит и их." echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents, ~/Library/Logs/claude-agents) оставлены; --purge удалит и их."
fi fi
echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2." echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2."