feat: панель как фоновая служба без iTerm2 + цвет фона на клиенте #5
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.superpowers/
|
||||
.bounded
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
# Архитектура
|
||||
|
||||
Два процесса, связанные только файлами в `~/.cache/claude-agents/`: хук внутри каждой сессии
|
||||
Claude Code пишет события, скрипт iTerm2 их читает и рисует панель.
|
||||
Три части, связанные файлами в `~/.cache/claude-agents/` и адресом `127.0.0.1:8766`: хук внутри
|
||||
каждой сессии Claude Code пишет события, служба launchd их читает и отдаёт страницу, скрипт iTerm2
|
||||
показывает эту страницу в Toolbelt. iTerm2 для работы панели не нужен.
|
||||
|
||||
```
|
||||
claude (каждая сессия) ──hook──▶ agents_panel_hook.sh ──append──▶ ~/.cache/claude-agents/<sid>.jsonl
|
||||
│
|
||||
iTerm2 AutoLaunch: claude_agents.py ◀───── read (poll 1.5 s) ────────────┘
|
||||
├─ + iTerm2 API (вкладки, профиль), ps, tmux, транскрипты ~/.claude/projects, git
|
||||
launchd: claude_agents.py --headless ◀──── read (poll 1.5 s) ────────────┘
|
||||
├─ + ps, tmux, транскрипты ~/.claude/projects, git
|
||||
├─ + api.anthropic.com oauth/usage, prepaid/credits (OAuth из Keychain)
|
||||
└─ HTTP 127.0.0.1:8766 ──▶ web view tool «Claude» в Toolbelt
|
||||
└─ HTTP 127.0.0.1:8766 ──▶ web view «Claude» в Toolbelt (регистрирует claude_agents.py в iTerm2)
|
||||
└─▶ браузер; с iPad — SSH local forward в Termius
|
||||
```
|
||||
|
||||
## Модули
|
||||
@ -17,19 +19,23 @@ iTerm2 AutoLaunch: claude_agents.py ◀───── read (poll 1.5 s) ──
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|---|---|---|
|
||||
| `files/agents_panel_hook.sh` | Событие хука → строка jsonl с PID, вкладкой iTerm2 или панелью tmux | `jq`, `ps` |
|
||||
| `files/claude_agents.py` — сессии | `load_sessions`/`replay`: jsonl → состояние сессий и субагентов; `build_tree` сводит с живыми вкладками, процессами, tmux | файлы хука, iTerm2 API, `ps`, `tmux`, транскрипты, `git` |
|
||||
| `files/claude_agents.py` — сессии | `load_sessions`/`replay`: jsonl → состояние сессий и субагентов; `build_tree` сводит с процессами и tmux | файлы хука, `ps`, `tmux`, транскрипты, `git` |
|
||||
| `files/claude_agents.py` — лимиты | `fetch_usage`/`fetch_balance` + `parse_limits`/`parse_balance` | Keychain/`~/.claude/.credentials.json`, `~/.claude.json`, api.anthropic.com, claude.ai |
|
||||
| `files/claude_agents.py` — UI | `render_*`, `handle` (HTTP), `activate`/`open_worktree`, `theme_poller` | `state`, iTerm2 API |
|
||||
| `files/claude_agents.py` — UI | `render_*`, `handle` (HTTP), `activate`/`open_worktree` (tmux) | `state` |
|
||||
| `files/claude_agents.py` — запуск | `run`: `serve` (служба, `--headless`) или `register_toolbelt` (iTerm2) | launchd, `iterm2` |
|
||||
| `files/launchagent.py` | plist службы `com.andrey.claude-agents` | — |
|
||||
| `files/settings_hooks.py` | Идемпотентно добавить/убрать хуки в `~/.claude/settings.json` | — |
|
||||
| `install.sh` / `uninstall.sh` | Разложить/убрать файлы, включить Python API iTerm2, перезапустить панель | `settings_hooks.py` |
|
||||
| `install.sh` / `uninstall.sh` | Разложить/убрать файлы, поставить/снять службу, включить Python API iTerm2 | `settings_hooks.py`, `launchagent.py`, `launchctl` |
|
||||
|
||||
## Границы
|
||||
- Хук не зависит от панели и не должен влиять на Claude Code: без вывода, всегда `exit 0`.
|
||||
- Панель только читает данные Claude Code (jsonl, транскрипты, `~/.claude.json`) и ничего туда не пишет;
|
||||
её единственная запись — удаление собственных логов старше `keep_logs_days`.
|
||||
- `claude_agents.py` — только стандартная библиотека + `iterm2`; импорт без iTerm2 должен работать.
|
||||
- Порт занимает только служба; скрипт iTerm2 сервер не поднимает и данные iTerm2 не передаёт.
|
||||
- `claude_agents.py` — только стандартная библиотека + опционально `iterm2`; импорт без iTerm2 должен работать.
|
||||
- HTTP слушает только `127.0.0.1`.
|
||||
- Фон панели выбирается на клиенте и хранится в браузере/web view; сервер о нём не знает.
|
||||
|
||||
## Открытые вопросы
|
||||
- Docstring `claude_agents.py` ссылается на несуществующий `settings-hooks.json` (теперь это `settings_hooks.py` + `install.sh`).
|
||||
- Автотестов нет; `claude_agents.py` — один файл на 1500 строк.
|
||||
- После перезагрузки Mac без входа на нём (только SSH) служба не стартует, Keychain заблокирован.
|
||||
- `claude_agents.py` — один файл на ~1400 строк.
|
||||
|
||||
58
CLAUDE.md
58
CLAUDE.md
@ -1,55 +1,63 @@
|
||||
# cc-agents-iterm-plugin
|
||||
|
||||
## Что это
|
||||
Панель «Claude» для iTerm2 Toolbelt. Сверху — дерево активных сессий Claude Code и их субагентов
|
||||
(название, модель, размер контекста, worktree, статус), снизу — лимиты подписки (5 ч, неделя,
|
||||
разбивка по продуктам) и баланс extra usage. Для одного пользователя на его Mac: видеть, что
|
||||
делают все запущенные `claude` и сколько осталось лимитов, и в один клик переходить в нужную
|
||||
вкладку или панель tmux.
|
||||
Панель «Claude» — страница на `http://127.0.0.1:8766/`. Сверху — дерево активных сессий Claude Code
|
||||
и их субагентов (название, модель, размер контекста, worktree, статус), снизу — лимиты подписки
|
||||
(5 ч, неделя, разбивка по продуктам) и баланс extra usage. Её показывает iTerm2 Toolbelt и
|
||||
браузер — в том числе Safari на iPad через SSH-туннель Termius. Для одного пользователя на его
|
||||
Mac: видеть, что делают все запущенные `claude` (в tmux), сколько осталось лимитов, и в один клик
|
||||
переходить в нужную панель tmux.
|
||||
|
||||
## Стек и ограничения
|
||||
- Python 3.8+, только стандартная библиотека; модуль `iterm2` предоставляет сам iTerm2
|
||||
(скрипт AutoLaunch). Без зависимостей из pip — пользователь ставит панель `./install.sh`.
|
||||
- Python 3.8+, только стандартная библиотека; модуль `iterm2` нужен только регистратору Toolbelt
|
||||
(его даёт iTerm2). Без зависимостей из pip — пользователь ставит панель `./install.sh`.
|
||||
- Хук — POSIX `sh` + `jq`. Хук никогда не мешает Claude Code: ничего не выводит, всегда `exit 0`.
|
||||
- Только macOS + iTerm2 с включённым Python API. Токен OAuth берётся из Keychain
|
||||
(`Claude Code-credentials`) или `~/.claude/.credentials.json`.
|
||||
- macOS, launchd. Токен OAuth берётся из Keychain (`Claude Code-credentials`) или
|
||||
`~/.claude/.credentials.json`, поэтому служба работает в GUI-сессии пользователя.
|
||||
- Сервер слушает только `127.0.0.1` — в панели видны тексты сообщений сессий.
|
||||
- Установщик не трогает чужие хуки и настройки: правит `~/.claude/settings.json` только через
|
||||
`settings_hooks.py` (маркер `agents_panel_hook`), перед заменой кладёт копии в
|
||||
`~/.claude-panel-backup/<дата>/`. Повторный запуск `install.sh` идемпотентен.
|
||||
`~/.claude-panel-backup/<дата>/`. Повторный запуск `install.sh` идемпотентен. Запуск по SSH
|
||||
`install.sh`/`uninstall.sh` отклоняют.
|
||||
|
||||
## Архитектура
|
||||
- `files/agents_panel_hook.sh` — хук Claude Code на SessionStart/End, UserPromptSubmit, Stop,
|
||||
Notification, SubagentStart/Stop, PostModelSwitch, PreToolUse(Agent|Task). Дописывает событие
|
||||
в `~/.cache/claude-agents/<session_id>.jsonl` вместе с PID процесса `claude`,
|
||||
`ITERM_SESSION_ID` или сокетом/панелью tmux.
|
||||
- `files/claude_agents.py` — скрипт iTerm2 AutoLaunch, один файл:
|
||||
- `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с живыми
|
||||
сессиями iTerm2, процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены,
|
||||
из git — worktree; собирает `state["tree"]` (`build_tree`).
|
||||
- `files/claude_agents.py` — один файл, два режима (`run`):
|
||||
- `--headless` (`serve`) — служба launchd `com.andrey.claude-agents`, единственный владелец порта:
|
||||
- `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с
|
||||
процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены, из git —
|
||||
worktree; собирает `state["tree"]` (`build_tree`, без данных iTerm2).
|
||||
- `usage_poller` / `balance_poller` ходят в `api.anthropic.com/api/oauth/usage` и
|
||||
`.../prepaid/credits` (запасной путь — claude.ai с `sessionKey` из Keychain).
|
||||
- HTTP-сервер на `127.0.0.1:8766` (`handle`) отдаёт страницу, которую iTerm2 показывает как web view
|
||||
tool: `/`, `/fragment` (дерево), `/limits`, `/message` (последние сообщения), `/activate`,
|
||||
`/open-wt`, `/raw` (отладка).
|
||||
- `theme_poller` подстраивает цвета под профиль iTerm2.
|
||||
- HTTP-сервер (`handle`): `/`, `/fragment` (дерево), `/limits`, `/message` (последние
|
||||
сообщения), `/activate` и `/open-wt` (через tmux), `/raw` (отладка).
|
||||
- без флага (`register_toolbelt`) — скрипт AutoLaunch iTerm2: регистрирует web view «Claude» на
|
||||
адрес службы; если порт молчит — `launchctl kickstart`. Свой сервер не поднимает.
|
||||
- `files/launchagent.py` — пишет plist службы (`write <plist> <python> <script> <log> <PATH>`; PATH берётся из оболочки `install.sh`, его наследуют панели tmux, которые открывает служба).
|
||||
- `files/settings_hooks.py` — добавляет/убирает хуки панели в `settings.json` (`add` / `remove`).
|
||||
- `files/config.example.json` — все ключи настроек; рабочий файл `~/.config/claude-agents/config.json`
|
||||
перечитывается на лету, кроме `port`.
|
||||
- `install.sh` / `uninstall.sh [--purge]` — раскладывают файлы по местам (таблица в README.md).
|
||||
- `install.sh` / `uninstall.sh [--purge]` — раскладывают файлы, ставят/убирают службу (таблица в README.md).
|
||||
|
||||
## Команды
|
||||
- `./install.sh` — установить или обновить у себя; затем Toolbelt → Claude и перезапуск сессий `claude`.
|
||||
- `./install.sh` — установить или обновить у себя (за самим Mac); затем Toolbelt → Claude и перезапуск сессий `claude`.
|
||||
- `./uninstall.sh` — удалить (`--purge` — вместе с настройками и логами).
|
||||
- `python3 -m unittest discover -s tests -v` — весь набор тестов (stdlib, без pytest; `node` нужен
|
||||
для теста клиентского JS). Зелёным считается только весь набор.
|
||||
- `python3 -m py_compile files/*.py && sh -n files/agents_panel_hook.sh && bash -n install.sh && bash -n uninstall.sh` —
|
||||
синтаксическая проверка; сборки нет.
|
||||
- `curl -s http://127.0.0.1:8766/raw` — что видит запущенная панель: логи хука, причины скрытия
|
||||
сессий, ошибки лимитов и баланса.
|
||||
сессий, ошибки лимитов и баланса. Лог службы — `~/Library/Logs/claude-agents/panel.log`,
|
||||
состояние — `launchctl print gui/$(id -u)/com.andrey.claude-agents`.
|
||||
|
||||
## Тесты
|
||||
- Автотестов пока нет. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`), так что чистые
|
||||
функции (`replay`, `build_tree`, `parse_limits`, `parse_balance`, `session_name`, `settings_hooks.add/remove`)
|
||||
можно покрывать pytest без iTerm2. Сеть, Keychain, `ps` и tmux в тестах мокаются.
|
||||
- Фикстуры — образцы jsonl хука и ответов API в `tests/fixtures/`.
|
||||
- `tests/` — `unittest`. `claude_agents.py` импортируется без iTerm2 (`iterm2 = None`). Сеть,
|
||||
Keychain, launchd, `ps` и tmux в тестах подменяются; реальные `~/.claude` и `~/Library` не трогаются.
|
||||
- `install.sh` и `uninstall.sh` в тестах запускаются только с `SSH_CONNECTION`/`SSH_TTY` (проверка
|
||||
отказа): без охраны они остановили бы настоящую панель на порту 8766.
|
||||
|
||||
## Где лежит код
|
||||
- Код — в `files/`; хук require-plan защищает его через `.claude/code-paths`. `install.sh` и
|
||||
|
||||
52
README.md
52
README.md
@ -1,10 +1,15 @@
|
||||
# Claude — панель для iTerm2 Toolbelt
|
||||
# Claude — панель сессий Claude Code
|
||||
|
||||
Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree),
|
||||
снизу — лимиты подписки и баланс extra usage.
|
||||
снизу — лимиты подписки и баланс extra usage. Панель — страница на `http://127.0.0.1:8766/`:
|
||||
её показывает iTerm2 Toolbelt, её можно открыть в браузере, в том числе с iPad через SSH-туннель.
|
||||
Сервер панели — фоновая служба macOS, iTerm2 для неё не нужен.
|
||||
|
||||
## Установка
|
||||
|
||||
Запускать за самим Mac, не по SSH (из SSH-сессии launchd и iTerm2 не настроить —
|
||||
установщик откажется):
|
||||
|
||||
```bash
|
||||
tar xzf claude-panel.tar.gz
|
||||
cd claude-panel
|
||||
@ -14,27 +19,43 @@ cd claude-panel
|
||||
Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите
|
||||
открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное.
|
||||
|
||||
Нужно: macOS, iTerm2, python3, jq (`brew install jq`, в macOS 15+ уже есть).
|
||||
Нужно: macOS, python3, jq (`brew install jq`, в macOS 15+ уже есть); iTerm2 — для Toolbelt.
|
||||
|
||||
## Что ставится
|
||||
|
||||
| Что | Куда |
|
||||
|---|---|
|
||||
| скрипт панели | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
|
||||
| служба (сервер панели) | `~/.local/share/claude-agents/claude_agents.py` |
|
||||
| описание службы launchd | `~/Library/LaunchAgents/com.andrey.claude-agents.plist` |
|
||||
| лог службы | `~/Library/Logs/claude-agents/panel.log` |
|
||||
| скрипт Toolbelt | `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/claude_agents.py` |
|
||||
| хук Claude Code | `~/.claude/hooks/agents_panel_hook.sh` + записи в `~/.claude/settings.json` |
|
||||
| пример настроек | `~/.config/claude-agents/config.example.json` |
|
||||
|
||||
Перед изменением `settings.json` и заменой файлов старые копии кладутся в
|
||||
`~/.claude-panel-backup/<дата>/`. Ваши остальные хуки и настройки не трогаются.
|
||||
Служба запускается при входе в систему и перезапускается при падении. Перед изменением
|
||||
`settings.json` и заменой файлов старые копии кладутся в `~/.claude-panel-backup/<дата>/`.
|
||||
Ваши остальные хуки и настройки не трогаются.
|
||||
|
||||
## С iPad (Termius)
|
||||
|
||||
1. В Termius: **Port Forwarding → Local**: хост — ваш Mac, local port `8766`,
|
||||
bind address `127.0.0.1`, destination `127.0.0.1`, destination port `8766`.
|
||||
2. Запустите forward и откройте в Safari `http://127.0.0.1:8766` — именно `127.0.0.1`,
|
||||
не `localhost` (сервер слушает только IPv4).
|
||||
|
||||
Сервер доступен только с самого Mac, в сеть не открывается: с других устройств — только через туннель.
|
||||
Клики работают через tmux, так что переходы и `lf` появляются в том же tmux, что у вас в Termius.
|
||||
|
||||
## Работа с панелью
|
||||
|
||||
- клик по сессии или агенту — последние сообщения (копятся, пока окно открыто);
|
||||
- ⌘-клик по сессии — перейти в её вкладку;
|
||||
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели;
|
||||
- ⌘-клик по сессии — перейти в её панель tmux;
|
||||
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
|
||||
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
|
||||
- сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё;
|
||||
- цвет токенов: жёлтый > 500k, красный > 700k.
|
||||
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
|
||||
- цвет токенов: жёлтый > 500k, красный > 700k;
|
||||
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
|
||||
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
|
||||
|
||||
## Настройки
|
||||
|
||||
@ -43,8 +64,15 @@ cd claude-panel
|
||||
|
||||
## Если что-то не так
|
||||
|
||||
`http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий,
|
||||
ошибки лимитов и баланса.
|
||||
- `http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий,
|
||||
ошибки лимитов и баланса;
|
||||
- `~/Library/Logs/claude-agents/panel.log` — лог службы;
|
||||
- `launchctl print gui/$(id -u)/com.andrey.claude-agents` — состояние службы; ошибки запуска (например, «не найден python») видны только здесь, а не в `panel.log`;
|
||||
- после переустановки или обновления Python (в том числе `brew upgrade`/`brew cleanup`) запустите
|
||||
`./install.sh` заново — служба хранит путь к `python3`, и без этого launchd не сможет её запустить;
|
||||
- «⎇ папка» мигает разделителем и панель сразу закрывается — служба не нашла `lf`. Служба берёт
|
||||
`PATH` из оболочки, в которой запускали `install.sh`: запустите его заново из терминала, где
|
||||
`command -v lf` находит файловый менеджер.
|
||||
|
||||
## Удаление
|
||||
|
||||
|
||||
266
docs/superpowers/plans/2026-10-18-headless-panel.md
Normal file
266
docs/superpowers/plans/2026-10-18-headless-panel.md
Normal file
@ -0,0 +1,266 @@
|
||||
# Headless Panel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Сервер панели «Claude» работает как фоновая служба launchd без iTerm2, iTerm2 только регистрирует web view, а фон панели настраивается на клиенте шестерёнкой ⚙.
|
||||
|
||||
**Architecture:** `files/claude_agents.py` получает два режима одного файла: `--headless` (HTTP-сервер, дерево сессий по PID/tmux, лимиты, баланс) и регистратор Toolbelt (запуск iTerm2 из AutoLaunch). Вся связь с живым iTerm2 (тема профиля, вкладки, split) удаляется. LaunchAgent описывается генератором `files/launchagent.py` и ставится `install.sh`. Фон выбирается в браузере, хранится в `localStorage`, производные цвета считает клиентский JS.
|
||||
|
||||
**Tech Stack:** Python 3.8+ (stdlib; тесты — `unittest`), POSIX shell / bash, launchd (`launchctl`), HTML/CSS/JS внутри `render_page`, `node` — только для теста клиентского JS.
|
||||
|
||||
**Spec:** `openspec/changes/headless-panel/` — `proposal.md`, `design.md`, `specs/panel-service/spec.md`, `specs/panel-appearance/spec.md`.
|
||||
|
||||
**Issue / branch:** #4, `feat/4-headless-panel`, worktree `/Users/andrey.epifancev/projects/cc-agents-iterm-plugin/.claude/worktrees/headless-panel`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Рантайм — только стандартная библиотека Python 3.8+; модуль `iterm2` импортируется опционально (уже так) и нужен только регистратору.
|
||||
- Тесты — `unittest` из stdlib, без pytest и pip: `python3 -m unittest discover -s tests -v`. Ни один тест не ходит в сеть, не трогает launchd, Keychain, реальный `~/.claude` и реальный `~/Library`.
|
||||
- Сервер слушает только `127.0.0.1`; порт по умолчанию `8766` (ключ `port` в `config.json`).
|
||||
- Метка службы `com.andrey.claude-agents` (та же строка — `identifier` web view); лог службы `~/Library/Logs/claude-agents/panel.log`.
|
||||
- Тема по умолчанию: `bg #002b36`, `fg #aacace`, `dim #668a91`, `green #859900`, `yellow #b58900`, `red #dc322f`, шрифт `'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace`, размер `13`.
|
||||
- Образцы фона: `#002b36`, `#1e1e1e`, `#000000`, `#2b2b3a`, `#fdf6e3`; плюс «свой цвет» (`<input type="color">`) и «сброс».
|
||||
- `install.sh` и `uninstall.sh` отказываются работать, если задан `SSH_CONNECTION` или `SSH_TTY`, ничего не меняя.
|
||||
- Хук `files/agents_panel_hook.sh` и `files/settings_hooks.py` не меняются.
|
||||
- Тексты для пользователя — на русском, в стиле существующих (`ok`/`warn`/`die` в установщике, короткие подписи в панели).
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Служба стартовала, а токена нет / Keychain заблокирован → страница отвечает 200, в блоке лимитов текст ошибки, процесс не падает (тест в Task 2: `test_serve_survives_usage_failure`).
|
||||
- Порт уже занят чужим процессом при старте службы → `serve()` падает с `OSError`, launchd перезапускает позже; второй сервер не появляется (тест в Task 2: `test_serve_busy_port_raises`).
|
||||
- Мусор в `localStorage` или хранилище недоступно (приватный режим) → тема по умолчанию, без ошибки JS (тест в Task 5: `panelColors('nope') === null` + `try/catch` вокруг чтения).
|
||||
- Блок лимитов перерисовывается раз в 15 с → раскрытая строка образцов и ⚙ не исчезают (тесты в Task 5: `gear-slot` в `.foot`, статичный `#bgctl` вне `#limits`).
|
||||
- Путь к скрипту с пробелами (`Application Support`) в LaunchAgent → передаётся одним аргументом (тест в Task 3: `test_cli_writes_plist_with_spaces`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Тестовая основа и удаление связи с живым iTerm2
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_core.py`
|
||||
- Modify: `files/claude_agents.py` (`CONFIG_KEYS` ~73–91, `DEFAULT_THEME` ~115–117, `state`/`_app` ~119–123, `live_sessions` ~1253–1270, `open_worktree` ~1288–1324, `activate` ~1342–1362, `refresher` ~1365–1384, `_hex`/`_mix`/`theme_poller` ~1396–1438)
|
||||
- Modify: `files/config.example.json` (убрать `use_profile_theme`, `theme_variant`)
|
||||
- Modify: `.gitignore` (добавить `__pycache__/`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `async def refresher() -> None` (без аргумента; дерево строится как `build_tree(sessions, None, procs, tmux)`); `activate(uuid)` и `open_worktree(uuid, path)` — сигнатуры прежние, для целей не `tmux:` ничего не делают. Удалены: `live_sessions`, `theme_poller`, `_hex`, `_mix`, `_app`, `USE_PROFILE_THEME`, `THEME_VARIANT`, ключи `use_profile_theme`/`theme_variant` из `CONFIG_KEYS`, ветки `tmux_twins` в `activate`.
|
||||
- `build_tree` не меняется.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_core.py` (в начале — `sys.path.insert(0, <repo>/files)`; `import claude_agents as ca`):
|
||||
- `test_default_theme_matches_chat_profile`: `ca.DEFAULT_THEME == {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91", "green": "#859900", "yellow": "#b58900", "red": "#dc322f", "font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}`.
|
||||
- `test_iterm_coupling_removed`: для `name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT", "_app", "_mix", "_hex")` — `assertFalse(hasattr(ca, name))`; `"use_profile_theme"` и `"theme_variant"` не в `ca.CONFIG_KEYS`.
|
||||
- `test_old_theme_keys_ignored`: временный `config.json` с `{"use_profile_theme": false, "theme_variant": "dark", "poll_sec": 2}`; подменить `ca.CONFIG_PATH`, `ca._config_mtime = None`; `ca.load_config()` → `ca.state.get("config_error") is None`, `ca.POLL_SEC == 2.0`. Восстановить `POLL_SEC` и `CONFIG_PATH` в `tearDown`.
|
||||
- `test_activate_tmux_target`: `ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]`, `ca._tmux` подменён записью вызовов; `asyncio.run(ca.activate("tmux:/tmp/s|%3"))` → вызовы `[("/tmp/s", "switch-client", "-t", "%3"), ("/tmp/s", "select-window", "-t", "%3"), ("/tmp/s", "select-pane", "-t", "%3")]`.
|
||||
- `test_activate_non_tmux_is_noop`: дерево с `uuid "w0t0p0:ABC"`; `asyncio.run(ca.activate("w0t0p0:ABC"))` — без исключения, вызовов `_tmux` нет.
|
||||
- `test_open_worktree_non_tmux_is_noop`: `ca.known_roots` подменён на `lambda: {tmpdir}`; `asyncio.run(ca.open_worktree("w0t0p0:ABC", tmpdir))` — без исключения, вызовов `_tmux` нет.
|
||||
- `test_open_worktree_tmux_splits`: дерево с `uuid "tmux:/tmp/s|%3"`, `known_roots → {tmpdir}`, `ca.FILE_MANAGER = "lf"`; `_last_open.clear()`; вызов → `[("/tmp/s", "split-window", "-h", "-t", "%3", "-c", tmpdir, "exec lf")]`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
Run: `python3 -m unittest tests.test_core -v`
|
||||
Expected: FAIL на `test_default_theme_matches_chat_profile`, `test_iterm_coupling_removed`, `test_old_theme_keys_ignored` (ключ всё ещё известен), `test_open_worktree_non_tmux_is_noop` может упасть на `_app`; tmux-тесты могут пройти уже сейчас — это нормально.
|
||||
|
||||
- [ ] **Step 3: Implement** в `files/claude_agents.py`: обновить `DEFAULT_THEME` значениями из Global Constraints; удалить перечисленное в Interfaces; `refresher()` без параметра (в прежнем `main` — вызов `refresher()` и без запуска `theme_poller`, чтобы модуль оставался рабочим до Task 2); в `activate` и `open_worktree` оставить только ветку `tmux:` и ранний `return` для остального; убрать из `state` ключ `"live"` и из `/raw` поле `theme_error`. Обновить модульный docstring: установка через `install.sh`, фоновая служба, без упоминания `settings-hooks.json` (закрывает O-1 из issue #3). Из `config.example.json` убрать два ключа. В `.gitignore` добавить строку `__pycache__/`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
Run: `python3 -m unittest tests.test_core -v && python3 -m py_compile files/claude_agents.py`
|
||||
Expected: все `OK`; `grep -n "iterm2\." files/claude_agents.py` показывает только `async_register_web_view_tool` в `main`/регистраторе и `run_forever`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add .gitignore tests/test_core.py files/claude_agents.py files/config.example.json
|
||||
git commit -m "refactor: drop live iTerm2 coupling from panel server (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Фоновый режим, регистратор Toolbelt и точка входа
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_service.py`
|
||||
- Modify: `files/claude_agents.py` (`main` ~1487–1506 → `serve`, `register_toolbelt`, `port_alive`, `kickstart_service`, `run`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `refresher()`, `usage_poller()`, `balance_poller()`, `handle` (Task 1 / существующие).
|
||||
- Produces:
|
||||
- `SERVICE_LABEL = "com.andrey.claude-agents"` (модульная константа).
|
||||
- `async def serve() -> None` — `load_config()`, `asyncio.start_server(handle, "127.0.0.1", PORT)` (ошибка bind → `OSError` наружу), задачи `refresher()`, `usage_poller()`, `balance_poller()`, затем работает до отмены; при отмене закрывает сервер.
|
||||
- `def port_alive(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool` — удаётся ли TCP-подключение.
|
||||
- `def kickstart_service() -> None` — `subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{SERVICE_LABEL}"], capture_output=True, timeout=10)`, исключения глотаются.
|
||||
- `async def register_toolbelt(connection) -> None` — `load_config()`; если `not port_alive(PORT)` → `kickstart_service()`; затем `iterm2.tool.async_register_web_view_tool(connection, display_name="Claude", identifier=SERVICE_LABEL, reveal_if_already_registered=False, url=f"http://127.0.0.1:{PORT}/")`. Сервер не поднимает.
|
||||
- `def run(argv: list) -> None` — `"--headless" in argv` → `asyncio.run(serve())`; иначе если `iterm2 is not None` → `iterm2.run_forever(register_toolbelt)`; иначе `raise SystemExit("нужен iTerm2 или флаг --headless")`. Блок `if __name__ == "__main__": run(sys.argv[1:])`.
|
||||
- Функция `main` удаляется.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_service.py` (подмены через `unittest.mock.patch.object`; свободный порт — `socket.bind(("127.0.0.1", 0))`):
|
||||
- `test_port_alive`: слушающий сокет на свободном порту → `True`; после закрытия → `False`.
|
||||
- `test_serve_answers`: подменить `PORT` (свободный), `CONFIG_PATH` (несуществующий), `load_sessions → []`, `fetch_usage → {}`, `fetch_balance → ({}, "test")`; внутри `asyncio.run` запустить `serve()` задачей, дождаться `port_alive` (≤ 3 с); GET `/` (через `loop.run_in_executor` + `urllib.request`) → статус 200, в теле `'<div id="root">'`; GET `/raw` → JSON с ключом `"tree"`; отменить задачу, порт освобождён.
|
||||
- `test_serve_survives_usage_failure`: как выше, но `fetch_usage` бросает `FileNotFoundError`; GET `/limits` (опрос ≤ 3 с) содержит `"Не найден токен Claude Code"`; GET `/` → 200.
|
||||
- `test_serve_busy_port_raises`: занять порт слушающим сокетом, `PORT` = он; `with self.assertRaises(OSError): asyncio.run(ca.serve())`.
|
||||
- `test_register_toolbelt_kickstarts_when_port_dead`: фальшивый `iterm2` (`types.SimpleNamespace(tool=SimpleNamespace(async_register_web_view_tool=<async-запись kwargs>))`), `port_alive → False`, `kickstart_service` — запись; `asyncio.run(ca.register_toolbelt(object()))` → kickstart вызван 1 раз; регистрация с `display_name="Claude"`, `identifier="com.andrey.claude-agents"`, `reveal_if_already_registered=False`, `url="http://127.0.0.1:8766/"` (при `PORT = 8766`).
|
||||
- `test_register_toolbelt_skips_kickstart_when_alive`: `port_alive → True` → kickstart не вызван, регистрация вызвана.
|
||||
- `test_kickstart_command`: `subprocess.run` подменён; `ca.kickstart_service()` → argv `["launchctl", "kickstart", f"gui/{os.getuid()}/com.andrey.claude-agents"]`; при `subprocess.run`, бросающем `OSError`, исключения наружу нет.
|
||||
- `test_run_headless_calls_serve`: `serve` подменён async-функцией с флагом; `ca.run(["--headless"])` → флаг выставлен.
|
||||
- `test_run_without_iterm_exits`: `iterm2 = None`; `with assertRaises(SystemExit): ca.run([])`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
Run: `python3 -m unittest tests.test_service -v`
|
||||
Expected: FAIL / ERROR — `AttributeError: ... has no attribute 'serve'` и т.п.
|
||||
|
||||
- [ ] **Step 3: Implement** перечисленное в Interfaces в `files/claude_agents.py`, рядом с бывшим `main`; добавить `import sys`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
Run: `python3 -m unittest discover -s tests -v`
|
||||
Expected: все `OK` (включая Task 1).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add tests/test_service.py files/claude_agents.py
|
||||
git commit -m "feat: headless panel server and Toolbelt registrar entry points (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Генератор LaunchAgent
|
||||
|
||||
**Files:**
|
||||
- Create: `files/launchagent.py`
|
||||
- Create: `tests/test_launchagent.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `LABEL = "com.andrey.claude-agents"`
|
||||
- `def plist(python: str, script: str, log: str) -> dict` → `{"Label": LABEL, "ProgramArguments": [python, script, "--headless"], "RunAtLoad": True, "KeepAlive": True, "StandardOutPath": log, "StandardErrorPath": log}`
|
||||
- CLI: `python3 files/launchagent.py write <plist-path> <python> <script> <log>` — создаёт родительский каталог plist и каталог лога, пишет `plistlib.dump(..., fmt=FMT_XML)` атомарно (`.tmp` + `os.replace`, как `settings_hooks.py`), печатает путь. Неизвестная команда / не те аргументы → usage в stderr, код 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_launchagent.py`:
|
||||
- `test_label_matches_server`: `launchagent.LABEL == "com.andrey.claude-agents" == claude_agents.SERVICE_LABEL`.
|
||||
- `test_plist_shape`: `plist("/opt/homebrew/bin/python3", "/x/claude_agents.py", "/l/panel.log")` равен словарю из Interfaces.
|
||||
- `test_cli_writes_plist_with_spaces`: во временном каталоге `subprocess.run([sys.executable, "files/launchagent.py", "write", f"{tmp}/LaunchAgents/a.plist", "/usr/bin/python3", f"{tmp}/Application Support/claude_agents.py", f"{tmp}/Logs/claude-agents/panel.log"])` → код 0; `plistlib.load` файла равен `plist(...)` с теми же аргументами (путь с пробелами — один элемент `ProgramArguments`); каталог `{tmp}/Logs/claude-agents` существует.
|
||||
- `test_cli_bad_args`: `[..., "launchagent.py", "write"]` → код 2.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
Run: `python3 -m unittest tests.test_launchagent -v`
|
||||
Expected: ERROR `ModuleNotFoundError: No module named 'launchagent'`.
|
||||
|
||||
- [ ] **Step 3: Implement `files/launchagent.py`** — docstring и стиль как у `files/settings_hooks.py`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
Run: `python3 -m unittest discover -s tests -v`
|
||||
Expected: все `OK`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add files/launchagent.py tests/test_launchagent.py
|
||||
git commit -m "feat: LaunchAgent plist generator for panel service (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Установка и удаление службы, документация
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_install.py`
|
||||
- Modify: `install.sh`, `uninstall.sh`
|
||||
- Modify: `README.md`, `CLAUDE.md` (разделы «Архитектура», «Команды», «Тесты»), `ARCHITECTURE.md` (схема, таблица модулей, границы)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: CLI `files/launchagent.py write` (Task 3); `claude_agents.py --headless` (Task 2).
|
||||
- Produces (пути, на которые опирается Task 6):
|
||||
- копия службы: `$HOME/.local/share/claude-agents/claude_agents.py`
|
||||
- plist: `$HOME/Library/LaunchAgents/com.andrey.claude-agents.plist`
|
||||
- лог: `$HOME/Library/Logs/claude-agents/panel.log`
|
||||
- копия регистратора: `$AUTOLAUNCH/claude_agents.py` (как раньше)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_install.py` (каждый — во временном пустом `HOME`, `env = {**os.environ, "HOME": tmp, "SSH_CONNECTION": "10.0.0.2 50000 10.0.0.1 22"}`; второй вариант — `SSH_TTY="/dev/ttys009"` без `SSH_CONNECTION`):
|
||||
- `test_install_refuses_over_ssh` (оба варианта): `subprocess.run(["bash", "install.sh"], env=env, capture_output=True, text=True)` → `returncode != 0`, в `stdout + stderr` есть `"за самим Mac"`, `os.listdir(tmp) == []`.
|
||||
- `test_uninstall_refuses_over_ssh` (оба варианта): то же для `uninstall.sh` и `uninstall.sh --purge`.
|
||||
|
||||
- [ ] **Step 2: НЕ запускать тест до охранной проверки.** Без неё `install.sh` даже во временном `HOME` выполнит `lsof … kill` по порту 8766 (остановит настоящую панель) и `defaults write com.googlecode.iterm2`, а `uninstall.sh` — тоже kill по порту. Провал теста здесь не проверяется; вместо этого в Step 4 тест должен пройти, а охрана — стоять первой исполняемой проверкой (ревьюер сверяет по диффу). Реальные `install.sh`/`uninstall.sh` в этой задаче не запускать — это Task 6.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
- Оба скрипта: первой исполняемой проверкой после `set -euo pipefail` и объявления функций вывода — если `[ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ]`, вывести `запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить` и `exit 1` (в `install.sh` через `die`; в `uninstall.sh` добавить такую же `die`).
|
||||
- `install.sh`, новые переменные: `LABEL=com.andrey.claude-agents`, `SERVICE_DIR="$HOME/.local/share/claude-agents"`, `PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"`, `SERVICE_LOG="$HOME/Library/Logs/claude-agents/panel.log"`, `PY="$(python3 -c 'import sys; print(sys.executable)')"`, `GUI="gui/$(id -u)"`.
|
||||
- Новая секция «Фоновая служба» после секции «Скрипт iTerm2»: `install -m 644 "$FILES/claude_agents.py" "$SERVICE_DIR/claude_agents.py"`; `python3 "$FILES/launchagent.py" write "$PLIST" "$PY" "$SERVICE_DIR/claude_agents.py" "$SERVICE_LOG"`; `launchctl bootout "$GUI/$LABEL"` (ошибку игнорировать); затем существующий блок «старая копия панели держит порт — останавливаем» (перенести сюда, чтобы он шёл после `bootout`); `launchctl bootstrap "$GUI" "$PLIST"` (ошибка → `die` с путём к логу); ждать до 10 с, пока `curl -s -o /dev/null --max-time 1 http://127.0.0.1:$PORT/` не ответит; ответил → `ok "служба запущена: http://127.0.0.1:$PORT/"`, нет → `warn "служба не ответила — смотрите $SERVICE_LOG"`.
|
||||
- Секция iTerm2: логика `EnableAPIServer` и запуск скрипта через `osascript` остаются; сообщения — «регистрация в Toolbelt», а не «панель запущена».
|
||||
- Итоговый текст: упомянуть, что панель работает и без iTerm2, адрес и лог.
|
||||
- `uninstall.sh`: `launchctl bootout "gui/$(id -u)/com.andrey.claude-agents"` (ошибку игнорировать) **до** блока остановки по порту; удалить plist и `$HOME/.local/share/claude-agents`; `--purge` — ещё и `$HOME/Library/Logs/claude-agents`.
|
||||
- `README.md`: установка только за самим Mac; в таблице «Что ставится» — строки для копии службы, plist, лога; раздел «С iPad»: Termius → Port Forwarding → Local, local port `8766`, bind `127.0.0.1`, destination `127.0.0.1:8766`, открыть `http://127.0.0.1:8766` (именно `127.0.0.1`, не `localhost`); убрать упоминание темы профиля; «Если что-то не так» — лог службы и `launchctl print gui/$(id -u)/com.andrey.claude-agents`.
|
||||
- `CLAUDE.md` / `ARCHITECTURE.md`: два процесса → служба launchd (сервер) + регистратор в iTerm2 + хук; команда тестов `python3 -m unittest discover -s tests -v`; убрать упоминания `theme_poller` и `live_sessions`.
|
||||
|
||||
- [ ] **Step 4: Run tests and syntax checks**
|
||||
Run: `python3 -m unittest discover -s tests -v && bash -n install.sh && bash -n uninstall.sh`
|
||||
Expected: все `OK`, синтаксис без ошибок.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add tests/test_install.py install.sh uninstall.sh README.md CLAUDE.md ARCHITECTURE.md
|
||||
git commit -m "feat: install panel as launchd service, refuse install over SSH (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Фон панели на клиенте
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_appearance.py`
|
||||
- Modify: `files/claude_agents.py` (`render_limits` ~1244–1248, `render_page` ~840–1028)
|
||||
- Modify: `README.md` (раздел «Работа с панелью»: ⚙)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEFAULT_THEME` (Task 1).
|
||||
- Produces:
|
||||
- `BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")`
|
||||
- `LIGHT_BG_TEXT = "#3b4a50"`
|
||||
- `CLIENT_COLORS_JS: str` — JS-код с чистой функцией `panelColors(bg)` → `{bg, fg, dim}` или `null`, если `bg` не `#rrggbb`. `fg` = `LIGHT_BG_TEXT`, если относительная яркость фона по WCAG > 0.4, иначе `DEFAULT_THEME["fg"]`; `dim` = покомпонентно `round(fg * 0.6 + bg * 0.4)` (та же формула, что давала `#668a91` для профиля), строкой `#rrggbb` в нижнем регистре.
|
||||
- Ключ `localStorage`: `claude-panel-bg`.
|
||||
- Разметка: `render_limits` добавляет в строку `.foot` пустой `<span class="gear-slot"></span>`; `render_page` после `#limits` выводит статичный `<div id="bgctl">` с `<span id="gear-home">`, внутри которого лежит `<span id="gear">⚙</span>`, и скрытой строкой `<div id="bgrow">` с образцами `<span class="sw" data-bg="#…">` для каждого `BG_PRESETS`, `<input type="color" id="bg-custom">` и `<span id="bg-reset">сброс</span>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_appearance.py`:
|
||||
- `test_page_applies_saved_bg_before_body`: `html = ca.render_page()`; `html.index("claude-panel-bg") < html.index("<body>")`; `CLIENT_COLORS_JS` целиком входит в `html`; чтение `localStorage` обёрнуто в `try` (в `html` до `<body>` есть `try {`).
|
||||
- `test_page_has_bg_controls`: в `html` есть `id="bgctl"`, `id="gear"`, `id="bgrow"`, `type="color"`, `id="bg-reset"`, и для каждого `hex` из `BG_PRESETS` — `data-bg="{hex}"`; `id="bgctl"` идёт после закрытия `#limits` (не внутри перерисовываемого блока).
|
||||
- `test_limits_foot_has_gear_slot`: `ca.state["usage_updated"] = time.time()`, `usage_error = None` → `'class="gear-slot"'` в `ca.render_limits()`; восстановить `state`.
|
||||
- `test_panel_colors_js` (`@unittest.skipUnless(shutil.which("node"), "нет node")`): `node -e` с `CLIENT_COLORS_JS + "console.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000'), panelColors('nope'), panelColors('#002b36')]))"` → `[{"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"}, {"bg": "#000000", "fg": "#aacace", "dim": "#66797c"}, null, {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"}]`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
Run: `python3 -m unittest tests.test_appearance -v`
|
||||
Expected: ERROR `AttributeError: ... 'CLIENT_COLORS_JS'` / FAIL по разметке.
|
||||
|
||||
- [ ] **Step 3: Implement** в `files/claude_agents.py`:
|
||||
- В `<head>` после `<style>`: `<script>` с `CLIENT_COLORS_JS` и `applyBg(hex)` — ставит `--bg`, `--fg`, `--dim` в `document.documentElement.style`; сразу читает `claude-panel-bg` в `try/catch` и применяет, если `panelColors` не `null`.
|
||||
- В конце `<body>`-скрипта: тап по `#gear` переключает видимость `#bgrow`; тап по `.sw` → `applyBg` + сохранить; `input` на `#bg-custom` → то же; `#bg-reset` → удалить ключ, снять inline-переменные (`removeProperty`); каждая запись/удаление — в `try/catch`. `placeGear()`: если в `#limits` есть `.gear-slot` — перенести туда существующий узел `#gear` (`appendChild`), иначе вернуть в `#gear-home`; вызывать при загрузке и после каждой вставки в `refreshLimits`.
|
||||
- CSS: `#gear`, `.sw`, `#bg-reset`, `#bg-custom` — область нажатия не меньше 36×36 CSS px; `.sw` — круг с рамкой `color-mix(in srgb, var(--fg) 35%, transparent)`; `#bgrow` по умолчанию `display:none`, раскрытый — `display:flex; gap:8px; align-items:center`.
|
||||
- `README.md`: строка про ⚙ — фон панели на этом устройстве, «сброс» — тема по умолчанию.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
Run: `python3 -m unittest discover -s tests -v`
|
||||
Expected: все `OK` (`test_panel_colors_js` не пропущен — `node` есть в `/opt/homebrew/bin`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add tests/test_appearance.py files/claude_agents.py README.md
|
||||
git commit -m "feat: client-side panel background with gear control (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Приёмка на настоящем Mac (выполняет человек)
|
||||
|
||||
Задача меняет окружение пользователя (launchd, `~/.claude/settings.json`, iTerm2), поэтому её **не выполняют сабагенты SDD**. Контроллер останавливается после финального ревью ветки и передаёт этот чеклист человеку; результаты (особенно п. 7) записываются в `openspec/changes/headless-panel/design.md` отдельным коммитом.
|
||||
|
||||
**Files:**
|
||||
- Modify: `openspec/changes/headless-panel/design.md` (результат проверки `localStorage` в Toolbelt, найденные отклонения)
|
||||
|
||||
- [ ] **Step 1:** За самим Mac, из worktree: `./install.sh` → все строки `✓`, в конце «служба запущена: http://127.0.0.1:8766/». `launchctl print gui/$(id -u)/com.andrey.claude-agents | grep state` → `state = running`. `lsof -nP -iTCP:8766 -sTCP:LISTEN` → один процесс, адрес `127.0.0.1:8766`. Если macOS спросит доступ к Keychain для `security` — разрешить и отметить в design.md.
|
||||
- [ ] **Step 2:** Полностью закрыть iTerm2 (⌘Q). С iPad через туннель Termius открыть `http://127.0.0.1:8766` → дерево сессий из tmux с моделями, контекстом, worktree; внизу лимиты и баланс.
|
||||
- [ ] **Step 3:** С iPad: «↗ вкладка» у сессии → tmux переключился на её окно/панель; `⎇ папка` → в том же окне tmux открылась панель с `lf`.
|
||||
- [ ] **Step 4:** `kill <PID службы>` → в течение ~30 с порт снова отвечает (`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8766/` → `200`).
|
||||
- [ ] **Step 5:** Запустить iTerm2 → Toolbelt → Claude показывает ту же страницу; `lsof` по-прежнему один слушатель (процесс службы, не iTerm2).
|
||||
- [ ] **Step 6:** В Safari на iPad: ⚙ → светлый образец → фон светлый, текст тёмный; подождать минуту (автоперезагрузка) — фон держится без мелькания; «сброс» → тема по умолчанию; «свой цвет» открывает палитру iPad.
|
||||
- [ ] **Step 7:** В Toolbelt: выбрать фон, перезапустить iTerm2 → записать в design.md, сохранился ли выбор.
|
||||
- [ ] **Step 8:** `./install.sh` ещё раз → одна служба, в `~/.claude/settings.json` хуки панели не задублированы (`grep -c agents_panel_hook ~/.claude/settings.json` — как до повтора).
|
||||
- [ ] **Step 9:** Из SSH-сессии Termius: `./install.sh` → отказ со словами «за самим Mac», панель продолжает отвечать.
|
||||
- [ ] **Step 10 (по желанию):** `./uninstall.sh` → `launchctl print gui/$(id -u)/com.andrey.claude-agents` сообщает, что службы нет; порт 8766 свободен; plist удалён. Затем `./install.sh`, чтобы вернуть панель.
|
||||
- [ ] **Step 11:** Commit записей в design.md:
|
||||
```bash
|
||||
git add openspec/changes/headless-panel/design.md
|
||||
git commit -m "docs: acceptance results for headless panel (#4)"
|
||||
```
|
||||
@ -1,22 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude — панель для iTerm2 Toolbelt.
|
||||
Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает
|
||||
iTerm2 Toolbelt.
|
||||
|
||||
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
|
||||
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии.
|
||||
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии.
|
||||
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
|
||||
|
||||
Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в
|
||||
~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов.
|
||||
Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их
|
||||
не сообщил, берутся из транскриптов.
|
||||
|
||||
Установка:
|
||||
1. Хук: скопировать agents_panel_hook.sh в ~/.claude/hooks/ и добавить блок
|
||||
"hooks" из settings-hooks.json в ~/.claude/settings.json
|
||||
2. Панель: этот файл — в ~/Library/Application Support/iTerm2/Scripts/AutoLaunch/
|
||||
3. Toolbelt → Claude (старый скрипт лимитов claude_usage.py из AutoLaunch удалить)
|
||||
Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd).
|
||||
|
||||
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
|
||||
Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта).
|
||||
Изменения подхватываются на лету, кроме port (нужен перезапуск службы).
|
||||
|
||||
Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
|
||||
Только стандартная библиотека.
|
||||
@ -27,9 +24,10 @@ import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@ -58,9 +56,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого
|
||||
CONTEXT_CRIT = 700_000 # … и красное
|
||||
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
|
||||
|
||||
FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree
|
||||
USE_PROFILE_THEME = True
|
||||
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
|
||||
FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree
|
||||
|
||||
# лимиты подписки и баланс (нижняя часть панели)
|
||||
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
|
||||
@ -78,8 +74,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме
|
||||
"keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400),
|
||||
"agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"),
|
||||
"file_manager": ("FILE_MANAGER", str),
|
||||
"use_profile_theme": ("USE_PROFILE_THEME", bool),
|
||||
"theme_variant": ("THEME_VARIANT", lambda v: v if v in ("auto", "dark", "light") else "auto"),
|
||||
"context_warn": ("CONTEXT_WARN", int),
|
||||
"context_crit": ("CONTEXT_CRIT", int),
|
||||
"history_max_chars": ("HISTORY_MAX_CHARS", int),
|
||||
@ -112,14 +106,14 @@ def load_config():
|
||||
state["config_error"] = None
|
||||
except Exception as e:
|
||||
state["config_error"] = f"config.json: {e}"
|
||||
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90",
|
||||
# Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой
|
||||
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
|
||||
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
|
||||
"font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12}
|
||||
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}
|
||||
|
||||
state = {"theme": dict(DEFAULT_THEME), "live": None, "tree": [], "error": None,
|
||||
state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None,
|
||||
"usage": None, "usage_error": None, "usage_updated": None,
|
||||
"balance": None, "balance_error": None, "balance_via": None}
|
||||
_app = None
|
||||
_server = None
|
||||
|
||||
|
||||
@ -837,6 +831,35 @@ def _render_tree() -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# Фон панели выбирается на клиенте (⚙ внизу) и хранится в localStorage этого браузера/web view;
|
||||
# текст и приглушённый текст подбираются под яркость фона, цвета статусов не меняются.
|
||||
BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")
|
||||
LIGHT_BG_TEXT = "#3b4a50" # текст на светлом фоне; на тёмном — fg темы по умолчанию
|
||||
BG_STORAGE_KEY = "claude-panel-bg"
|
||||
CLIENT_COLORS_JS = """function panelColors(bg) {
|
||||
if (typeof bg !== 'string' || !/^#[0-9a-f]{6}$/i.test(bg)) return null;
|
||||
bg = bg.toLowerCase();
|
||||
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
const lum = h => { const [r, g, b] = rgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
||||
const ratio = (a, c) => (Math.max(a, c) + 0.05) / (Math.min(a, c) + 0.05); // контраст по WCAG
|
||||
const L = lum(bg); // текст — тот из двух, что контрастнее фону
|
||||
const fg = ratio(lum('__LIGHT__'), L) > ratio(lum('__DARK__'), L) ? '__LIGHT__' : '__DARK__';
|
||||
const f = rgb(fg), k = rgb(bg); // dim — как у темы: 60% текста, 40% фона
|
||||
const dim = '#' + f.map((c, i) => Math.round(c * 0.6 + k[i] * 0.4).toString(16).padStart(2, '0')).join('');
|
||||
return {bg, fg, dim};
|
||||
}""".replace("__LIGHT__", LIGHT_BG_TEXT).replace("__DARK__", DEFAULT_THEME["fg"])
|
||||
|
||||
|
||||
def render_bgctl() -> str:
|
||||
swatches = "".join(f'<span class="sw" data-bg="{c}" title="{c}"><i style="background:{c}"></i></span>'
|
||||
for c in BG_PRESETS)
|
||||
return (f'<div id="bgctl"><div id="bgrow">{swatches}'
|
||||
f'<input type="color" id="bg-custom" title="свой цвет" value="{DEFAULT_THEME["bg"]}">'
|
||||
f'<span id="bg-reset">сброс</span></div>'
|
||||
f'<span id="gear-home"><span id="gear" title="Фон панели">⚙</span></span></div>')
|
||||
|
||||
|
||||
def render_page() -> str:
|
||||
t = state["theme"]
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8">
|
||||
@ -863,7 +886,23 @@ def render_page() -> str:
|
||||
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
|
||||
.sub {{ color:var(--dim); }}
|
||||
.split {{ margin:-2px 0 7px; }}
|
||||
.foot {{ margin-top:2px; opacity:.8; }}
|
||||
.foot {{ margin-top:2px; opacity:.8; display:flex; align-items:center; }}
|
||||
.gear-slot {{ margin-left:auto; }}
|
||||
/* фон на этом устройстве: ⚙ в строке «обновлено», образцы — под блоком лимитов */
|
||||
#bgctl {{ flex:none; padding:0 10px; }}
|
||||
#gear-home {{ display:flex; justify-content:flex-end; }}
|
||||
#gear {{ display:inline-flex; align-items:center; justify-content:center; min-width:36px; min-height:36px;
|
||||
margin:-10px -8px; cursor:pointer; color:var(--dim); font-size:15px; }}
|
||||
#gear:hover {{ color:var(--fg); }}
|
||||
#bgrow {{ display:none; gap:4px; align-items:center; flex-wrap:wrap; padding:0 0 8px; }}
|
||||
#bgrow.open {{ display:flex; }}
|
||||
.sw {{ display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; cursor:pointer; }}
|
||||
.sw i {{ width:24px; height:24px; border-radius:50%; box-sizing:border-box;
|
||||
border:1px solid color-mix(in srgb, var(--fg) 35%, transparent); }}
|
||||
#bg-custom {{ width:36px; height:36px; padding:4px; border:none; background:none; cursor:pointer; }}
|
||||
#bg-reset {{ display:inline-flex; align-items:center; min-height:36px; padding:0 6px; margin-left:auto;
|
||||
color:var(--dim); cursor:pointer; }}
|
||||
#bg-reset:hover {{ color:var(--fg); }}
|
||||
.foot.stale {{ color:var(--yellow); }}
|
||||
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
|
||||
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }}
|
||||
@ -922,9 +961,21 @@ def render_page() -> str:
|
||||
.msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
|
||||
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }}
|
||||
.trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
|
||||
</style></head><body>
|
||||
</style>
|
||||
<script>
|
||||
{CLIENT_COLORS_JS}
|
||||
function applyBg(hex) {{
|
||||
const c = panelColors(hex), s = document.documentElement.style;
|
||||
if (!c) {{ ['--bg', '--fg', '--dim'].forEach(p => s.removeProperty(p)); return false; }}
|
||||
s.setProperty('--bg', c.bg); s.setProperty('--fg', c.fg); s.setProperty('--dim', c.dim);
|
||||
return true;
|
||||
}}
|
||||
// до отрисовки — чтобы при каждой перезагрузке не мелькала тема по умолчанию
|
||||
try {{ applyBg(localStorage.getItem('{BG_STORAGE_KEY}')); }} catch (e) {{}}
|
||||
</script></head><body>
|
||||
<div id="root">{render_tree()}</div>
|
||||
<div id="limits">{render_limits()}</div>
|
||||
{render_bgctl()}
|
||||
<div id="ov">
|
||||
<div class="ov-head"><span class="ov-title"></span>
|
||||
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
|
||||
@ -943,7 +994,12 @@ def render_page() -> str:
|
||||
async function refreshLimits() {{
|
||||
try {{
|
||||
const r = await fetch('/limits', {{cache: 'no-store'}});
|
||||
if (r.ok) document.getElementById('limits').innerHTML = await r.text();
|
||||
if (r.ok) {{
|
||||
const html = await r.text();
|
||||
document.getElementById('gear-home').appendChild(gear); // иначе innerHTML уничтожит ⚙
|
||||
document.getElementById('limits').innerHTML = html;
|
||||
placeGear();
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
|
||||
@ -1021,8 +1077,38 @@ def render_page() -> str:
|
||||
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
|
||||
setInterval(loadMsg, 2000);
|
||||
|
||||
// тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто)
|
||||
setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
|
||||
// ---- фон панели на этом устройстве ----
|
||||
const gear = document.getElementById('gear'), bgrow = document.getElementById('bgrow');
|
||||
const bgCustom = document.getElementById('bg-custom');
|
||||
function placeGear() {{
|
||||
const slot = document.querySelector('#limits .gear-slot');
|
||||
(slot || document.getElementById('gear-home')).appendChild(gear);
|
||||
}}
|
||||
function saveBg(hex) {{
|
||||
if (!applyBg(hex)) return;
|
||||
bgCustom.value = hex.toLowerCase();
|
||||
try {{ localStorage.setItem('{BG_STORAGE_KEY}', hex.toLowerCase()); }} catch (e) {{}}
|
||||
}}
|
||||
gear.addEventListener('click', e => {{ e.stopPropagation(); bgrow.classList.toggle('open'); }});
|
||||
bgrow.addEventListener('click', e => {{
|
||||
e.stopPropagation();
|
||||
const sw = e.target.closest('.sw');
|
||||
if (sw) saveBg(sw.dataset.bg);
|
||||
}});
|
||||
bgCustom.addEventListener('input', () => saveBg(bgCustom.value));
|
||||
document.getElementById('bg-reset').addEventListener('click', () => {{
|
||||
applyBg(null);
|
||||
bgCustom.value = '{DEFAULT_THEME["bg"]}';
|
||||
try {{ localStorage.removeItem('{BG_STORAGE_KEY}'); }} catch (e) {{}}
|
||||
}});
|
||||
try {{
|
||||
const saved = localStorage.getItem('{BG_STORAGE_KEY}');
|
||||
if (panelColors(saved)) bgCustom.value = saved;
|
||||
}} catch (e) {{}}
|
||||
placeGear();
|
||||
|
||||
// раз в минуту перезагружаем страницу (если не открыты сообщение и выбор фона) — подхватить новую версию службы
|
||||
setInterval(() => {{ if (!cur && !bgrow.classList.contains('open')) location.reload(); }}, 60000);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
@ -1244,31 +1330,12 @@ def render_limits() -> str:
|
||||
if state["usage_updated"]:
|
||||
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
|
||||
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
|
||||
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>')
|
||||
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}'
|
||||
f'<span class="gear-slot"></span></div>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------- связь с iTerm2 ----------
|
||||
|
||||
async def live_sessions(connection):
|
||||
global _app
|
||||
if _app is None:
|
||||
_app = await iterm2.async_get_app(connection)
|
||||
live = {}
|
||||
for w in _app.terminal_windows:
|
||||
for tab in w.tabs:
|
||||
for sess in tab.sessions:
|
||||
pane = None
|
||||
try:
|
||||
name = await sess.async_get_variable("name")
|
||||
job = await sess.async_get_variable("jobName")
|
||||
pane = await sess.async_get_variable("tmuxWindowPane") # tmux -CC: номер панели
|
||||
except Exception:
|
||||
name, job = sess.name, ""
|
||||
live[sess.session_id] = {"name": name or "", "job": job or "",
|
||||
"tmux_pane": f"%{pane}" if pane is not None else None}
|
||||
return live
|
||||
|
||||
# ---------- действия по клику (через tmux) ----------
|
||||
|
||||
def known_roots():
|
||||
roots = set()
|
||||
@ -1286,13 +1353,15 @@ _last_open = {} # path -> time последнего открытия (защи
|
||||
|
||||
|
||||
async def open_worktree(uuid, path):
|
||||
"""Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер.
|
||||
"""Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер.
|
||||
|
||||
Команда и папка задаются настройками новой панели, а не вводом текста в оболочку:
|
||||
Команда передаётся tmux при создании панели, а не вводом текста в оболочку:
|
||||
так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
|
||||
и панель закрывается сама, когда выходите из lf."""
|
||||
и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются."""
|
||||
if path not in known_roots() or not os.path.isdir(path):
|
||||
return # открываем только папки, которые панель сама показала
|
||||
if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()):
|
||||
return
|
||||
now = time.time()
|
||||
if now - _last_open.get(path, 0) < 3:
|
||||
return # повторный клик/запрос — уже открываем
|
||||
@ -1300,28 +1369,10 @@ async def open_worktree(uuid, path):
|
||||
state["open_log"] = (state.get("open_log") or [])[-9:] + [
|
||||
{"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}]
|
||||
|
||||
if uuid and uuid.startswith("tmux:") and uuid in known_targets():
|
||||
sock, pane = _tmux_target(uuid)
|
||||
cmd = f"exec {FILE_MANAGER}"
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd))
|
||||
return
|
||||
if _app is None:
|
||||
return
|
||||
sess = _app.get_session_by_id(uuid) if uuid and not uuid.startswith("tmux:") else None
|
||||
if sess is None:
|
||||
w = _app.current_terminal_window
|
||||
sess = w.current_tab.current_session if w else None
|
||||
if sess is None:
|
||||
return
|
||||
prof = iterm2.LocalWriteOnlyProfile()
|
||||
prof.set_use_custom_command("Yes")
|
||||
# через login-оболочку, чтобы PATH был как обычно (Homebrew и т.п.)
|
||||
shell = os.environ.get("SHELL") or "/bin/zsh"
|
||||
prof.set_command(f"{shell} -lc {shlex.quote('cd ' + shlex.quote(path) + ' && exec ' + FILE_MANAGER)}")
|
||||
new = await sess.async_split_pane(vertical=True, profile_customizations=prof)
|
||||
if new is not None:
|
||||
await new.async_activate()
|
||||
|
||||
|
||||
def known_targets():
|
||||
@ -1340,41 +1391,27 @@ def _tmux(sock, *args):
|
||||
|
||||
|
||||
async def activate(uuid):
|
||||
if uuid not in known_targets():
|
||||
return # только то, что панель сама показала
|
||||
twin = (state.get("tmux_twins") or {}).get(uuid)
|
||||
if twin and _app is not None: # tmux -CC — переключаемся средствами iTerm2: вкладка и окно спереди
|
||||
sess = _app.get_session_by_id(twin)
|
||||
if sess:
|
||||
await sess.async_activate(select_tab=True, order_window_front=True)
|
||||
return
|
||||
if uuid.startswith("tmux:"):
|
||||
if uuid not in known_targets() or not uuid.startswith("tmux:"):
|
||||
return # только сессии в tmux, которые панель сама показала
|
||||
sock, pane = _tmux_target(uuid)
|
||||
loop = asyncio.get_running_loop()
|
||||
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
|
||||
("select-pane", "-t", pane)):
|
||||
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a))
|
||||
return
|
||||
if _app is None:
|
||||
return
|
||||
sess = _app.get_session_by_id(uuid)
|
||||
if sess:
|
||||
await sess.async_activate(select_tab=True, order_window_front=True)
|
||||
|
||||
|
||||
async def refresher(connection):
|
||||
async def refresher():
|
||||
last_cleanup = 0
|
||||
while True:
|
||||
try:
|
||||
load_config()
|
||||
live = await live_sessions(connection) if connection is not None else None
|
||||
loop = asyncio.get_running_loop()
|
||||
sessions = await loop.run_in_executor(None, load_sessions)
|
||||
alive_now = [x for x in sessions if not x["ended"]]
|
||||
procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now])
|
||||
tmux = await loop.run_in_executor(
|
||||
None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]})
|
||||
state["tree"] = build_tree(sessions, live, procs, tmux)
|
||||
state["tree"] = build_tree(sessions, None, procs, tmux)
|
||||
state["error"] = None
|
||||
if time.time() - last_cleanup > 3600:
|
||||
cleanup_logs()
|
||||
@ -1393,49 +1430,6 @@ def cleanup_logs():
|
||||
pass
|
||||
|
||||
|
||||
def _hex(c) -> str:
|
||||
return "#%02x%02x%02x" % (round(c.red), round(c.green), round(c.blue))
|
||||
|
||||
|
||||
def _mix(c1, c2, k: float) -> str:
|
||||
return "#%02x%02x%02x" % tuple(round(a * k + b * (1 - k)) for a, b in
|
||||
[(c1.red, c2.red), (c1.green, c2.green), (c1.blue, c2.blue)])
|
||||
|
||||
|
||||
async def theme_poller(connection):
|
||||
"""Цвета и шрифт — из профиля текущей сессии. Если в профиле включены отдельные цвета
|
||||
для светлой и тёмной темы, берётся вариант, который iTerm2 сейчас показывает."""
|
||||
while True:
|
||||
try:
|
||||
app = _app or await iterm2.async_get_app(connection)
|
||||
p = await app.current_terminal_window.current_tab.current_session.async_get_profile()
|
||||
suffix = ""
|
||||
if getattr(p, "use_separate_colors_for_light_and_dark_mode", False):
|
||||
mode = [THEME_VARIANT] if THEME_VARIANT != "auto" else await app.async_get_theme()
|
||||
suffix = " (Dark)" if "dark" in mode else " (Light)"
|
||||
|
||||
def color(key):
|
||||
return p.get_color_with_key(key + suffix) or p.get_color_with_key(key)
|
||||
|
||||
theme = dict(DEFAULT_THEME)
|
||||
bg, fg = color("Background Color"), color("Foreground Color")
|
||||
if bg is not None and fg is not None:
|
||||
theme.update(bg=_hex(bg), fg=_hex(fg), dim=_mix(fg, bg, 0.6))
|
||||
for name, idx in (("red", 1), ("green", 2), ("yellow", 3)):
|
||||
c = color(f"Ansi {idx} Color")
|
||||
if c is not None:
|
||||
theme[name] = _hex(c)
|
||||
font = (p.normal_font or "").rsplit(" ", 1)
|
||||
if len(font) == 2:
|
||||
theme["font"] = f"'{font[0].split('-')[0]}', '{font[0]}', ui-monospace, monospace"
|
||||
theme["size"] = min(int(float(font[1])), 13)
|
||||
theme["source"] = f"profile {p.name!r}{suffix or ''}"
|
||||
state["theme"] = theme
|
||||
except Exception as e:
|
||||
state["theme_error"] = str(e)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
# ---------- HTTP ----------
|
||||
|
||||
async def handle(reader, writer):
|
||||
@ -1443,6 +1437,20 @@ async def handle(reader, writer):
|
||||
request = (await reader.read(2048)).decode("latin-1")
|
||||
target = request.split(" ", 2)[1] if request.count(" ") >= 2 else "/"
|
||||
url = urllib.parse.urlparse(target)
|
||||
headers = {}
|
||||
for line in request.split("\r\n")[1:]:
|
||||
name, sep, value = line.partition(":")
|
||||
if sep:
|
||||
headers[name.strip().lower()] = value.strip()
|
||||
port = writer.get_extra_info("sockname")[1]
|
||||
# защита от DNS rebinding и чужих страниц в браузере: только свой адрес в Host,
|
||||
# а действия (/activate, /open-wt) — ещё и не из чужого сайта
|
||||
if (headers.get("host", "").lower() not in (f"127.0.0.1:{port}", f"localhost:{port}")
|
||||
or (url.path in ("/activate", "/open-wt")
|
||||
and headers.get("sec-fetch-site", "").lower() == "cross-site")):
|
||||
writer.write(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
return
|
||||
ctype = "text/html; charset=utf-8"
|
||||
if url.path == "/limits":
|
||||
body = render_limits()
|
||||
@ -1463,7 +1471,7 @@ async def handle(reader, writer):
|
||||
body = "ok"
|
||||
elif url.path == "/raw":
|
||||
body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"),
|
||||
"theme": state["theme"], "theme_error": state.get("theme_error"),
|
||||
"theme": state["theme"],
|
||||
"usage_error": state["usage_error"], "balance_via": state["balance_via"],
|
||||
"balance_error": state["balance_error"],
|
||||
"log_files": sorted(os.path.basename(p) for p in
|
||||
@ -1484,23 +1492,70 @@ async def handle(reader, writer):
|
||||
writer.close()
|
||||
|
||||
|
||||
async def main(connection):
|
||||
# ---------- запуск ----------
|
||||
# Два режима одного файла: служба launchd (--headless) держит порт и всё считает,
|
||||
# скрипт AutoLaunch в iTerm2 только показывает её страницу в Toolbelt.
|
||||
|
||||
SERVICE_LABEL = "com.andrey.claude-agents" # метка LaunchAgent и идентификатор web view
|
||||
|
||||
|
||||
async def serve():
|
||||
"""Сервер панели без iTerm2: страница, дерево сессий, лимиты и баланс. Работает до отмены."""
|
||||
global _server
|
||||
load_config()
|
||||
_server = await asyncio.start_server(handle, "127.0.0.1", PORT)
|
||||
asyncio.create_task(refresher(connection))
|
||||
asyncio.create_task(usage_poller())
|
||||
asyncio.create_task(balance_poller())
|
||||
if USE_PROFILE_THEME:
|
||||
asyncio.create_task(theme_poller(connection))
|
||||
tasks = [asyncio.create_task(c) for c in (refresher(), usage_poller(), balance_poller())]
|
||||
try:
|
||||
async with _server:
|
||||
await _server.serve_forever()
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
|
||||
|
||||
def port_alive(port, host="127.0.0.1", timeout=0.5) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def kickstart_service():
|
||||
"""Просит launchd запустить службу панели, если она не запущена."""
|
||||
try:
|
||||
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{SERVICE_LABEL}"],
|
||||
capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def register_toolbelt(connection):
|
||||
"""Скрипт AutoLaunch: регистрирует web view со страницей службы; свой сервер не поднимает."""
|
||||
load_config()
|
||||
if not port_alive(PORT):
|
||||
kickstart_service()
|
||||
for _ in range(20): # до ~5 с ждём, пока служба займёт порт, иначе web view покажет ошибку
|
||||
if port_alive(PORT):
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
await iterm2.tool.async_register_web_view_tool(
|
||||
connection,
|
||||
display_name="Claude",
|
||||
identifier="com.andrey.claude-agents",
|
||||
identifier=SERVICE_LABEL,
|
||||
reveal_if_already_registered=False,
|
||||
url=f"http://127.0.0.1:{PORT}/",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__" and iterm2 is not None:
|
||||
iterm2.run_forever(main)
|
||||
def run(argv):
|
||||
if "--headless" in argv:
|
||||
asyncio.run(serve())
|
||||
elif iterm2 is not None:
|
||||
iterm2.run_forever(register_toolbelt)
|
||||
else:
|
||||
raise SystemExit("нужен iTerm2 или флаг --headless")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run(sys.argv[1:])
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
"agent_idle_min": 15,
|
||||
"poll_sec": 1.5,
|
||||
"keep_logs_days": 2,
|
||||
"use_profile_theme": true,
|
||||
"port": 8766,
|
||||
"context_warn": 500000,
|
||||
"context_crit": 700000,
|
||||
@ -13,6 +12,5 @@
|
||||
"usage_refresh_sec": 120,
|
||||
"balance_refresh_sec": 600,
|
||||
"show_breakdown": true,
|
||||
"show_balance": true,
|
||||
"theme_variant": "auto"
|
||||
"show_balance": true
|
||||
}
|
||||
|
||||
42
files/launchagent.py
Normal file
42
files/launchagent.py
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Пишет описание службы launchd для сервера панели: запуск при входе, перезапуск при падении.
|
||||
|
||||
launchagent.py write <plist> <python> <claude_agents.py> <log> <PATH>
|
||||
"""
|
||||
import os
|
||||
import plistlib
|
||||
import sys
|
||||
|
||||
LABEL = "com.andrey.claude-agents" # то же, что SERVICE_LABEL в claude_agents.py
|
||||
|
||||
|
||||
def plist(python, script, log, path):
|
||||
return {
|
||||
"Label": LABEL,
|
||||
"ProgramArguments": [python, script, "--headless"],
|
||||
# у launchd PATH без Homebrew, а панели tmux, открытые службой (lf), получают её PATH
|
||||
"EnvironmentVariables": {"PATH": path},
|
||||
"RunAtLoad": True,
|
||||
"KeepAlive": True,
|
||||
"StandardOutPath": log,
|
||||
"StandardErrorPath": log,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 7 or sys.argv[1] != "write":
|
||||
print(__doc__.strip(), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
path, python, script, log = (os.path.expanduser(a) for a in sys.argv[2:6])
|
||||
env_path = sys.argv[6]
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(log), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
plistlib.dump(plist(python, script, log, env_path), f, fmt=plistlib.FMT_XML)
|
||||
os.replace(tmp, path)
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
79
install.sh
79
install.sh
@ -1,8 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов Claude Code,
|
||||
# лимиты подписки и баланс. Повторный запуск безопасен — обновляет установленное.
|
||||
# Установка панели «Claude»: дерево сессий и агентов Claude Code, лимиты подписки и баланс.
|
||||
# Сервер панели — служба launchd (работает и без iTerm2), iTerm2 показывает её в Toolbelt.
|
||||
# Повторный запуск безопасен — обновляет установленное.
|
||||
set -euo pipefail
|
||||
|
||||
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# из SSH-сессии launchd (gui/<uid>) и iTerm2 не настроить, а прежний сервер уже был бы остановлен
|
||||
[ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] \
|
||||
|| die "запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить. Если вы и так за Mac (например, в панели tmux, открытой раньше из SSH), запустите в обычной вкладке iTerm2 или после unset SSH_CONNECTION SSH_TTY"
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
FILES="$HERE/files"
|
||||
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
|
||||
@ -11,11 +21,12 @@ SETTINGS="$HOME/.claude/settings.json"
|
||||
CONFIG_DIR="$HOME/.config/claude-agents"
|
||||
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$"
|
||||
PORT=8766
|
||||
LABEL=com.andrey.claude-agents
|
||||
SERVICE_DIR="$HOME/.local/share/claude-agents"
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
SERVICE_LOG="$HOME/Library/Logs/claude-agents/panel.log"
|
||||
GUI="gui/$(id -u)"
|
||||
|
||||
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
backup() { mkdir -p "$BACKUP_DIR"; cp -p "$1" "$BACKUP_DIR/"; }
|
||||
|
||||
# ---------- проверки ----------
|
||||
@ -23,11 +34,12 @@ say "Проверяю окружение"
|
||||
[ "$(uname)" = "Darwin" ] || [ "${CLAUDE_PANEL_ANY_OS:-}" = 1 ] || die "нужен macOS с iTerm2"
|
||||
python3 -c 'import sys; assert sys.version_info >= (3, 8)' 2>/dev/null \
|
||||
|| die "нужен python3 ≥ 3.8 (xcode-select --install или brew install python)"
|
||||
ok "python3: $(python3 -V 2>&1)"
|
||||
PY="$(command -v python3)" # не sys.executable: версионный путь Homebrew исчезает после brew cleanup
|
||||
ok "python3: $(python3 -V 2>&1) ($PY)"
|
||||
if command -v jq >/dev/null; then ok "jq: $(command -v jq)"
|
||||
else warn "нет jq — хук без него молча ничего не пишет. Установите: brew install jq"; fi
|
||||
if [ -d "/Applications/iTerm.app" ] || [ -d "$HOME/Applications/iTerm.app" ]; then ok "iTerm2 найден"
|
||||
else warn "iTerm2 не найден в /Applications — панель будет работать только в нём"; fi
|
||||
else warn "iTerm2 не найден в /Applications — панель будет доступна только в браузере"; fi
|
||||
|
||||
# ---------- хук Claude Code ----------
|
||||
say "Хук Claude Code"
|
||||
@ -41,7 +53,7 @@ if [ "$added" = "—" ]; then ok "settings.json: хуки уже на месте
|
||||
else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi
|
||||
|
||||
# ---------- скрипт iTerm2 ----------
|
||||
say "Скрипт iTerm2"
|
||||
say "Скрипт iTerm2 (регистрация в Toolbelt)"
|
||||
mkdir -p "$AUTOLAUNCH"
|
||||
if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов
|
||||
backup "$AUTOLAUNCH/claude_usage.py" && rm "$AUTOLAUNCH/claude_usage.py"
|
||||
@ -57,6 +69,33 @@ install -m 644 "$FILES/config.example.json" "$CONFIG_DIR/config.example.json"
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then ok "ваш config.json не тронут"
|
||||
else ok "пример настроек: $CONFIG_DIR/config.example.json (скопируйте в config.json, чтобы менять)"; fi
|
||||
|
||||
# ---------- фоновая служба ----------
|
||||
say "Фоновая служба"
|
||||
mkdir -p "$SERVICE_DIR"
|
||||
install -m 644 "$FILES/claude_agents.py" "$SERVICE_DIR/claude_agents.py"
|
||||
# PATH из текущей оболочки: панели tmux, открытые службой (lf), должны находить то же, что и в терминале
|
||||
python3 "$FILES/launchagent.py" write "$PLIST" "$PY" "$SERVICE_DIR/claude_agents.py" "$SERVICE_LOG" "$PATH" >/dev/null
|
||||
ok "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" >/dev/null 2>&1 || true
|
||||
# прежняя копия панели (сервер внутри iTerm2) держит порт — останавливаем, чтобы его заняла служба
|
||||
if command -v lsof >/dev/null; then
|
||||
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; ok "остановлена прежняя версия панели"; fi
|
||||
fi
|
||||
# сразу после bootout bootstrap иногда падает с «Input/output error» — повторяем
|
||||
for attempt in 1 2 3 4 5; do
|
||||
launchctl bootstrap "$GUI" "$PLIST" && break
|
||||
[ "$attempt" = 5 ] && die "launchd не принял службу — смотрите launchctl print $GUI/$LABEL и $SERVICE_LOG"
|
||||
sleep 1
|
||||
done
|
||||
up=0
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/"; then up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ $up = 1 ]; then ok "служба запущена: http://127.0.0.1:$PORT/"
|
||||
else warn "служба не ответила — смотрите $SERVICE_LOG"; fi
|
||||
|
||||
# ---------- Python API в iTerm2 ----------
|
||||
say "iTerm2"
|
||||
iterm_running=0
|
||||
@ -74,28 +113,24 @@ else
|
||||
ok "Python API уже включён"
|
||||
fi
|
||||
|
||||
# старая копия панели держит порт — останавливаем, чтобы запустилась новая
|
||||
if command -v lsof >/dev/null; then
|
||||
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; sleep 1; ok "остановлена прежняя версия панели"; fi
|
||||
fi
|
||||
|
||||
if [ $iterm_running = 1 ] && [ "$api" = 1 ]; then
|
||||
pkill -f "Scripts/AutoLaunch/claude_agents.py" || true # прежний регистратор, иначе их станет по одному на запуск
|
||||
if osascript -e 'tell application "iTerm2" to launch API script named "claude_agents"' >/dev/null 2>&1; then
|
||||
ok "панель запущена"
|
||||
ok "панель зарегистрирована в Toolbelt"
|
||||
else
|
||||
warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py"
|
||||
warn "не смог зарегистрировать сам — Scripts → AutoLaunch → claude_agents.py"
|
||||
fi
|
||||
else
|
||||
warn "панель запустится сама при следующем запуске iTerm2"
|
||||
warn "в Toolbelt панель появится при следующем запуске iTerm2"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Готово. Осталось:
|
||||
1. В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
|
||||
2. Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
|
||||
Готово. Панель работает и без iTerm2: http://127.0.0.1:$PORT/
|
||||
(с iPad — через SSH-туннель на этот адрес, см. README).
|
||||
В iTerm2: View → Toolbelt → Show Toolbelt, затем Toolbelt → Claude.
|
||||
Перезапустите открытые сессии claude — хуки подхватываются при старте сессии.
|
||||
|
||||
Проверка, что всё работает: http://127.0.0.1:$PORT/raw
|
||||
Проверка: http://127.0.0.1:$PORT/raw · лог службы: $SERVICE_LOG
|
||||
Удаление: $HERE/uninstall.sh
|
||||
EOF
|
||||
|
||||
2
openspec/changes/headless-panel/.openspec.yaml
Normal file
2
openspec/changes/headless-panel/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-18
|
||||
214
openspec/changes/headless-panel/design.md
Normal file
214
openspec/changes/headless-panel/design.md
Normal file
@ -0,0 +1,214 @@
|
||||
# Design: панель без iTerm2 + фон на клиенте
|
||||
|
||||
Issue: #4. Ветка: `feat/4-headless-panel`.
|
||||
|
||||
## Намерение
|
||||
|
||||
Пользователь смотрит панель с iPad: Termius держит SSH local forward `8766 → 127.0.0.1:8766` на Mac,
|
||||
страница открыта в Safari рядом с Termius (Split View). Все сессии Claude Code — в обычном tmux,
|
||||
который работает сам по себе. Сейчас страница пропадает, если на Mac не запущен iTerm2, потому что
|
||||
сервер поднимает скрипт AutoLaunch iTerm2. Переключение сессий с iPad уже работает — через tmux.
|
||||
Нужно: страница доступна всегда, пока Mac включён и пользователь вошёл; фон меняется прямо на
|
||||
странице, потому что тема по умолчанию в Safari раздражает.
|
||||
|
||||
Успех: iTerm2 закрыт → в Safari на iPad видно дерево сессий, лимиты и баланс; клики переключают
|
||||
tmux; фон выставлен под тему Termius и держится между перезагрузками.
|
||||
|
||||
## Цели
|
||||
- Фоновый сервер панели, независимый от iTerm2, автозапуск при входе, перезапуск при падении.
|
||||
- Один владелец порта 8766; iTerm2-скрипт только регистрирует web view в Toolbelt.
|
||||
- Шестерёнка ⚙ внизу панели (вариант A из мокапов): образцы фона, «свой цвет», «сброс»;
|
||||
выбор хранится на устройстве; текст подстраивается под яркость фона.
|
||||
- `install.sh`/`uninstall.sh` управляют службой; обновление старой установки без ручных шагов.
|
||||
|
||||
## Не цели
|
||||
- Досыл названий вкладок iTerm2 и цветов профиля в фоновый сервер (решено пользователем:
|
||||
в tmux имена и так из tmux; цвет — шестерёнкой, в Toolbelt тоже).
|
||||
- Доступ к странице из сети напрямую (только через туннель).
|
||||
- Мод Claude Code / TUI-версия панели (обсуждались, отложены).
|
||||
- Запуск службы без входа пользователя в систему на самом Mac (см. «Открытые вопросы»).
|
||||
|
||||
## Анти-цели (провал, даже если «работает»)
|
||||
- Два сервера одновременно или удвоенный опрос лимитов (эндпоинт отвечает 429).
|
||||
- Порт открыт наружу: в панели видны тексты сообщений сессий.
|
||||
- Мелькание темы по умолчанию при каждой автоперезагрузке страницы (раз в минуту) после выбора фона.
|
||||
- Установщик ломает чужие хуки/настройки или требует ручных шагов при обновлении.
|
||||
|
||||
## Ограничения
|
||||
- Python 3.8+, только стандартная библиотека; модуль `iterm2` — только в режиме регистратора.
|
||||
- macOS, `launchctl`; LaunchAgent в сессии пользователя (Aqua).
|
||||
- В LaunchAgent минимальный `PATH`: путь к `python3` фиксируется установщиком абсолютным; поиск
|
||||
`tmux`/`ps` уже идёт через `EXTRA_PATH` в коде.
|
||||
- Токен лимитов — из Keychain через `security`; служба должна работать в GUI-сессии пользователя,
|
||||
где Keychain разблокирован.
|
||||
- Сервер слушает только `127.0.0.1`.
|
||||
|
||||
## Решения, принятые с пользователем
|
||||
- Вариант 0 (SSH-туннель + Safari) — основной сценарий, мод/TUI не делаем.
|
||||
- Фоновая служба через LaunchAgent; один сервер на порту; iTerm2-скрипт — регистратор.
|
||||
- Цвет профиля iTerm2 не досылать и не читать (вариант 3): в Toolbelt фон тоже ставится шестерёнкой.
|
||||
- Место настройки фона — шестерёнка в нижней строке (вариант A).
|
||||
- Перезагрузка Mac без GUI-входа — не решаем сейчас, запоминаем на будущее.
|
||||
- `install.sh` / `uninstall.sh` запускаются только за самим Mac (не по SSH). При запуске из SSH-сессии
|
||||
(`SSH_CONNECTION`/`SSH_TTY` заданы) установщик предупреждает и завершается, ничего не меняя;
|
||||
в README — строка об этом.
|
||||
|
||||
## Как устроено (то, что решено)
|
||||
|
||||
### Режимы `claude_agents.py`
|
||||
Один исходный файл, два режима:
|
||||
- **Фоновый сервер** (`--headless`, запускает launchd системным `python3`): `asyncio.start_server` на
|
||||
`127.0.0.1:PORT`, `refresher(None)`, `usage_poller`, `balance_poller`; без `iterm2`, без
|
||||
`theme_poller`, без регистрации web view. `live=None` → `build_tree` уже судит о сессиях по PID и
|
||||
tmux. Блок `__main__` запускает этот режим при флаге независимо от наличия `iterm2`.
|
||||
- **Регистратор** (запуск iTerm2 из AutoLaunch, без флага): проверяет, отвечает ли порт; если нет —
|
||||
`launchctl kickstart gui/<uid>/<label>`; затем `async_register_web_view_tool` на
|
||||
`http://127.0.0.1:PORT/` и остаётся жить (`run_forever`), ничего больше не делая.
|
||||
- `theme_poller`, `live_sessions`, использование `_app` в `activate`/`open_worktree`,
|
||||
`USE_PROFILE_THEME`, `THEME_VARIANT` и их ключи в `CONFIG_KEYS` удаляются. Старые ключи в чужом
|
||||
`config.json` просто игнорируются (`load_config` берёт только известные ключи).
|
||||
- `activate`/`open_worktree` для целей не из tmux становятся no-op.
|
||||
|
||||
### LaunchAgent
|
||||
- Метка `com.andrey.claude-agents` (совпадает с идентификатором web view).
|
||||
- `ProgramArguments`: абсолютный путь к `python3`, путь к установленному `claude_agents.py`, `--headless`.
|
||||
- `RunAtLoad=true`, `KeepAlive=true`, `ThrottleInterval` по умолчанию.
|
||||
- stdout/stderr — в `~/Library/Logs/claude-agents/panel.log`.
|
||||
|
||||
### install.sh / uninstall.sh
|
||||
- install: как раньше хук и `settings.json`; копирует `claude_agents.py` в AutoLaunch (регистратор);
|
||||
пишет plist в `~/Library/LaunchAgents/`; останавливает всё, что слушает порт (прежний сервер
|
||||
внутри iTerm2); `launchctl bootout` (если была) + `bootstrap gui/<uid>` + `kickstart -k`; ждёт,
|
||||
пока порт ответит, и сообщает результат; если iTerm2 запущен с включённым API — перезапускает
|
||||
скрипт AutoLaunch (как сейчас через `osascript`).
|
||||
- uninstall: `launchctl bootout`, удаляет plist, остальное как сейчас; `--purge` удаляет и лог службы.
|
||||
|
||||
### Фон на клиенте
|
||||
- В `render_page`: в `<head>` до отрисовки маленький скрипт читает выбор из `localStorage`
|
||||
(в `try/catch`) и выставляет CSS-переменные `--bg`, `--fg`, `--dim` на `:root` — без мелькания.
|
||||
- Производные цвета: по относительной яркости фона выбирается светлый или тёмный текст;
|
||||
`--dim` — смесь текста с фоном (как `_mix` на сервере, коэффициент 0.6). Линии дерева и
|
||||
разделители уже считаются от `--fg` через `color-mix`, отдельно не трогаются.
|
||||
- ⚙ — в строке `.foot` блока лимитов. Блок лимитов перерисовывается сервером раз в 15 с через
|
||||
`/limits`, поэтому ⚙ и строка образцов живут вне перерисовываемого HTML (отдельный элемент
|
||||
рядом с `#limits` или восстанавливаются после вставки), чтобы раскрытая строка не схлопывалась.
|
||||
- Образцы: текущий по умолчанию (`#002b36`), нейтральный тёмный (`#1e1e1e`), чёрный (`#000000`),
|
||||
тёмно-фиолетово-серый (`#2b2b3a`), светлый (`#fdf6e3`). «Свой цвет» — `<input type="color">`
|
||||
(на iPad открывает системную палитру). «Сброс» удаляет ключ и возвращает тему сервера.
|
||||
- Тема сервера по умолчанию — `DEFAULT_THEME`, выровненная по нынешнему профилю iTerm2 `Chat`
|
||||
(снято с работающей панели 2026-10-18): `bg #002b36`, `fg #aacace`, `dim #668a91`,
|
||||
`green #859900`, `yellow #b58900`, `red #dc322f`, шрифт `'MesloLGLNF', 'MesloLGLNF-Regular',
|
||||
ui-monospace, monospace`, размер 13. Значения зашиты в код, профиль не читается.
|
||||
- Если web view Toolbelt не сохраняет `localStorage` между перезапусками iTerm2 — принимаем это:
|
||||
после перезапуска в Toolbelt тема по умолчанию (она и есть вид профиля `Chat`). Серверного
|
||||
хранения выбора нет. Проверить поведение в начале реализации и записать результат сюда.
|
||||
|
||||
## Оставлено исполнителю
|
||||
- Точное расположение установленного `claude_agents.py` для службы (та же копия в AutoLaunch или
|
||||
отдельная) — при условии одного исходного файла в репозитории.
|
||||
- Размер и вид ⚙ и образцов в пределах требований спеки (пальцем на iPad, ничего лишнего в
|
||||
свёрнутом виде).
|
||||
- Тесты: pytest по чистым функциям (`settings_hooks`, генерация plist, расчёт производных цветов,
|
||||
если он на сервере) и ручная проверка сценариев спеки, которые завязаны на launchd/iPad.
|
||||
|
||||
## Риски
|
||||
- Сохраняется ли `localStorage` в web view Toolbelt iTerm2 между перезапусками iTerm2 — не проверено.
|
||||
Решено с пользователем: если нет, фон в Toolbelt сбрасывается к теме по умолчанию (вид профиля `Chat`).
|
||||
- Доступ `security` к элементу Keychain `Claude Code-credentials` из процесса launchd может
|
||||
потребовать подтверждения пользователя один раз.
|
||||
- `/usr/bin/python3` без установленных Command Line Tools — заглушка; установщик уже проверяет, что
|
||||
`python3` реально работает, и фиксирует его путь.
|
||||
|
||||
## Открытые вопросы
|
||||
- После перезагрузки Mac без входа на нём (только SSH с iPad) LaunchAgent не стартует, а Keychain
|
||||
заблокирован. Пользователь такого не делал, но не исключает; решение отложено.
|
||||
|
||||
## Decisions made without discussion
|
||||
- 2026-10-18 — Сервер остаётся только на `127.0.0.1`. Обоснование: в панели тексты сообщений
|
||||
сессий; с iPad и так заходят через туннель. Цена ошибки: если понадобится LAN — добавить ключ
|
||||
`bind` в конфиг.
|
||||
- 2026-10-18 — Возможности iTerm2 для сессий вне tmux (имена вкладок, переход во вкладку, `lf` в
|
||||
split iTerm2, `tmux -CC`) убираются целиком, а не только в фоновом режиме. Обоснование: следствие
|
||||
«один сервер на порту» + «не досылать данные из iTerm2»; пользователь держит всё в tmux. Цена
|
||||
ошибки: для сессий вне tmux теряется переход по клику; вернуть можно досылом из регистратора.
|
||||
- 2026-10-18 — Подстраивается только фон, а производные (текст, приглушённый, линии) считаются
|
||||
автоматически; цвета статусов не меняются. Обоснование: пользователь говорил о фоне. Цена
|
||||
ошибки: добавить выбор цвета текста.
|
||||
- 2026-10-18 — Регистратор при неотвечающем порте делает `launchctl kickstart`, а не поднимает свой
|
||||
сервер. Обоснование: один код сервера, нет гонки за порт. Цена ошибки: если launchd-службы нет
|
||||
(не переустановлено), в Toolbelt пустая страница до `install.sh`.
|
||||
- 2026-10-18 — Метка LaunchAgent `com.andrey.claude-agents`, лог в `~/Library/Logs/claude-agents/`.
|
||||
Цена ошибки: переименование при следующей установке.
|
||||
- 2026-10-18 (план) — Тесты на `unittest` из stdlib, не pytest. Обоснование: pytest не установлен,
|
||||
`pip install` запрещён хуком на main и противоречит «только stdlib»; `unittest`-тесты pytest тоже
|
||||
запускает. Цена ошибки: переписать на pytest-стиль при желании.
|
||||
- 2026-10-18 (план) — Копия скрипта для службы — `~/.local/share/claude-agents/claude_agents.py`,
|
||||
отдельно от копии в AutoLaunch. Обоснование: служба не зависит от каталога iTerm2. Цена ошибки:
|
||||
два места при ручном обновлении (`install.sh` обновляет оба).
|
||||
- 2026-10-18 (план) — Текст — `#3b4a50` (светлый фон) или `#aacace` (как в профиле, тёмный фон);
|
||||
выбирается тот, у которого выше коэффициент контраста WCAG `(Lmax+0.05)/(Lmin+0.05)` с фоном
|
||||
(было: порог яркости > 0.4, на средних тонах давал ~1.5:1 — находка I-1). Цена ошибки:
|
||||
на отдельных средних фонах контраст всё равно не дотянет до 4.5:1; подобрать другие цвета текста.
|
||||
- 2026-10-18 (план) — Область нажатия ⚙, образцов и кнопок — не меньше 36×36 CSS px. Цена ошибки:
|
||||
поправить размер после приёмки на iPad.
|
||||
- 2026-10-18 (план) — В этой ветке заодно закрываются O-1 (устаревший docstring) и O-3
|
||||
(`__pycache__/` в `.gitignore`) из issue #3: оба файла и так меняются. Цена ошибки: нет.
|
||||
- 2026-10-18 (план) — Приёмка (Task 6) выполняется человеком, не сабагентами: она меняет launchd,
|
||||
`~/.claude/settings.json` и настройки iTerm2 на рабочем Mac.
|
||||
- 2026-10-18 (исполнение) — R-1 (design #12): `test_old_theme_keys_ignored` и
|
||||
`test_open_worktree_non_tmux_is_noop` прошли до реализации Task 1 (старые ключи и раньше не давали
|
||||
ошибки, `_app` был `None`); оставлены как тесты-фиксаторы поведения после удаления. Цена ошибки:
|
||||
нет — остальные тесты Task 1 падали корректно.
|
||||
- 2026-10-18 (исполнение) — R-2 (design #13): `build_tree` оставлен с параметром `live` (вызывается с
|
||||
`None`) и мёртвой веткой `tmux_twins`, потому что план требовал не менять `build_tree`. Цена ошибки:
|
||||
мёртвый код; уборка отложена (M-9).
|
||||
- 2026-10-18 (исполнение) — R-3 (design #14): RED для `test_install` показан временной порчей текста
|
||||
сообщения охраны, а не запуском скриптов без охраны (это задело бы настоящий launchd и порт 8766).
|
||||
Цена ошибки: тест защищён только проверяемой охраной — закрыто заглушками команд в PATH (M-7).
|
||||
- 2026-10-18 (финальное ревью) — R-4 (design #15): из находок финального ревью исправлены в ветке
|
||||
I-1…I-4, M-1, M-2, M-3, M-5, M-7, M-8, M-12; отложены в follow-up M-4 (порт из `config.json` в
|
||||
`install.sh`/`uninstall.sh`), M-6 (ротация `panel.log`), M-9 (мёртвый код `build_tree`), M-11
|
||||
(область нажатия 44 pt — оценить на приёмке, Task 6 Step 6). I-3 (нет проверки Host/Sec-Fetch-Site)
|
||||
существовал до ветки, но исправлен сразу: служба теперь работает постоянно и открывается из
|
||||
браузера. Цена ошибки: отложенные пункты — мелкие неудобства, не потеря данных.
|
||||
- 2026-10-18 (финальное ревью) — R-5 (design #16): запрос без заголовка `Host` или с `Host` без порта
|
||||
получает 403 (сравнение строго с `127.0.0.1:<порт>` / `localhost:<порт>`, порт берётся из сокета).
|
||||
Обоснование: браузеры, web view iTerm2 и туннель Termius всегда шлют `Host` с портом. Цена ошибки:
|
||||
клиент без `Host` (голый HTTP/1.0) не откроет панель — ослабить проверку.
|
||||
- 2026-10-18 (финальное ревью) — R-6 (design #17): тесты к исправлениям написаны без отдельного шага
|
||||
RED; вместо этого новые тесты прогнаны на коде до исправлений (2e8ab31) — все 6 новых тестов
|
||||
(I-1, I-3 ×2, I-4, M-2, M-12) там падают. Цена ошибки: нет.
|
||||
- 2026-10-18 (приёмка) — R-7 (design #18): служба получает `PATH` той оболочки, из которой запущен
|
||||
`install.sh` (`EnvironmentVariables.PATH` в plist). Обоснование: tmux отдаёт новой панели `PATH`
|
||||
вызвавшего `split-window`, а у launchd `PATH=/usr/bin:/bin:/usr/sbin:/sbin` — `exec lf` не находился,
|
||||
панель «⎇ папка» мигала и закрывалась (H-1). Так панели, открытые службой, видят то же, что и
|
||||
терминал (редактор, превьюеры lf). Цена ошибки: если `install.sh` запущен из урезанного окружения,
|
||||
`lf` снова не найдётся — README подсказывает перезапустить установщик из обычного терминала.
|
||||
|
||||
## Результаты приёмки (Task 6, 2026-10-18)
|
||||
Проверял человек с iPad (Termius, SSH + tmux) и контроллер из сессии Claude Code на том же Mac.
|
||||
- Step 1 — ок: служба `com.andrey.claude-agents` запущена launchd (родитель PID 1, python из
|
||||
`/opt/homebrew/bin/python3`), один слушатель `127.0.0.1:8766`; лимиты и баланс загрузились,
|
||||
запроса доступа к Keychain не было. Чужой `Host` → 403.
|
||||
- Step 2 — ок: iTerm2 закрыт (⌘Q через `osascript`), с iPad через туннель Termius видны дерево,
|
||||
лимиты и баланс; tmux-сервер отвязан от iTerm2 (PPID 1) и пережил закрытие.
|
||||
- Step 3 — «↗ вкладка» ок; «⎇ папка» сначала мигала разделителем и закрывалась (H-1, причина —
|
||||
`PATH` службы), после c810f5a и переустановки — ок.
|
||||
- Step 4 — ок: после `kill` служба снова отвечает 200 примерно через 2 с.
|
||||
- Step 5 — ок: после `open -a iTerm` поднялся один процесс регистрации, порт держит служба;
|
||||
Toolbelt → Claude показывает страницу службы (проверено человеком за Mac).
|
||||
- Step 6 — ок: светлый образец, автоперезагрузка не закрывает строку образцов (I-4), «сброс»,
|
||||
«свой цвет» открывает палитру iPad. Размер областей нажатия (M-11) жалоб не вызвал.
|
||||
- Step 7 — ок: выбранный в Toolbelt фон сохраняется после перезапуска iTerm2 (`localStorage` web
|
||||
view переживает перезапуск).
|
||||
- Попутно: при проверке человек случайно завершил процесс iTerm2, вместе с которым погас tmux-сервер
|
||||
(его запускает интеграция `tmux -CC`). Служба этого не заметила — тот же PID, страница отвечает 200.
|
||||
- Step 8 — ок, три повторных запуска: одна служба, `bootstrap` без «Bootstrap failed» (M-5 не
|
||||
проявился), один процесс регистрации (M-1), 9 хуков в `settings.json` без дублей.
|
||||
- Step 9 — ок с `SSH_CONNECTION=test`: отказ «за самим Mac» до каких-либо действий. Наблюдение:
|
||||
в окне tmux, открытом из Termius, `SSH_CONNECTION` пуст, и охрана не срабатывает (подтверждает
|
||||
M-3); установка из такого окна проходит нормально, т. к. tmux-сервер живёт в GUI-сессии.
|
||||
- Step 10 — не выполнялся (по желанию).
|
||||
- Наблюдение O-3: во время приёмки `./install.sh` один раз запустили из основного checkout (main,
|
||||
старая версия без охраны) — он перезаписал скрипт AutoLaunch старым сервером; launchd сразу
|
||||
поднял службу. Исправлено повторным `install.sh` из ветки; после merge неактуально.
|
||||
44
openspec/changes/headless-panel/proposal.md
Normal file
44
openspec/changes/headless-panel/proposal.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Панель «Claude» сейчас живёт только внутри iTerm2: её HTTP-сервер на `127.0.0.1:8766` поднимает
|
||||
скрипт AutoLaunch, и когда iTerm2 закрыт, страницы нет. Пользователь смотрит панель с iPad —
|
||||
Termius держит SSH-туннель к `127.0.0.1:8766` на Mac, страница открыта в Safari, а сами сессии
|
||||
Claude Code крутятся в tmux, которому iTerm2 не нужен. Панель должна быть доступна, пока Mac
|
||||
включён и пользователь вошёл в систему, независимо от iTerm2. Заодно тема по умолчанию
|
||||
(Solarized dark) в Safari на iPad «мозолит глаза», и её нужно менять прямо на странице.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новый фоновый режим панели (`--headless`): HTTP-сервер, дерево сессий, лимиты и баланс без
|
||||
iTerm2. Запускается LaunchAgent'ом при входе в систему, перезапускается при падении.
|
||||
- Порт 8766 всегда занимает только фоновый сервер. Скрипт AutoLaunch в iTerm2 больше не поднимает
|
||||
сервер, а только регистрирует web view «Claude» в Toolbelt на тот же адрес.
|
||||
- **BREAKING**: подстройка цветов и шрифта под профиль iTerm2 убрана; ключи `use_profile_theme`
|
||||
и `theme_variant` в `config.json` больше ничего не делают.
|
||||
- **BREAKING**: возможности, которым нужна живая связь с iTerm2, уходят: названия вкладок iTerm2 для
|
||||
сессий вне tmux, переход во вкладку iTerm2 для сессий вне tmux, переключение панелей `tmux -CC`,
|
||||
открытие `lf` в разделённой панели iTerm2 для сессий вне tmux. Сессии в обычном tmux работают
|
||||
как раньше (имена, переход, `lf` — через tmux).
|
||||
- Новая настройка фона на клиенте: шестерёнка ⚙ в нижней строке панели раскрывает образцы фона,
|
||||
«свой цвет» и «сброс». Выбор хранится на устройстве (браузере/web view), текст подстраивается.
|
||||
- `install.sh` ставит и запускает LaunchAgent, `uninstall.sh` его останавливает и удаляет.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `panel-service`: где и как работает сервер панели — фоновая служба без iTerm2, единственный
|
||||
владелец порта, регистрация в Toolbelt, установка/удаление.
|
||||
- `panel-appearance`: как пользователь меняет фон панели на своём устройстве.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- openspec/specs/ пуст — изменяемых спек нет -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `files/claude_agents.py`: точка входа с двумя режимами (фоновый сервер / регистратор Toolbelt),
|
||||
удаление `theme_poller` и чтения профиля, клиентский JS/CSS для фона в `render_page`.
|
||||
- `install.sh`, `uninstall.sh`: LaunchAgent (`~/Library/LaunchAgents/`), лог службы.
|
||||
- `files/config.example.json`, README.md: убрать ключи темы, описать фоновую службу и шестерёнку.
|
||||
- Внешние зависимости не добавляются (только стандартная библиотека Python, `launchctl`).
|
||||
@ -0,0 +1,65 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Позволяет пользователю выбрать фон панели прямо на странице, отдельно на каждом устройстве
|
||||
(Safari на iPad, Toolbelt на Mac), чтобы панель не резала глаза рядом с его терминалом.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Шестерёнка настройки фона
|
||||
Нижняя строка панели (рядом с «обновлено ЧЧ:ММ:СС») SHALL содержать значок ⚙. Тап по нему SHALL
|
||||
раскрывать строку с образцами фона, кнопкой «свой цвет» и кнопкой «сброс»; повторный тап SHALL её
|
||||
сворачивать. В свёрнутом виде, кроме значка, ничего нового на странице не видно.
|
||||
|
||||
#### Scenario: Раскрыть и свернуть
|
||||
- **WHEN** пользователь нажимает ⚙
|
||||
- **THEN** появляется строка образцов; при повторном нажатии она скрывается
|
||||
|
||||
#### Scenario: Попадание пальцем
|
||||
- **WHEN** страница открыта в Safari на iPad
|
||||
- **THEN** ⚙, образцы и кнопки нажимаются пальцем без промахов (не мельче привычных элементов
|
||||
управления iPadOS)
|
||||
|
||||
### Requirement: Выбор фона
|
||||
Выбор образца или своего цвета SHALL сразу менять фон всей панели, включая окно сообщений. Цвет
|
||||
основного текста, приглушённого текста, линий дерева и разделителей SHALL подстраиваться под
|
||||
яркость фона, чтобы оставаться читаемым. Цвета статусов и лимитов (зелёный, жёлтый, красный)
|
||||
SHALL оставаться прежними.
|
||||
|
||||
#### Scenario: Светлый фон
|
||||
- **WHEN** пользователь выбирает светлый образец
|
||||
- **THEN** фон становится светлым, а текст — тёмным и читаемым
|
||||
|
||||
#### Scenario: Свой цвет
|
||||
- **WHEN** пользователь нажимает «свой цвет» и выбирает цвет в системной палитре
|
||||
- **THEN** фон панели становится этим цветом, текст подстраивается
|
||||
|
||||
### Requirement: Выбор хранится на устройстве
|
||||
Выбранный фон SHALL сохраняться средствами того браузера или web view, где он выбран, и SHALL
|
||||
применяться при каждом открытии и автоматической перезагрузке страницы без мелькания темы по
|
||||
умолчанию. Выбор на одном устройстве SHALL NOT влиять на другие. Если клиент не сохраняет данные
|
||||
страницы между своими перезапусками (возможно, web view Toolbelt), после перезапуска клиента
|
||||
показывается тема по умолчанию; на сервере выбор не хранится.
|
||||
|
||||
#### Scenario: Клиент без постоянного хранилища
|
||||
- **WHEN** выбор фона сделан в Toolbelt, а web view Toolbelt не сохраняет данные между
|
||||
перезапусками iTerm2, и iTerm2 перезапущен
|
||||
- **THEN** панель в Toolbelt показывает тему по умолчанию
|
||||
|
||||
#### Scenario: Перезагрузка страницы
|
||||
- **WHEN** пользователь выбрал фон в Safari на iPad и страница перезагрузилась (сама раз в минуту
|
||||
или вручную)
|
||||
- **THEN** сразу отображается выбранный фон
|
||||
|
||||
#### Scenario: Разные устройства
|
||||
- **WHEN** в Safari на iPad выбран светлый фон
|
||||
- **THEN** панель в Toolbelt на Mac сохраняет свой фон
|
||||
|
||||
### Requirement: Сброс к теме по умолчанию
|
||||
«Сброс» SHALL удалять сохранённый выбор и возвращать тему, которую отдаёт сервер. Тема по умолчанию
|
||||
SHALL выглядеть как нынешний профиль iTerm2 пользователя (`Chat`: фон `#002b36`, текст `#aacace`).
|
||||
|
||||
#### Scenario: Сброс
|
||||
- **WHEN** пользователь нажимает «сброс»
|
||||
- **THEN** панель показывает тему по умолчанию, и после перезагрузки она остаётся по умолчанию
|
||||
111
openspec/changes/headless-panel/specs/panel-service/spec.md
Normal file
111
openspec/changes/headless-panel/specs/panel-service/spec.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Определяет, где и как работает сервер панели «Claude»: фоновая служба пользователя, не зависящая
|
||||
от iTerm2, единственный владелец порта панели, тонкая регистрация в Toolbelt и установка/удаление.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Сервер панели работает без iTerm2
|
||||
Сервер панели SHALL отдавать страницу, дерево сессий, лимиты и баланс на `127.0.0.1:<port>`
|
||||
(по умолчанию 8766), пока пользователь вошёл в систему, независимо от того, запущен ли iTerm2.
|
||||
|
||||
#### Scenario: iTerm2 не запущен
|
||||
- **WHEN** пользователь вошёл в систему, iTerm2 не запущен, а сессия Claude Code работает в tmux
|
||||
- **THEN** `http://127.0.0.1:8766/` отвечает 200 и показывает эту сессию с её субагентами, моделью,
|
||||
контекстом и worktree, а нижний блок показывает лимиты и баланс
|
||||
|
||||
#### Scenario: Имена сессий в tmux
|
||||
- **WHEN** сессия Claude Code работает в панели tmux
|
||||
- **THEN** её имя берётся из заголовка панели tmux или из `сессия:окно имя_окна`, как и раньше
|
||||
|
||||
### Requirement: Служба стартует при входе и перезапускается при падении
|
||||
Сервер панели SHALL запускаться автоматически при входе пользователя в систему и SHALL
|
||||
перезапускаться, если его процесс завершился.
|
||||
|
||||
#### Scenario: Вход в систему
|
||||
- **WHEN** пользователь входит в систему на Mac после перезагрузки
|
||||
- **THEN** сервер панели запускается без открытия iTerm2 и каких-либо действий пользователя
|
||||
|
||||
#### Scenario: Падение процесса
|
||||
- **WHEN** процесс сервера панели завершился (ошибка, `kill`)
|
||||
- **THEN** он снова запущен и отвечает на порту в пределах нескольких десятков секунд
|
||||
|
||||
### Requirement: Порт панели занимает один процесс
|
||||
На порту панели SHALL работать ровно один сервер; лимиты и баланс SHALL опрашиваться одним
|
||||
процессом, чтобы не удваивать запросы к API.
|
||||
|
||||
#### Scenario: iTerm2 запускается при работающей службе
|
||||
- **WHEN** служба уже отвечает на порту и пользователь запускает iTerm2
|
||||
- **THEN** второй сервер не запускается, в API лимитов идёт один поток запросов, а в Toolbelt
|
||||
появляется панель «Claude», показывающая ту же страницу
|
||||
|
||||
### Requirement: Toolbelt показывает панель фоновой службы
|
||||
Скрипт панели в iTerm2 SHALL регистрировать web view «Claude» в Toolbelt, указывающий на адрес
|
||||
фоновой службы, и SHALL NOT поднимать собственный сервер.
|
||||
|
||||
#### Scenario: Toolbelt при запущенной службе
|
||||
- **WHEN** iTerm2 запущен и пользователь выбирает Toolbelt → Claude
|
||||
- **THEN** панель показывает ту же страницу, что и `http://127.0.0.1:8766/` в браузере
|
||||
|
||||
#### Scenario: Служба не отвечает при старте iTerm2
|
||||
- **WHEN** iTerm2 стартует, а служба на порту не отвечает
|
||||
- **THEN** скрипт просит launchd запустить службу и всё равно регистрирует web view
|
||||
|
||||
### Requirement: Сервер доступен только локально
|
||||
Сервер панели SHALL слушать только `127.0.0.1`; доступ с других устройств возможен только через
|
||||
туннель (например, SSH local forward).
|
||||
|
||||
#### Scenario: Запрос из локальной сети
|
||||
- **WHEN** устройство в той же сети обращается к Mac по его LAN-адресу на порт панели
|
||||
- **THEN** соединение не устанавливается
|
||||
|
||||
### Requirement: Действия по клику работают через tmux
|
||||
Переход к сессии и открытие worktree по клику SHALL выполняться средствами tmux для сессий в tmux,
|
||||
независимо от iTerm2 и от того, на каком устройстве открыта страница.
|
||||
|
||||
#### Scenario: Переход к сессии с iPad
|
||||
- **WHEN** пользователь с iPad (страница через туннель) нажимает «↗ вкладка» у сессии в tmux
|
||||
- **THEN** клиент tmux переключается на окно и панель этой сессии
|
||||
|
||||
#### Scenario: Открытие worktree с iPad
|
||||
- **WHEN** пользователь нажимает `⎇ папка` у сессии в tmux
|
||||
- **THEN** в том же окне tmux открывается новая панель с файловым менеджером в этой папке
|
||||
|
||||
### Requirement: Установка и удаление службы
|
||||
`install.sh` SHALL устанавливать, (пере)запускать службу и останавливать прежний сервер, державший
|
||||
порт; повторный запуск SHALL быть безопасным. `uninstall.sh` SHALL останавливать и удалять службу.
|
||||
|
||||
#### Scenario: Обновление старой установки
|
||||
- **WHEN** у пользователя стоит прежняя версия (сервер внутри iTerm2) и он запускает `install.sh`
|
||||
- **THEN** прежний сервер остановлен, служба установлена и отвечает на порту, а iTerm2-скрипт
|
||||
обновлён до регистратора
|
||||
|
||||
#### Scenario: Повторная установка
|
||||
- **WHEN** `install.sh` запускается второй раз подряд
|
||||
- **THEN** служба одна, отвечает на порту, записи хуков в `settings.json` не задублированы
|
||||
|
||||
#### Scenario: Запуск по SSH
|
||||
- **WHEN** `install.sh` или `uninstall.sh` запущен из SSH-сессии
|
||||
- **THEN** скрипт сообщает, что его нужно запускать за самим Mac, и завершается с ненулевым кодом,
|
||||
ничего не изменив: прежний сервер продолжает работать
|
||||
|
||||
#### Scenario: Удаление
|
||||
- **WHEN** пользователь запускает `uninstall.sh`
|
||||
- **THEN** служба остановлена, её описание удалено, порт свободен и после повторного входа в систему
|
||||
служба не запускается
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Тема панели из профиля iTerm2
|
||||
**Reason**: Сервер больше не связан с iTerm2; фон задаётся на клиенте (см. `panel-appearance`).
|
||||
**Migration**: Выставить фон шестерёнкой ⚙ внизу панели; ключи `use_profile_theme` и
|
||||
`theme_variant` в `config.json` удалить (они игнорируются).
|
||||
|
||||
### Requirement: Возможности iTerm2 для сессий вне tmux
|
||||
**Reason**: Нужна живая связь сервера с iTerm2, а сервер теперь фоновый; пользователь держит все
|
||||
сессии в tmux.
|
||||
**Migration**: Запускать `claude` внутри tmux. Для сессий вне tmux имя берётся из заголовка сессии
|
||||
Claude Code или папки, переход во вкладку и `lf` в разделённой панели iTerm2 недоступны; режим
|
||||
`tmux -CC` не поддерживается для перехода.
|
||||
81
tests/test_appearance.py
Normal file
81
tests/test_appearance.py
Normal file
@ -0,0 +1,81 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
|
||||
import claude_agents as ca # noqa: E402
|
||||
|
||||
|
||||
class Page(unittest.TestCase):
|
||||
def test_page_applies_saved_bg_before_body(self):
|
||||
html = ca.render_page()
|
||||
body = html.index("<body>")
|
||||
self.assertLess(html.index("claude-panel-bg"), body)
|
||||
self.assertIn(ca.CLIENT_COLORS_JS, html)
|
||||
self.assertIn("try {", html[:body])
|
||||
|
||||
def test_page_has_bg_controls(self):
|
||||
html = ca.render_page()
|
||||
for marker in ('id="bgctl"', 'id="gear"', 'id="bgrow"', 'type="color"', 'id="bg-reset"'):
|
||||
self.assertIn(marker, html)
|
||||
self.assertEqual(ca.BG_PRESETS, ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3"))
|
||||
for hex_ in ca.BG_PRESETS:
|
||||
self.assertIn(f'data-bg="{hex_}"', html)
|
||||
limits_end = html.index('<div id="limits">') + len('<div id="limits">') + len(ca.render_limits())
|
||||
self.assertGreater(html.index('id="bgctl"'), limits_end)
|
||||
|
||||
|
||||
def test_reload_skipped_while_colour_row_open(self):
|
||||
html = ca.render_page()
|
||||
self.assertIn("if (!cur && !bgrow.classList.contains('open')) location.reload()", html)
|
||||
self.assertLess(html.index("const gear = document.getElementById('gear'), bgrow"),
|
||||
html.index("location.reload()"))
|
||||
|
||||
def test_limits_refresh_reads_body_before_moving_gear(self):
|
||||
html = ca.render_page()
|
||||
start = html.index("async function refreshLimits")
|
||||
body = html[start:html.index("setInterval(refreshLimits")]
|
||||
self.assertLess(body.index("await r.text()"), body.index("appendChild(gear)"))
|
||||
|
||||
|
||||
class Limits(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(ca.state)
|
||||
|
||||
def tearDown(self):
|
||||
ca.state.clear()
|
||||
ca.state.update(self.saved)
|
||||
|
||||
def test_limits_foot_has_gear_slot(self):
|
||||
ca.state["usage_updated"], ca.state["usage_error"] = time.time(), None
|
||||
self.assertIn('class="gear-slot"', ca.render_limits())
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "нет node")
|
||||
class ClientColors(unittest.TestCase):
|
||||
def test_panel_colors_js(self):
|
||||
js = ca.CLIENT_COLORS_JS + (
|
||||
"\nconsole.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000'),"
|
||||
" panelColors('nope'), panelColors('#002b36')]))")
|
||||
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout
|
||||
self.assertEqual(json.loads(out), [
|
||||
{"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"},
|
||||
{"bg": "#000000", "fg": "#aacace", "dim": "#66797c"},
|
||||
None,
|
||||
{"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"},
|
||||
])
|
||||
|
||||
def test_text_colour_by_contrast(self):
|
||||
cases = {"#a0a0a0": "#3b4a50", "#999999": "#3b4a50", "#6699cc": "#3b4a50", "#333333": "#aacace"}
|
||||
js = ca.CLIENT_COLORS_JS + (
|
||||
"\nconsole.log(JSON.stringify(%s.map(c => panelColors(c).fg)))" % json.dumps(list(cases)))
|
||||
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout
|
||||
self.assertEqual(json.loads(out), list(cases.values()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
88
tests/test_core.py
Normal file
88
tests/test_core.py
Normal file
@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
|
||||
import claude_agents as ca # noqa: E402
|
||||
|
||||
|
||||
class DefaultTheme(unittest.TestCase):
|
||||
def test_default_theme_matches_chat_profile(self):
|
||||
self.assertEqual(ca.DEFAULT_THEME, {
|
||||
"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
|
||||
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
|
||||
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13})
|
||||
|
||||
|
||||
class NoITermCoupling(unittest.TestCase):
|
||||
def test_iterm_coupling_removed(self):
|
||||
for name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT",
|
||||
"_app", "_mix", "_hex"):
|
||||
self.assertFalse(hasattr(ca, name), name)
|
||||
self.assertNotIn("use_profile_theme", ca.CONFIG_KEYS)
|
||||
self.assertNotIn("theme_variant", ca.CONFIG_KEYS)
|
||||
|
||||
|
||||
class Config(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime = self.saved
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_old_theme_keys_ignored(self):
|
||||
path = os.path.join(self.tmp.name, "config.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({"use_profile_theme": False, "theme_variant": "dark", "poll_sec": 2}, f)
|
||||
ca.CONFIG_PATH, ca._config_mtime = path, None
|
||||
ca.load_config()
|
||||
self.assertIsNone(ca.state.get("config_error"))
|
||||
self.assertEqual(ca.POLL_SEC, 2.0)
|
||||
|
||||
|
||||
class Actions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.saved = (ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"])
|
||||
ca._tmux = lambda sock, *args: self.calls.append((sock, *args))
|
||||
ca._last_open.clear()
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"] = self.saved
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_activate_tmux_target(self):
|
||||
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
|
||||
asyncio.run(ca.activate("tmux:/tmp/s|%3"))
|
||||
self.assertEqual(self.calls, [("/tmp/s", "switch-client", "-t", "%3"),
|
||||
("/tmp/s", "select-window", "-t", "%3"),
|
||||
("/tmp/s", "select-pane", "-t", "%3")])
|
||||
|
||||
def test_activate_non_tmux_is_noop(self):
|
||||
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
|
||||
asyncio.run(ca.activate("w0t0p0:ABC"))
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_open_worktree_non_tmux_is_noop(self):
|
||||
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
|
||||
ca.known_roots = lambda: {self.tmp.name}
|
||||
asyncio.run(ca.open_worktree("w0t0p0:ABC", self.tmp.name))
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_open_worktree_tmux_splits(self):
|
||||
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
|
||||
ca.known_roots = lambda: {self.tmp.name}
|
||||
ca.FILE_MANAGER = "lf"
|
||||
asyncio.run(ca.open_worktree("tmux:/tmp/s|%3", self.tmp.name))
|
||||
self.assertEqual(self.calls, [("/tmp/s", "split-window", "-h", "-t", "%3",
|
||||
"-c", self.tmp.name, "exec lf")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
40
tests/test_install.py
Normal file
40
tests/test_install.py
Normal file
@ -0,0 +1,40 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
STUBBED = ("launchctl", "lsof", "defaults", "osascript", "pgrep", "pkill", "curl")
|
||||
SSH_ENVS = ({"SSH_CONNECTION": "10.0.0.2 50000 10.0.0.1 22"}, {"SSH_TTY": "/dev/ttys009"})
|
||||
|
||||
|
||||
class RefuseOverSsh(unittest.TestCase):
|
||||
"""Только охранная проверка: реальная установка в этих тестах не запускается."""
|
||||
|
||||
def assert_refused(self, *cmd):
|
||||
for extra in SSH_ENVS:
|
||||
with self.subTest(cmd=cmd, env=extra), tempfile.TemporaryDirectory() as home, \
|
||||
tempfile.TemporaryDirectory() as stubs:
|
||||
# второй рубеж: даже если охрана не сработает, системные команды — заглушки
|
||||
for name in STUBBED:
|
||||
path = os.path.join(stubs, name)
|
||||
with open(path, "w") as f:
|
||||
f.write("#!/bin/sh\nexit 1\n")
|
||||
os.chmod(path, 0o755)
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("SSH_CONNECTION", "SSH_TTY")}
|
||||
env.update(HOME=home, PATH=stubs + os.pathsep + env.get("PATH", ""), **extra)
|
||||
r = subprocess.run(["bash", *cmd], cwd=ROOT, env=env, capture_output=True, text=True)
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn("за самим Mac", r.stdout + r.stderr)
|
||||
self.assertEqual(os.listdir(home), [])
|
||||
|
||||
def test_install_refuses_over_ssh(self):
|
||||
self.assert_refused("install.sh")
|
||||
|
||||
def test_uninstall_refuses_over_ssh(self):
|
||||
self.assert_refused("uninstall.sh")
|
||||
self.assert_refused("uninstall.sh", "--purge")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
tests/test_launchagent.py
Normal file
51
tests/test_launchagent.py
Normal file
@ -0,0 +1,51 @@
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
sys.path.insert(0, os.path.join(ROOT, "files"))
|
||||
import claude_agents # noqa: E402
|
||||
import launchagent # noqa: E402
|
||||
|
||||
SCRIPT = os.path.join(ROOT, "files", "launchagent.py")
|
||||
|
||||
|
||||
class Plist(unittest.TestCase):
|
||||
def test_label_matches_server(self):
|
||||
self.assertEqual(launchagent.LABEL, "com.andrey.claude-agents")
|
||||
self.assertEqual(launchagent.LABEL, claude_agents.SERVICE_LABEL)
|
||||
|
||||
def test_plist_shape(self):
|
||||
self.assertEqual(
|
||||
launchagent.plist("/opt/homebrew/bin/python3", "/x/claude_agents.py", "/l/panel.log",
|
||||
"/opt/homebrew/bin:/usr/bin:/bin"),
|
||||
{"Label": "com.andrey.claude-agents",
|
||||
"ProgramArguments": ["/opt/homebrew/bin/python3", "/x/claude_agents.py", "--headless"],
|
||||
"EnvironmentVariables": {"PATH": "/opt/homebrew/bin:/usr/bin:/bin"},
|
||||
"RunAtLoad": True, "KeepAlive": True,
|
||||
"StandardOutPath": "/l/panel.log", "StandardErrorPath": "/l/panel.log"})
|
||||
|
||||
|
||||
class Cli(unittest.TestCase):
|
||||
def test_cli_writes_plist_with_spaces(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = f"{tmp}/LaunchAgents/a.plist"
|
||||
args = ("/usr/bin/python3", f"{tmp}/Application Support/claude_agents.py",
|
||||
f"{tmp}/Logs/claude-agents/panel.log", "/opt/homebrew/bin:/usr/bin:/bin")
|
||||
r = subprocess.run([sys.executable, SCRIPT, "write", target, *args],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
with open(target, "rb") as f:
|
||||
self.assertEqual(plistlib.load(f), launchagent.plist(*args))
|
||||
self.assertTrue(os.path.isdir(f"{tmp}/Logs/claude-agents"))
|
||||
|
||||
def test_cli_bad_args(self):
|
||||
r = subprocess.run([sys.executable, SCRIPT, "write"], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
227
tests/test_service.py
Normal file
227
tests/test_service.py
Normal file
@ -0,0 +1,227 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
|
||||
import claude_agents as ca # noqa: E402
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def http_get(port, path):
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=3) as r:
|
||||
return r.status, r.read().decode("utf-8")
|
||||
|
||||
|
||||
class PortAlive(unittest.TestCase):
|
||||
def test_port_alive(self):
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen()
|
||||
port = s.getsockname()[1]
|
||||
self.assertTrue(ca.port_alive(port))
|
||||
s.close()
|
||||
self.assertFalse(ca.port_alive(port))
|
||||
|
||||
|
||||
class Serve(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.port = free_port()
|
||||
patches = [
|
||||
mock.patch.object(ca, "PORT", self.port),
|
||||
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
|
||||
mock.patch.object(ca, "LOG_DIR", self._tmpdir()),
|
||||
mock.patch.object(ca, "load_sessions", lambda: []),
|
||||
mock.patch.object(ca, "fetch_usage", lambda: {}),
|
||||
mock.patch.object(ca, "fetch_balance", lambda: ({}, "test")),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _tmpdir(self):
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
return d.name
|
||||
|
||||
async def _with_server(self, check):
|
||||
task = asyncio.create_task(ca.serve())
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
for _ in range(60):
|
||||
if ca.port_alive(self.port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
return await check(loop)
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def test_serve_answers(self):
|
||||
async def check(loop):
|
||||
status, body = await loop.run_in_executor(None, http_get, self.port, "/")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('<div id="root">', body)
|
||||
status, raw = await loop.run_in_executor(None, http_get, self.port, "/raw")
|
||||
self.assertIn("tree", json.loads(raw))
|
||||
asyncio.run(self._with_server(check))
|
||||
self.assertFalse(ca.port_alive(self.port))
|
||||
|
||||
def test_serve_survives_usage_failure(self):
|
||||
def no_token():
|
||||
raise FileNotFoundError("no credentials")
|
||||
|
||||
async def check(loop):
|
||||
body = ""
|
||||
for _ in range(60):
|
||||
_, body = await loop.run_in_executor(None, http_get, self.port, "/limits")
|
||||
if "Не найден токен Claude Code" in body:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertIn("Не найден токен Claude Code", body)
|
||||
status, _ = await loop.run_in_executor(None, http_get, self.port, "/")
|
||||
self.assertEqual(status, 200)
|
||||
with mock.patch.object(ca, "fetch_usage", no_token):
|
||||
asyncio.run(self._with_server(check))
|
||||
|
||||
def test_host_header_checked(self):
|
||||
def get(host, path="/", **extra):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", headers={"Host": host, **extra})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
return r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
return e.code
|
||||
|
||||
async def check(loop):
|
||||
self.assertEqual(await loop.run_in_executor(None, get, "evil.example:%d" % self.port), 403)
|
||||
self.assertEqual(await loop.run_in_executor(None, get, "127.0.0.1"), 403)
|
||||
self.assertEqual(await loop.run_in_executor(None, get, "127.0.0.1:%d" % self.port), 200)
|
||||
self.assertEqual(await loop.run_in_executor(None, get, "LocalHost:%d" % self.port), 200)
|
||||
self.assertEqual(await loop.run_in_executor(None, get, "evil.example:%d" % self.port, "/raw"), 403)
|
||||
asyncio.run(self._with_server(check))
|
||||
|
||||
def test_cross_site_actions_forbidden(self):
|
||||
def get(path, site):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", headers={"Sec-Fetch-Site": site})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
return r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
return e.code
|
||||
|
||||
async def check(loop):
|
||||
for path in ("/activate?id=x", "/open-wt?id=x&path=/tmp"):
|
||||
self.assertEqual(await loop.run_in_executor(None, get, path, "cross-site"), 403)
|
||||
with mock.patch.object(ca, "activate", mock.AsyncMock()) as act, \
|
||||
mock.patch.object(ca, "open_worktree", mock.AsyncMock()) as wt:
|
||||
asyncio.run(self._with_server(check))
|
||||
act.assert_not_called()
|
||||
wt.assert_not_called()
|
||||
|
||||
def test_serve_busy_port_raises(self):
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", self.port))
|
||||
s.listen()
|
||||
self.addCleanup(s.close)
|
||||
with self.assertRaises(OSError):
|
||||
asyncio.run(ca.serve())
|
||||
|
||||
|
||||
class RegisterToolbelt(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.registered = []
|
||||
self.kicks = []
|
||||
|
||||
async def register(connection, **kwargs):
|
||||
self.registered.append(kwargs)
|
||||
|
||||
fake = types.SimpleNamespace(tool=types.SimpleNamespace(async_register_web_view_tool=register))
|
||||
patches = [
|
||||
mock.patch.object(ca, "iterm2", fake),
|
||||
mock.patch.object(ca, "PORT", 8766),
|
||||
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
|
||||
mock.patch.object(ca, "kickstart_service", lambda: self.kicks.append(1)),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def assert_registered(self):
|
||||
self.assertEqual(self.registered, [{
|
||||
"display_name": "Claude", "identifier": "com.andrey.claude-agents",
|
||||
"reveal_if_already_registered": False, "url": "http://127.0.0.1:8766/"}])
|
||||
|
||||
def test_register_toolbelt_kickstarts_when_port_dead(self):
|
||||
async def no_sleep(sec):
|
||||
pass
|
||||
with mock.patch.object(ca, "port_alive", lambda *a, **k: False), \
|
||||
mock.patch.object(ca.asyncio, "sleep", no_sleep):
|
||||
asyncio.run(ca.register_toolbelt(object()))
|
||||
self.assertEqual(self.kicks, [1])
|
||||
self.assert_registered()
|
||||
|
||||
def test_register_toolbelt_waits_for_service(self):
|
||||
alive = iter([False, False, False, True]) # проверка в начале + ожидание после kickstart
|
||||
sleeps = []
|
||||
|
||||
async def fake_sleep(sec):
|
||||
sleeps.append(sec)
|
||||
with mock.patch.object(ca, "port_alive", lambda *a, **k: next(alive)), \
|
||||
mock.patch.object(ca.asyncio, "sleep", fake_sleep):
|
||||
asyncio.run(ca.register_toolbelt(object()))
|
||||
self.assertEqual(sleeps, [0.25, 0.25])
|
||||
self.assert_registered()
|
||||
|
||||
def test_register_toolbelt_skips_kickstart_when_alive(self):
|
||||
with mock.patch.object(ca, "port_alive", lambda *a, **k: True):
|
||||
asyncio.run(ca.register_toolbelt(object()))
|
||||
self.assertEqual(self.kicks, [])
|
||||
self.assert_registered()
|
||||
|
||||
|
||||
class Kickstart(unittest.TestCase):
|
||||
def test_kickstart_command(self):
|
||||
with mock.patch.object(ca.subprocess, "run") as run:
|
||||
ca.kickstart_service()
|
||||
self.assertEqual(run.call_args.args[0],
|
||||
["launchctl", "kickstart", f"gui/{os.getuid()}/com.andrey.claude-agents"])
|
||||
with mock.patch.object(ca.subprocess, "run", side_effect=OSError("no launchctl")):
|
||||
ca.kickstart_service() # не бросает
|
||||
|
||||
|
||||
class Run(unittest.TestCase):
|
||||
def test_run_headless_calls_serve(self):
|
||||
called = []
|
||||
|
||||
async def fake_serve():
|
||||
called.append(1)
|
||||
with mock.patch.object(ca, "serve", fake_serve):
|
||||
ca.run(["--headless"])
|
||||
self.assertEqual(called, [1])
|
||||
|
||||
def test_run_without_iterm_exits(self):
|
||||
with mock.patch.object(ca, "iterm2", None):
|
||||
with self.assertRaises(SystemExit):
|
||||
ca.run([])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
20
uninstall.sh
20
uninstall.sh
@ -1,14 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Удаление панели «Claude». С --purge удаляет ещё настройки и логи хука.
|
||||
# Удаление панели «Claude». С --purge удаляет ещё настройки и логи.
|
||||
set -euo pipefail
|
||||
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31mОшибка:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] \
|
||||
|| die "запустите за самим Mac, не по SSH — из SSH-сессии launchd и iTerm2 не настроить. Если вы и так за Mac (например, в панели tmux, открытой раньше из SSH), запустите в обычной вкладке iTerm2 или после unset SSH_CONNECTION SSH_TTY"
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch"
|
||||
SETTINGS="$HOME/.claude/settings.json"
|
||||
BACKUP_DIR="$HOME/.claude-panel-backup/$(date +%Y%m%d-%H%M%S)-$$-uninstall"
|
||||
PORT=8766
|
||||
LABEL=com.andrey.claude-agents
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
# служба — первой, иначе launchd поднимет сервер снова
|
||||
launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 && ok "служба остановлена" || true
|
||||
rm -f "$PLIST"
|
||||
rm -rf "$HOME/.local/share/claude-agents"
|
||||
ok "служба удалена"
|
||||
|
||||
if command -v lsof >/dev/null; then
|
||||
pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
@ -25,9 +37,9 @@ fi
|
||||
rm -f "$HOME/.claude/hooks/agents_panel_hook.sh" && ok "хук удалён"
|
||||
|
||||
if [ "${1:-}" = "--purge" ]; then
|
||||
rm -rf "$HOME/.config/claude-agents" "$HOME/.cache/claude-agents"
|
||||
rm -rf "$HOME/.config/claude-agents" "$HOME/.cache/claude-agents" "$HOME/Library/Logs/claude-agents"
|
||||
ok "настройки и логи удалены"
|
||||
else
|
||||
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents) оставлены; --purge удалит и их."
|
||||
echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents, ~/Library/Logs/claude-agents) оставлены; --purge удалит и их."
|
||||
fi
|
||||
echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user