Compare commits

..

No commits in common. "6626bb4668dc289ed97cbbf21138b2f07e1c5808" and "b67f74ddffd8ab73f19aafb2dc9429e5cf5bb585" have entirely different histories.

15 changed files with 229 additions and 917 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,63 +1,55 @@
# cc-agents-iterm-plugin # cc-agents-iterm-plugin
## Что это ## Что это
Панель «Claude» — страница на `http://127.0.0.1:8766/`. Сверху — дерево активных сессий Claude Code Панель «Claude» для iTerm2 Toolbelt. Сверху — дерево активных сессий Claude Code и их субагентов
и их субагентов (название, модель, размер контекста, worktree, статус), снизу — лимиты подписки (название, модель, размер контекста, worktree, статус), снизу — лимиты подписки (5 ч, неделя,
(5 ч, неделя, разбивка по продуктам) и баланс extra usage. Её показывает iTerm2 Toolbelt и разбивка по продуктам) и баланс extra usage. Для одного пользователя на его Mac: видеть, что
браузер — в том числе Safari на iPad через SSH-туннель Termius. Для одного пользователя на его делают все запущенные `claude` и сколько осталось лимитов, и в один клик переходить в нужную
Mac: видеть, что делают все запущенные `claude` (в tmux), сколько осталось лимитов, и в один клик вкладку или панель tmux.
переходить в нужную панель tmux.
## Стек и ограничения ## Стек и ограничения
- Python 3.8+, только стандартная библиотека; модуль `iterm2` нужен только регистратору Toolbelt - Python 3.8+, только стандартная библиотека; модуль `iterm2` предоставляет сам iTerm2
(его даёт iTerm2). Без зависимостей из pip — пользователь ставит панель `./install.sh`. (скрипт AutoLaunch). Без зависимостей из pip — пользователь ставит панель `./install.sh`.
- Хук — POSIX `sh` + `jq`. Хук никогда не мешает Claude Code: ничего не выводит, всегда `exit 0`. - Хук — POSIX `sh` + `jq`. Хук никогда не мешает Claude Code: ничего не выводит, всегда `exit 0`.
- macOS, launchd. Токен OAuth берётся из Keychain (`Claude Code-credentials`) или - Только macOS + iTerm2 с включённым Python API. Токен OAuth берётся из Keychain
`~/.claude/.credentials.json`, поэтому служба работает в GUI-сессии пользователя. (`Claude Code-credentials`) или `~/.claude/.credentials.json`.
- Сервер слушает только `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` идемпотентен. Запуск по SSH `~/.claude-panel-backup/<дата>/`. Повторный запуск `install.sh` идемпотентен.
`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` — один файл, два режима (`run`): - `files/claude_agents.py` — скрипт iTerm2 AutoLaunch, один файл:
- `--headless` (`serve`) — служба launchd `com.andrey.claude-agents`, единственный владелец порта: - `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с живыми
- `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с сессиями iTerm2, процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены,
процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены, из git — из git — worktree; собирает `state["tree"]` (`build_tree`).
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` — установить или обновить у себя (за самим Mac); затем Toolbelt → Claude и перезапуск сессий `claude`. - `./install.sh` — установить или обновить у себя; затем 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`.
## Тесты ## Тесты
- `tests/` — `unittest`. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`). Сеть, - Автотестов пока нет. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`), так что чистые
Keychain, launchd, `ps` и tmux в тестах подменяются; реальные `~/.claude` и `~/Library` не трогаются. функции (`replay`, `build_tree`, `parse_limits`, `parse_balance`, `session_name`, `settings_hooks.add/remove`)
- `install.sh` и `uninstall.sh` в тестах запускаются только с `SSH_CONNECTION`/`SSH_TTY` (проверка можно покрывать pytest без iTerm2. Сеть, Keychain, `ps` и tmux в тестах мокаются.
отказа): без охраны они остановили бы настоящую панель на порту 8766. - Фикстуры — образцы jsonl хука и ответов API в `tests/fixtures/`.
## Где лежит код ## Где лежит код
- Код — в `files/`; хук require-plan защищает его через `.claude/code-paths`. `install.sh` и - Код — в `files/`; хук require-plan защищает его через `.claude/code-paths`. `install.sh` и

View File

