diff --git a/.gitignore b/.gitignore index 4b7e182..533a1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .superpowers/ .bounded .DS_Store +__pycache__/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d35acb8..086af0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/.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 строк. diff --git a/CLAUDE.md b/CLAUDE.md index e932e10..5a563ef 100644 --- a/CLAUDE.md +++ b/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/.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`). - - `usage_poller` / `balance_poller` ходят в `api.anthropic.com/api/oauth/usage` и - `.../prepaid/credits` (запасной путь — claude.ai с `sessionKey` из Keychain). - - HTTP-сервер на `127.0.0.1:8766` (`handle`) отдаёт страницу, которую iTerm2 показывает как web view - tool: `/`, `/fragment` (дерево), `/limits`, `/message` (последние сообщения), `/activate`, - `/open-wt`, `/raw` (отладка). - - `theme_poller` подстраивает цвета под профиль iTerm2. +- `files/claude_agents.py` — один файл, два режима (`run`): + - `--headless` (`serve`) — служба launchd `com.andrey.claude-agents`, единственный владелец порта: + - `refresher` раз в `poll_sec` перечитывает jsonl (`load_sessions` → `replay`), сверяет с + процессами (`ps`) и панелями tmux, дочитывает из транскриптов модель и токены, из git — + worktree; собирает `state["tree"]` (`build_tree`, без данных iTerm2). + - `usage_poller` / `balance_poller` ходят в `api.anthropic.com/api/oauth/usage` и + `.../prepaid/credits` (запасной путь — claude.ai с `sessionKey` из Keychain). + - HTTP-сервер (`handle`): `/`, `/fragment` (дерево), `/limits`, `/message` (последние + сообщения), `/activate` и `/open-wt` (через tmux), `/raw` (отладка). + - без флага (`register_toolbelt`) — скрипт AutoLaunch iTerm2: регистрирует web view «Claude» на + адрес службы; если порт молчит — `launchctl kickstart`. Свой сервер не поднимает. +- `files/launchagent.py` — пишет plist службы (`write
{render_tree()}
{render_limits()}
+{render_bgctl()}
↗ вкладка @@ -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); """ @@ -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'
обновлено {upd}
') + parts.append(f'
обновлено {upd}' + f'
') 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() + 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)) 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:"): - 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) + 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)) -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:]) diff --git a/files/config.example.json b/files/config.example.json index 23186c9..083cb4c 100644 --- a/files/config.example.json +++ b/files/config.example.json @@ -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 } diff --git a/files/launchagent.py b/files/launchagent.py new file mode 100644 index 0000000..33ae402 --- /dev/null +++ b/files/launchagent.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Пишет описание службы launchd для сервера панели: запуск при входе, перезапуск при падении. + + launchagent.py write +""" +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() diff --git a/install.sh b/install.sh index 9cdebef..c148da7 100755 --- a/install.sh +++ b/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/) и 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 </