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/
.bounded
.DS_Store
__pycache__/

View File

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

View File

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

View File

@ -1,15 +1,10 @@
# Claude — панель сессий Claude Code
# Claude — панель для iTerm2 Toolbelt
Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree),
снизу — лимиты подписки и баланс extra usage. Панель — страница на `http://127.0.0.1:8766/`:
её показывает iTerm2 Toolbelt, её можно открыть в браузере, в том числе с iPad через SSH-туннель.
Сервер панели — фоновая служба macOS, iTerm2 для неё не нужен.
снизу — лимиты подписки и баланс extra usage.
## Установка
Запускать за самим Mac, не по SSH (из SSH-сессии launchd и iTerm2 не настроить —
установщик откажется):
```bash
tar xzf claude-panel.tar.gz
cd claude-panel
@ -19,43 +14,27 @@ cd claude-panel
Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите
открытые сессии `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` |
| описание службы launchd | `~/Library/LaunchAgents/com.andrey.claude-agents.plist` |
| лог службы | `~/Library/Logs/claude-agents/panel.log` |
| скрипт Toolbelt | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
| скрипт панели | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
| хук Claude Code | `~/.claude/hooks/agents_panel_hook.sh` + записи в `~/.claude/settings.json` |
| пример настроек | `~/.config/claude-agents/config.example.json` |
Служба запускается при входе в систему и перезапускается при падении. Перед изменением
`settings.json` и заменой файлов старые копии кладутся в `~/.claude-panel-backup/<дата>/`.
Ваши остальные хуки и настройки не трогаются.
## С 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.
Перед изменением `settings.json` и заменой файлов старые копии кладутся в
`~/.claude-panel-backup/<дата>/`. Ваши остальные хуки и настройки не трогаются.
## Работа с панелью
- клик по сессии или агенту — последние сообщения (копятся, пока окно открыто);
- ⌘-клик по сессии — перейти в её панель tmux;
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
- ⌘-клик по сессии — перейти в её вкладку;
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели;
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
- цвет токенов: жёлтый > 500k, красный > 700k;
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
- сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё;
- цвет токенов: жёлтый > 500k, красный > 700k.
## Настройки
@ -64,12 +43,8 @@ cd claude-panel
## Если что-то не так
- `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 не сможет её запустить.
`http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий,
ошибки лимитов и баланса.
## Удаление

View File

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

View File

@ -5,6 +5,7 @@
"agent_idle_min": 15,
"poll_sec": 1.5,
"keep_logs_days": 2,
"use_profile_theme": true,
"port": 8766,
"context_warn": 500000,
"context_crit": 700000,
@ -12,5 +13,6 @@
"usage_refresh_sec": 120,
"balance_refresh_sec": 600,
"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
# Установка панели «Claude»: дерево сессий и агентов Claude Code, лимиты подписки и баланс.
# Сервер панели — служба launchd (работает и без iTerm2), iTerm2 показывает её в Toolbelt.
# Повторный запуск безопасен — обновляет установленное.
# Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов Claude Code,
# лимиты подписки и баланс. Повторный запуск безопасен — обновляет установленное.
set -euo pipefail
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
# из SSH-сессии launchd (gui/<uid>) и iTerm2 не настроить, а прежний сервер уже был бы остановлен
[ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] \
|| die "запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить. Если вы и так за Mac (например, в панели tmux, открытой раньше из SSH), запустите в обычной вкладке iTerm2 или после unset SSH_CONNECTION SSH_TTY"
HERE="$(cd "$(dirname "$0")" && pwd)"
FILES="$HERE/files"
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
@ -21,12 +11,11 @@ SETTINGS="$HOME/.claude/settings.json"
CONFIG_DIR="$HOME/.config/claude-agents"
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$"
PORT=8766
LABEL=com.andrey.claude-agents
SERVICE_DIR="$HOME/.local/share/claude-agents"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
SERVICE_LOG="$HOME/Library/Logs/claude-agents/panel.log"
GUI="gui/$(id -u)"
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
backup() { mkdir -p "$BACKUP_DIR"; cp -p "$1" "$BACKUP_DIR/"; }
# ---------- проверки ----------
@ -34,12 +23,11 @@ say "Проверяю окружение"
[ "$(uname)" = "Darwin" ] || [ "${CLAUDE_PANEL_ANY_OS:-}" = 1 ] || die "нужен macOS с iTerm2"
python3 -c 'import sys; assert sys.version_info >= (3, 8)' 2>/dev/null \
|| die "нужен python3 ≥ 3.8 (xcode-select --install или brew install python)"
PY="$(command -v python3)" # не sys.executable: версионный путь Homebrew исчезает после brew cleanup
ok "python3: $(python3 -V 2>&1) ($PY)"
ok "python3: $(python3 -V 2>&1)"
if command -v jq >/dev/null; then ok "jq: $(command -v jq)"
else warn "нет jq — хук без него молча ничего не пишет. Установите: brew install jq"; fi
if [ -d "/Applications/iTerm.app" ] || [ -d "$HOME/Applications/iTerm.app" ]; then ok "iTerm2 найден"
else warn "iTerm2 не найден в /Applications — панель будет доступна только в браузере"; fi
else warn "iTerm2 не найден в /Applications — панель будет работать только в нём"; fi
# ---------- хук Claude Code ----------
say "Хук Claude Code"
@ -53,7 +41,7 @@ if [ "$added" = "—" ]; then ok "settings.json: хуки уже на месте
else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi
# ---------- скрипт iTerm2 ----------
say "Скрипт iTerm2 (регистрация в Toolbelt)"
say "Скрипт iTerm2"
mkdir -p "$AUTOLAUNCH"
if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов
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 не тронут"
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 ----------
say "iTerm2"
iterm_running=0
@ -112,24 +74,28 @@ else
ok "Python API уже включён"
fi
# старая копия панели держит порт — останавливаем, чтобы запустилась новая
if command -v lsof >/dev/null; then
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; ok "остановлена прежняя версия панели"; fi
fi
if [ $iterm_running = 1 ] && [ "$api" = 1 ]; then
pkill -f "Scripts/AutoLaunch/claude_agents.py" || true # прежний регистратор, иначе их станет по одному на запуск
if osascript -e 'tell application "iTerm2" to launch API script named "claude_agents"' >/dev/null 2>&1; then
ok "панель зарегистрирована в Toolbelt"
ok "панель запущена"
else
warn "не смог зарегистрировать сам — Scripts → AutoLaunch → claude_agents.py"
warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py"
fi
else
warn "в Toolbelt панель появится при следующем запуске iTerm2"
warn "панель запустится сама при следующем запуске iTerm2"
fi
cat <<EOF
Готово. Панель работает и без iTerm2: http://127.0.0.1:$PORT/
(с iPad — через SSH-туннель на этот адрес, см. README).
В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Готово. Осталось:
1. В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
2. Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
Проверка: http://127.0.0.1:$PORT/raw · лог службы: $SERVICE_LOG
Проверка, что всё работает: http://127.0.0.1:$PORT/raw
Удаление: $HERE/uninstall.sh
EOF

View File

@ -145,36 +145,11 @@ tmux; фон выставлен под тему Termius и держится ме
- 2026-10-18 (план) — Копия скрипта для службы — `~/.local/share/claude-agents/claude_agents.py`,
отдельно от копии в AutoLaunch. Обоснование: служба не зависит от каталога iTerm2. Цена ошибки:
два места при ручном обновлении (`install.sh` обновляет оба).
- 2026-10-18 (план) — Текст — `#3b4a50` (светлый фон) или `#aacace` (как в профиле, тёмный фон);
выбирается тот, у которого выше коэффициент контраста WCAG `(Lmax+0.05)/(Lmin+0.05)` с фоном
(было: порог яркости > 0.4, на средних тонах давал ~1.5:1 — находка I-1). Цена ошибки:
на отдельных средних фонах контраст всё равно не дотянет до 4.5:1; подобрать другие цвета текста.
- 2026-10-18 (план) — Текст на светлом фоне `#3b4a50`, порог — относительная яркость фона по WCAG
> 0.4; на тёмном фоне текст `#aacace` (как в профиле). Цена ошибки: подобрать другие значения.
- 2026-10-18 (план) — Область нажатия ⚙, образцов и кнопок — не меньше 36×36 CSS px. Цена ошибки:
поправить размер после приёмки на iPad.
- 2026-10-18 (план) — В этой ветке заодно закрываются O-1 (устаревший docstring) и O-3
(`__pycache__/` в `.gitignore`) из issue #3: оба файла и так меняются. Цена ошибки: нет.
- 2026-10-18 (план) — Приёмка (Task 6) выполняется человеком, не сабагентами: она меняет launchd,
`~/.claude/settings.json` и настройки iTerm2 на рабочем Mac.
- 2026-10-18 (исполнение) — R-1 (design #12): `test_old_theme_keys_ignored` и
`test_open_worktree_non_tmux_is_noop` прошли до реализации Task 1 (старые ключи и раньше не давали
ошибки, `_app` был `None`); оставлены как тесты-фиксаторы поведения после удаления. Цена ошибки:
нет — остальные тесты Task 1 падали корректно.
- 2026-10-18 (исполнение) — R-2 (design #13): `build_tree` оставлен с параметром `live` (вызывается с
`None`) и мёртвой веткой `tmux_twins`, потому что план требовал не менять `build_tree`. Цена ошибки:
мёртвый код; уборка отложена (M-9).
- 2026-10-18 (исполнение) — R-3 (design #14): RED для `test_install` показан временной порчей текста
сообщения охраны, а не запуском скриптов без охраны (это задело бы настоящий launchd и порт 8766).
Цена ошибки: тест защищён только проверяемой охраной — закрыто заглушками команд в PATH (M-7).
- 2026-10-18 (финальное ревью) — R-4 (design #15): из находок финального ревью исправлены в ветке
I-1…I-4, M-1, M-2, M-3, M-5, M-7, M-8, M-12; отложены в follow-up M-4 (порт из `config.json` в
`install.sh`/`uninstall.sh`), M-6 (ротация `panel.log`), M-9 (мёртвый код `build_tree`), M-11
(область нажатия 44 pt — оценить на приёмке, Task 6 Step 6). I-3 (нет проверки Host/Sec-Fetch-Site)
существовал до ветки, но исправлен сразу: служба теперь работает постоянно и открывается из
браузера. Цена ошибки: отложенные пункты — мелкие неудобства, не потеря данных.
- 2026-10-18 (финальное ревью) — R-5 (design #16): запрос без заголовка `Host` или с `Host` без порта
получает 403 (сравнение строго с `127.0.0.1:<порт>` / `localhost:<порт>`, порт берётся из сокета).
Обоснование: браузеры, web view iTerm2 и туннель Termius всегда шлют `Host` с портом. Цена ошибки:
клиент без `Host` (голый HTTP/1.0) не откроет панель — ослабить проверку.
- 2026-10-18 (финальное ревью) — R-6 (design #17): тесты к исправлениям написаны без отдельного шага
RED; вместо этого новые тесты прогнаны на коде до исправлений (2e8ab31) — все 6 новых тестов
(I-1, I-3 ×2, I-4, M-2, M-12) там падают. Цена ошибки: нет.

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