@ -1,15 +1,10 @@
# Claude — панель сессий Claude Code # Claude — панель для iTerm2 Toolbelt
Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree), Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree),
снизу — лимиты подписки и баланс extra usage. Панель — страница на `http://127.0.0.1:8766/`: снизу — лимиты подписки и баланс extra usage.
её показывает 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
@ -19,43 +14,27 @@ cd claude-panel
Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите
открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное. открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное.
Нужно: macOS, python3, jq (`brew install jq`, в macOS 15+ уже есть); iTerm2 — для Toolbelt. Нужно: macOS, iTerm2, python3, jq (`brew install jq`, в macOS 15+ уже есть).
## Что ставится ## Что ставится
| Что | Куда | | Что | Куда |
|---|---| |---|---|
| служба (сервер панели) | `~/.local/share/claude-agents/claude_agents.py` | | скрипт панели | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/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` и заменой файлов старые копии кладутся в
`settings.json` и заменой файлов старые копии кладутся в `~/.claude-panel-backup/<дата>/`. `~/.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` в соседней панели tmux; - клик по `⎇ папка` — открыть в ней `lf` в соседней панели;
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен; - цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются; - сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё;
- цвет токенов: жёлтый > 500k, красный > 700k; - цвет токенов: жёлтый > 500k, красный > 700k.
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
## Настройки ## Настройки
@ -64,12 +43,8 @@ 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,19 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает Claude — панель для iTerm2 Toolbelt.
iTerm2 Toolbelt.
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов — Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии. название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии.
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage. Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в
не сообщил, берутся из транскриптов. ~/.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 — разобранное состояние.
Только стандартная библиотека. Только стандартная библиотека.
@ -24,10 +27,9 @@ 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
@ -56,7 +58,9 @@ 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" # что открывать в новой панели tmux по клику на worktree FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree
USE_PROFILE_THEME = True
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
# лимиты подписки и баланс (нижняя часть панели) # лимиты подписки и баланс (нижняя часть панели)
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429 USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
@ -74,6 +78,8 @@ 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),
@ -106,14 +112,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}"
# Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90",
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
"green": "#859900", "yellow": "#b58900", "red": "#dc322f", "green": "#859900", "yellow": "#b58900", "red": "#dc322f",
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13} "font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12}
state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None, state = {"theme": dict(DEFAULT_THEME), "live": None, "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
@ -831,35 +837,6 @@ 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">
@ -886,23 +863,7 @@ 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; display:flex; align-items:center; }} .foot {{ margin-top:2px; opacity:.8; }}
.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); }}
@ -961,21 +922,9 @@ 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> </style></head><body>
<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>
@ -994,12 +943,7 @@ 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) {{ if (r.ok) document.getElementById('limits').innerHTML = await r.text();
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); // «сброс через …» тоже обновляется
@ -1077,38 +1021,8 @@ 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);
// ---- фон панели на этом устройстве ---- // тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто)
const gear = document.getElementById('gear'), bgrow = document.getElementById('bgrow'); setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
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>"""
@ -1330,12 +1244,31 @@ 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}' parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>')
f'<span class="gear-slot"></span></div>')
return "".join(parts) return "".join(parts)
# ---------- действия по клику (через tmux) ---------- # ---------- связь с 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
def known_roots(): def known_roots():
roots = set() roots = set()
@ -1353,15 +1286,13 @@ _last_open = {} # path -> time последнего открытия (защи
async def open_worktree(uuid, path): async def open_worktree(uuid, path):
"""Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер. """Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер.
Команда передаётся tmux при создании панели, а не вводом текста в оболочку: Команда и папка задаются настройками новой панели, а не вводом текста в оболочку:
так не возникает гонки «текст отправлен раньше, чем оболочка готова», так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются.""" и панель закрывается сама, когда выходите из lf."""
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 # повторный клик/запрос — уже открываем
@ -1369,10 +1300,28 @@ 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}]
sock, pane = _tmux_target(uuid) if uuid and uuid.startswith("tmux:") and uuid in known_targets():
cmd = f"exec {FILE_MANAGER}" sock, pane = _tmux_target(uuid)
await asyncio.get_running_loop().run_in_executor( cmd = f"exec {FILE_MANAGER}"
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd)) 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(): def known_targets():
@ -1391,27 +1340,41 @@ def _tmux(sock, *args):
async def activate(uuid): async def activate(uuid):
if uuid not in known_targets() or not uuid.startswith("tmux:"): if uuid not in known_targets():
return # только сессии в tmux, которые панель сама показала return # только то, что панель сама показала
sock, pane = _tmux_target(uuid) twin = (state.get("tmux_twins") or {}).get(uuid)
loop = asyncio.get_running_loop() if twin and _app is not None: # tmux -CC — переключаемся средствами iTerm2: вкладка и окно спереди
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane), sess = _app.get_session_by_id(twin)
("select-pane", "-t", pane)): if sess:
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a)) await sess.async_activate(select_tab=True, order_window_front=True)
return
if uuid.startswith("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(): async def refresher(connection):
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, None, procs, tmux) state["tree"] = build_tree(sessions, live, procs, tmux)
state["error"] = None state["error"] = None
if time.time() - last_cleanup > 3600: if time.time() - last_cleanup > 3600:
cleanup_logs() cleanup_logs()
@ -1430,6 +1393,49 @@ 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):
@ -1437,20 +1443,6 @@ 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()
@ -1471,7 +1463,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": state["theme"], "theme_error": state.get("theme_error"),
"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
@ -1492,70 +1484,23 @@ 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)
tasks = [asyncio.create_task(c) for c in (refresher(), usage_poller(), balance_poller())] asyncio.create_task(refresher(connection))
try: asyncio.create_task(usage_poller())
async with _server: asyncio.create_task(balance_poller())
await _server.serve_forever() if USE_PROFILE_THEME:
finally: asyncio.create_task(theme_poller(connection))
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=SERVICE_LABEL, identifier="com.andrey.claude-agents",
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}/",
) )
def run(argv): if __name__ == "__main__" and iterm2 is not None:
if "--headless" in argv: iterm2.run_forever(main)
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,6 +5,7 @@
"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,
@ -12,5 +13,6 @@
"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"
} }

View File

@ -1,39 +0,0 @@
#!/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,18 +1,8 @@
#!/bin/bash #!/bin/bash
# Установка панели «Claude»: дерево сессий и агентов Claude Code, лимиты подписки и баланс. # Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов 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"
@ -21,12 +11,11 @@ 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/"; }
# ---------- проверки ---------- # ---------- проверки ----------
@ -34,12 +23,11 @@ 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)"
PY="$(command -v python3)" # не sys.executable: версионный путь Homebrew исчезает после brew cleanup ok "python3: $(python3 -V 2>&1)"
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"
@ -53,7 +41,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 (регистрация в Toolbelt)" say "Скрипт iTerm2"
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"
@ -69,32 +57,6 @@ 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
@ -112,24 +74,28 @@ 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 "панель зарегистрирована в Toolbelt" ok "панель запущена"
else else
warn "не смог зарегистрировать сам — Scripts → AutoLaunch → claude_agents.py" warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py"
fi fi
else else
warn "в Toolbelt панель появится при следующем запуске iTerm2" warn "панель запустится сама при следующем запуске iTerm2"
fi fi
cat <<EOF cat <<EOF
Готово. Панель работает и без iTerm2: http://127.0.0.1:$PORT/ Готово. Осталось:
(с iPad — через SSH-туннель на этот адрес, см. README). 1. В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude. 2. Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Проверка: http://127.0.0.1:$PORT/raw · лог службы: $SERVICE_LOG Проверка, что всё работает: http://127.0.0.1:$PORT/raw
Удаление: $HERE/uninstall.sh Удаление: $HERE/uninstall.sh
EOF EOF

View File

@ -145,36 +145,11 @@ 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` (светлый фон) или `#aacace` (как в профиле, тёмный фон); - 2026-10-18 (план) — Текст на светлом фоне `#3b4a50`, порог — относительная яркость фона по WCAG
выбирается тот, у которого выше коэффициент контраста WCAG `(Lmax+0.05)/(Lmin+0.05)` с фоном > 0.4; на тёмном фоне текст `#aacace` (как в профиле). Цена ошибки: подобрать другие значения.
(было: порог яркости > 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) там падают. Цена ошибки: нет.

View File

@ -1,81 +0,0 @@
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()

View File

@ -1,88 +0,0 @@
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()

View File

@ -1,40 +0,0 @@
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()

View File

@ -1,49 +0,0 @@
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()

View File

@ -1,227 +0,0 @@
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,26 +1,14 @@
#!/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"
# служба — первой, иначе launchd поднимет сервер снова ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
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)
@ -37,9 +25,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" "$HOME/Library/Logs/claude-agents" rm -rf "$HOME/.config/claude-agents" "$HOME/.cache/claude-agents"
ok "настройки и логи удалены" ok "настройки и логи удалены"
else else
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents, ~/Library/Logs/claude-agents) оставлены; --purge удалит и их." echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents) оставлены; --purge удалит и их."
fi fi
echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2." echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2."