commit 0d2380d36ddabb8a14e1071aac09d34e3471aca1 Author: Andrey Epifancev Date: Sun Oct 18 19:12:16 2026 +0400 chore: import existing iTerm2 Claude panel code Co-Authored-By: Claude Opus 5.5 diff --git a/.claude/code-paths b/.claude/code-paths new file mode 100644 index 0000000..a37273b --- /dev/null +++ b/.claude/code-paths @@ -0,0 +1 @@ +files/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d82a004 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Claude — панель для iTerm2 Toolbelt + +Сверху — дерево активных сессий Claude Code и их субагентов (модель, контекст, worktree), +снизу — лимиты подписки и баланс extra usage. + +## Установка + +```bash +tar xzf claude-panel.tar.gz +cd claude-panel +./install.sh +``` + +Затем в iTerm2: **View → Toolbelt → Show Toolbelt**, **Toolbelt → Claude**, и перезапустите +открытые сессии `claude`. Повторный запуск `install.sh` обновляет установленное. + +Нужно: macOS, iTerm2, python3, jq (`brew install jq`, в macOS 15+ уже есть). + +## Что ставится + +| Что | Куда | +|---|---| +| скрипт панели | `~/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/<дата>/`. Ваши остальные хуки и настройки не трогаются. + +## Работа с панелью + +- клик по сессии или агенту — последние сообщения (копятся, пока окно открыто); +- ⌘-клик по сессии — перейти в её вкладку; +- клик по `⎇ папка` — открыть в ней `lf` в соседней панели; +- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен; +- сессии внутри tmux (и `tmux -CC`) тоже видны: клик по `⎇` делит панель tmux, ⌘-клик переключает на неё; +- цвет токенов: жёлтый > 500k, красный > 700k. + +## Настройки + +Скопируйте `config.example.json` в `~/.config/claude-agents/config.json` и оставьте только +нужные ключи. Меняется на лету, кроме `port`. + +## Если что-то не так + +`http://127.0.0.1:8766/raw` — что панель видит: логи хука, причины скрытия сессий, +ошибки лимитов и баланса. + +## Удаление + +```bash +./uninstall.sh # настройки и логи остаются +./uninstall.sh --purge # удалить всё +``` diff --git a/files/agents_panel_hook.sh b/files/agents_panel_hook.sh new file mode 100755 index 0000000..0931b5b --- /dev/null +++ b/files/agents_panel_hook.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Хук Claude Code для панели «Claude» в iTerm2. +# Пишет события сессий и субагентов в ~/.cache/claude-agents/.jsonl +# вместе с тем, где запущена сессия: PID процесса claude, вкладка iTerm2 или панель tmux. +# Нужен jq (в macOS 15+ встроен, иначе: brew install jq). +# Никогда не мешает Claude Code: ничего не выводит и всегда выходит с кодом 0. + +dir="$HOME/.cache/claude-agents" +input=$(cat) + +sid=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-') +[ -n "$sid" ] || exit 0 +mkdir -p "$dir" 2>/dev/null || exit 0 + +# PID самого claude: идём вверх по родителям, пропуская оболочки, которыми запущен хук +pid=$PPID +comm="" +i=0 +while [ "$i" -lt 4 ] && [ "${pid:-1}" -gt 1 ]; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null | sed 's#.*/##; s/^-//') + case "$comm" in + sh|bash|zsh|dash|fish) pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') ;; + *) break ;; + esac + i=$((i + 1)) +done + +# Внутри tmux ITERM_SESSION_ID унаследован от сервера tmux и указывает не туда — не пишем его +iterm="${ITERM_SESSION_ID:-}" +tmux_socket="" +tmux_pane="" +if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then + iterm="" + tmux_socket="${TMUX%%,*}" + tmux_pane="$TMUX_PANE" +fi + +printf '%s' "$input" | jq -c --arg iterm "$iterm" --arg pid "${pid:-}" --arg pid_comm "$comm" \ + --arg tmux_socket "$tmux_socket" --arg tmux_pane "$tmux_pane" ' + def cut: if type == "string" then .[0:300] else . end; + { + hook_event_name, session_id, transcript_path, cwd, agent_id, agent_type, + model, session_title, source, from_model, to_model, tool_name, tool_use_id, + notification_type, agent_transcript_path, reason, + message: (.message | cut), + description: (.description | cut), + last_assistant_message: (.last_assistant_message | cut), + tool_input: (if (.tool_input | type) == "object" + then .tool_input | {subagent_type, description, model, name} + | map_values(cut) | with_entries(select(.value != null)) + else null end), + t: now, + iterm: $iterm, + pid: ($pid | tonumber? // null), + pid_comm: (if $pid_comm == "" then null else $pid_comm end), + tmux_socket: (if $tmux_socket == "" then null else $tmux_socket end), + tmux_pane: (if $tmux_pane == "" then null else $tmux_pane end) + } | with_entries(select(.value != null)) +' >> "$dir/$sid.jsonl" 2>/dev/null + +exit 0 diff --git a/files/claude_agents.py b/files/claude_agents.py new file mode 100644 index 0000000..b0feb58 --- /dev/null +++ b/files/claude_agents.py @@ -0,0 +1,1506 @@ +#!/usr/bin/env python3 +""" +Claude — панель для iTerm2 Toolbelt. + +Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов — +название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии. +Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage. + +Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в +~/.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 удалить) + +Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json. +Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта). + +Отладка: http://127.0.0.1:8766/raw — разобранное состояние. +Только стандартная библиотека. +""" +import asyncio +import glob +import html +import json +import os +import re +import shlex +import shutil +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +try: + import iterm2 +except ImportError: # для тестов вне iTerm2 + iterm2 = None + +# ---------- настройки ---------- +# Значения по умолчанию. Переопределяются в ~/.config/claude-agents/config.json +# (см. config.example.json); файл перечитывается на лету, кроме port. + +CONFIG_PATH = os.path.expanduser("~/.config/claude-agents/config.json") +LOG_DIR = os.path.expanduser("~/.cache/claude-agents") # туда же пишет хук + +PORT = 8766 +POLL_SEC = 1.5 # как часто панель обновляет дерево +DONE_LINGER_SEC = 20 # сколько показывать завершившегося субагента +STALE_SEC = 30 * 60 # без iTerm2: сессия без событий дольше этого скрыта +KEEP_LOGS_SEC = 2 * 86400 +AGENT_DIR = "changed" # папка у субагента: changed — если отличается от родителя, always, never +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 + +# лимиты подписки и баланс (нижняя часть панели) +USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429 +BALANCE_REFRESH_SEC = 600 +ORG_UUID = "" # пусто — взять из ~/.claude.json (аккаунт Claude Code), иначе через API +SHOW_BREAKDOWN = True # строка «Claude Code 98% · Cowork 2%» под недельными лимитами +SHOW_BALANCE = True +AGENT_IDLE_SEC = 15 * 60 # субагент без SubagentStop, чей транскрипт не меняется столько — завис/убит + +CONFIG_KEYS = { # ключ в config.json -> (глобальная переменная, тип) + "port": ("PORT", int), + "poll_sec": ("POLL_SEC", float), + "done_linger_sec": ("DONE_LINGER_SEC", float), + "agent_idle_min": ("AGENT_IDLE_SEC", lambda v: float(v) * 60), + "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), + "usage_refresh_sec": ("USAGE_REFRESH_SEC", float), + "balance_refresh_sec": ("BALANCE_REFRESH_SEC", float), + "org_uuid": ("ORG_UUID", str), + "show_breakdown": ("SHOW_BREAKDOWN", bool), + "show_balance": ("SHOW_BALANCE", bool), +} +_config_mtime = None + + +def load_config(): + """Подхватывает config.json, если он появился или изменился. Ошибки не роняют панель.""" + global _config_mtime + try: + mtime = os.path.getmtime(CONFIG_PATH) + except OSError: + return + if mtime == _config_mtime: + return + _config_mtime = mtime + try: + with open(CONFIG_PATH, encoding="utf-8") as f: + cfg = json.load(f) + g = globals() + for key, (name, conv) in CONFIG_KEYS.items(): + if key in cfg: + g[name] = conv(cfg[key]) + state["config_error"] = None + except Exception as e: + state["config_error"] = f"config.json: {e}" +DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90", + "green": "#859900", "yellow": "#b58900", "red": "#dc322f", + "font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12} + +state = {"theme": dict(DEFAULT_THEME), "live": None, "tree": [], "error": None, + "usage": None, "usage_error": None, "usage_updated": None, + "balance": None, "balance_error": None, "balance_via": None} +_app = None +_server = None + + +# ---------- модели ---------- + +FAMILIES = ("opus", "sonnet", "haiku", "fable", "mythos") + + +def pretty_model(m) -> str: + """claude-opus-5-5 → Opus 5.5, claude-3-5-sonnet-20241022 → Sonnet 3.5, opus → Opus.""" + if not m: + return "" + s = str(m).strip() + low = s.lower() + if low == "inherit": + return "" + suffix = " 1M" if "[1m]" in low else "" + low = low.replace("[1m]", "") + for fam in FAMILIES: + if fam in low: + after = re.search(fam + r"-(\d{1,2})(?:[-.](\d{1,2}))?(?!\d)", low) + before = re.search(r"(\d+)-(\d{1,2})-" + fam, low) + ver = after or before + v = "" + if ver: + v = ver.group(1) + (f".{ver.group(2)}" if ver.group(2) else "") + return f"{fam.capitalize()} {v}".strip() + suffix + return s + + +_model_cache = {} # path -> (mtime, model) + + +def model_from_transcript(path): + """Последняя модель, которой отвечал ассистент, по хвосту JSONL-транскрипта.""" + if not path or not os.path.exists(path): + return None + try: + mtime = os.path.getmtime(path) + cached = _model_cache.get(path) + if cached and cached[0] == mtime: + return cached[1] + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + f.seek(max(0, f.tell() - 256 * 1024)) + lines = f.read().decode("utf-8", "replace").splitlines() + model = None + for line in reversed(lines): + if '"model"' not in line: + continue + try: + rec = json.loads(line) + except ValueError: + continue + msg = rec.get("message") if isinstance(rec, dict) else None + m = msg.get("model") if isinstance(msg, dict) else None + if m and not str(m).startswith("<"): + model = m + break + _model_cache[path] = (mtime, model) + return model + except OSError: + return None + + +def subagent_transcript(session, agent_id): + if session.get("agent_paths", {}).get(agent_id): + return session["agent_paths"][agent_id] + tp = session.get("transcript_path") + if not tp or not agent_id: + return None + base = os.path.dirname(tp) + for pattern in (os.path.join(base, session["id"], "subagents", f"*{agent_id}*.jsonl"), + os.path.join(base, session["id"], "**", f"*{agent_id}*.jsonl"), + os.path.join(base, f"*{agent_id}*.jsonl")): + hits = glob.glob(pattern, recursive=True) + if hits: + session.setdefault("agent_paths", {})[agent_id] = hits[0] + return hits[0] + return None + + +# ---------- токены ---------- + +_tok_cache = {} # path -> {"offset": int, "rest": bytes, "by_id": {msg_id: usage}} + + +def _content_chars(content) -> int: + """Длина текста сообщения: строки, текстовые блоки, результаты инструментов.""" + if isinstance(content, str): + return len(content) + n = 0 + if isinstance(content, list): + for b in content: + if not isinstance(b, dict): + continue + if b.get("type") == "text": + n += len(b.get("text") or "") + elif b.get("type") == "tool_result": + n += _content_chars(b.get("content")) + elif b.get("type") == "tool_use": + n += len(json.dumps(b.get("input") or {}, ensure_ascii=False)) + return n + + +def tokens_from_transcript(path, skip_sidechain=False): + """Размер контекста, как его показывает Claude Code: usage последнего ответа модели + (вход + запись в кэш + чтение кэша + выход) плюс грубая оценка (символы / 4) всего, + что добавилось в разговор после этого ответа — результатов инструментов, + уведомлений и т.п., ещё не отправленных модели. + + Читает файл инкрементально. Одно сообщение Claude Code пишет несколькими строками + с одинаковым message.id — usage у них общий.""" + if not path or not os.path.exists(path): + return None + key = (path, skip_sidechain) + st = _tok_cache.get(key) + if st is None: + st = _tok_cache[key] = {"offset": 0, "rest": b"", "usage": None, "usage_id": None, + "after": 0} + try: + size = os.path.getsize(path) + if size < st["offset"]: # файл переписан (компакция) — считаем заново + st.update(offset=0, rest=b"", usage=None, usage_id=None, after=0) + if size > st["offset"]: + with open(path, "rb") as f: + f.seek(st["offset"]) + chunk = st["rest"] + f.read() + st["offset"] = size + lines = chunk.split(b"\n") + st["rest"] = lines.pop() # незаконченная строка + for raw in lines: + if not raw.strip(): + continue + try: + rec = json.loads(raw) + except ValueError: + continue + if not isinstance(rec, dict) or (skip_sidechain and rec.get("isSidechain")): + continue + if rec.get("cwd"): + st["cwd"] = rec["cwd"] + msg = rec.get("message") + if not isinstance(msg, dict): + continue + if isinstance(msg.get("usage"), dict) and rec.get("type") == "assistant": + mid = msg.get("id") + if mid != st["usage_id"]: + st["after"] = 0 # новый ответ модели — всё предыдущее уже в его usage + st["usage"], st["usage_id"] = msg["usage"], mid + elif st["usage"] is not None: + st["after"] += _content_chars(msg.get("content")) + except OSError: + return None + u = st["usage"] + if not u: + return None + total = {"input": u.get("input_tokens") or 0, + "cache_write": u.get("cache_creation_input_tokens") or 0, + "cache_read": u.get("cache_read_input_tokens") or 0, + "output": u.get("output_tokens") or 0, + "pending": round(st["after"] / 4)} + total["all"] = sum(total.values()) + return total if total["all"] else None + + +def transcript_cwd(path, skip_sidechain=False): + """Последняя рабочая папка из транскрипта (после вызова tokens_from_transcript).""" + return (_tok_cache.get((path, skip_sidechain)) or {}).get("cwd") + + +# ---------- git / worktree ---------- + +_git_cache = {} # cwd -> (time, info) + + +def git_info(cwd): + """{'root', 'name', 'branch'} для репозитория или worktree, в котором лежит cwd. + Читает .git напрямую, без запуска git.""" + if not cwd: + return None + hit = _git_cache.get(cwd) + if hit and time.time() - hit[0] < 10: + return hit[1] + info, d = None, cwd + while d and d != os.path.dirname(d): + dot = os.path.join(d, ".git") + if os.path.exists(dot): + gitdir = dot + if os.path.isfile(dot): # worktree: в .git ссылка "gitdir: …" + try: + with open(dot) as f: + line = f.read().strip() + if line.startswith("gitdir:"): + gitdir = os.path.normpath(os.path.join(d, line[7:].strip())) + except OSError: + pass + branch = "" + try: + with open(os.path.join(gitdir, "HEAD")) as f: + head = f.read().strip() + branch = head.split("refs/heads/", 1)[1] if "refs/heads/" in head else head[:8] + except OSError: + pass + info = {"root": d, "name": os.path.basename(d), "branch": branch, + "worktree": os.path.isfile(dot)} + break + d = os.path.dirname(d) + if info is None and os.path.isdir(cwd): + # не git-репозиторий (например, папка, в которой лежат несколько worktree) — + # показываем саму папку, без ветки + info = {"root": cwd, "name": "", "branch": "", "worktree": False} + if info: + root = info["root"] + # для раскладки <проект>/.wt/<задача>/<репо> показываем путь после .wt/ + info["name"] = root.split("/.wt/", 1)[1] if "/.wt/" in root else os.path.basename(root) + _git_cache[cwd] = (time.time(), info) + return info + + +def fmt_tokens(n) -> str: + if n is None: + return "" + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1000: + return f"{n / 1000:.1f}k" + return str(n) + + +def tokens_html(t) -> str: + if not t: + return "" + tip = (f"вход {fmt_tokens(t['input'])} · запись в кэш {fmt_tokens(t['cache_write'])} · " + f"чтение кэша {fmt_tokens(t['cache_read'])} · выход {fmt_tokens(t['output'])} · " + f"новое после ответа ~{fmt_tokens(t.get('pending', 0))}") + level = " crit" if t["all"] > CONTEXT_CRIT else " warn" if t["all"] > CONTEXT_WARN else "" + return f'{fmt_tokens(t["all"])} tokens' + + +# ---------- разбор событий ---------- + +def replay(events): + """Собирает состояние одной сессии из её событий.""" + s = {"id": None, "iterm": "", "pid": None, "pid_comm": None, "tmux_socket": None, "tmux_pane": None, + "model": None, "title": None, "cwd": None, + "transcript_path": None, "status": "idle", "detail": "", "ended": False, + "agents": {}, "pending": [], "last": 0.0} + for ev in events: + name = ev.get("hook_event_name") + aid = ev.get("agent_id") + t = ev.get("t", 0.0) + s["id"] = ev.get("session_id") or s["id"] + if ev.get("tmux_pane"): # сессия в tmux: ITERM_SESSION_ID там чужой + s["tmux_socket"], s["tmux_pane"], s["iterm"] = ev.get("tmux_socket"), ev["tmux_pane"], "" + elif ev.get("iterm"): + s["iterm"], s["tmux_pane"] = ev["iterm"], None + if ev.get("pid"): + s["pid"], s["pid_comm"] = ev["pid"], ev.get("pid_comm") + s["cwd"] = ev.get("cwd") or s["cwd"] + s["transcript_path"] = ev.get("transcript_path") or s["transcript_path"] + s["last"] = max(s["last"], t) + + if name == "SessionStart": + s["ended"] = False + s["model"] = ev.get("model") or s["model"] + s["title"] = ev.get("session_title") or s["title"] + s["status"], s["detail"] = "idle", "" + if ev.get("source") in ("startup", "clear"): + s["agents"], s["pending"] = {}, [] + elif name == "SessionEnd": + s["ended"] = True + elif name == "PostModelSwitch": + s["model"] = ev.get("to_model") or s["model"] + elif name == "UserPromptSubmit": + s["status"], s["detail"] = "working", "" + elif name == "Notification" and not aid: + s["status"] = "waiting" + s["detail"] = ev.get("message") or "" + elif name == "Stop" and not aid: + s["status"] = "idle" + s["detail"] = ev.get("last_assistant_message") or "" + elif name == "PreToolUse" and ev.get("tool_name") in ("Agent", "Task"): + ti = ev.get("tool_input") or {} + s["pending"].append({"type": ti.get("subagent_type") or "general-purpose", + "desc": ti.get("description") or ti.get("name") or "", + "model": ti.get("model"), "parent": aid, "t": t}) + if not aid: # запуск из самой сессии, а не из фонового субагента + s["status"] = "working" + elif name == "SubagentStart" and aid: + atype = ev.get("agent_type") or "" + if aid in s["agents"]: + # фоновый агент проснулся (пришёл результат его субагента) — это + # продолжение, а не новый запуск: описание и родитель прежние + s["agents"][aid]["status"], s["agents"][aid]["end"] = "running", None + continue + p = next((x for x in s["pending"] if x["type"] == atype), None) \ + or (s["pending"][0] if s["pending"] and atype else None) + if not p and not atype: + continue # служебный агент Claude Code (у них пустой agent_type) + if p: + s["pending"].remove(p) + s["agents"][aid] = { + "id": aid, "type": atype or p["type"], + "desc": (p or {}).get("desc") or ev.get("description") or "", + "model": (p or {}).get("model") or ev.get("model"), + "parent": (p or {}).get("parent"), "status": "running", + "start": t, "end": None, + } + elif name == "SubagentStop" and aid in s["agents"]: + a = s["agents"][aid] + a["status"], a["end"] = "done", t + if ev.get("agent_transcript_path"): + s.setdefault("agent_paths", {})[aid] = ev["agent_transcript_path"] + elif aid and aid in s["agents"] and s["agents"][aid]["status"] != "done": + s["agents"][aid]["status"] = "running" + # PreToolUse без SubagentStart старше минуты — запуск не состоялся + s["pending"] = [p for p in s["pending"] if time.time() - p["t"] < 60] + return s + + +def load_sessions(): + sessions = [] + for path in glob.glob(os.path.join(LOG_DIR, "*.jsonl")): + try: + with open(path, encoding="utf-8") as f: + events = [json.loads(line) for line in f if line.strip()] + except (OSError, ValueError): + continue + if events: + s = replay(events) + if s["id"]: + sessions.append(s) + return sessions + + + + +def agent_looks_dead(session, a, now) -> bool: + path = subagent_transcript(session, a["id"]) + try: + last = os.path.getmtime(path) if path else a.get("start") or now + except OSError: + last = a.get("start") or now + limit = AGENT_IDLE_SEC if path else 2 * 3600 + return now - last > limit + + +# ---------- где запущены сессии: процессы и tmux ---------- + +EXTRA_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + + +def _which(cmd): + return shutil.which(cmd, path=os.environ.get("PATH", "") + ":" + EXTRA_PATH) + + +def running_processes(pids): + """{pid: имя процесса} для живых из pids (одним вызовом ps).""" + pids = sorted({int(p) for p in pids if p}) + if not pids: + return {} + try: + out = subprocess.run(["ps", "-o", "pid=,comm=", "-p", ",".join(map(str, pids))], + capture_output=True, text=True, timeout=5).stdout + except Exception: + return None # не смогли спросить — не скрываем сессии по этому признаку + res = {} + for line in out.splitlines(): + parts = line.strip().split(None, 1) + if len(parts) == 2 and parts[0].isdigit(): + res[int(parts[0])] = os.path.basename(parts[1].strip()).lstrip("-") + return res + + +def tmux_panes(sockets): + """{(сокет, %pane): {"title", "where"}} по всем серверам tmux, где есть наши сессии.""" + tmux = _which("tmux") + if not tmux or not sockets: + return None + res = {} + for sock in sorted(sockets): + try: + out = subprocess.run( + [tmux, "-S", sock, "list-panes", "-a", "-F", + "#{pane_id}\t#{pane_title}\t#{session_name}:#{window_index} #{window_name}"], + capture_output=True, text=True, timeout=5) + except Exception: + continue + if out.returncode != 0: + continue # сервер не запущен — все его панели закрыты + for line in out.stdout.splitlines(): + pane, _, rest = line.partition("\t") + title, _, where = rest.partition("\t") + res[(sock, pane)] = {"title": title, "where": where} + return res + + +def build_tree(sessions, live, procs=None, tmux=None): + """live: {uuid: {"name":…, "job":…}} из API iTerm2 или None (нет связи с iTerm2).""" + now = time.time() + by_tab = {} + debug = [] + tmux_twins = {} + for s in sessions: + uuid = s["iterm"].split(":")[-1] if s["iterm"] else "" + info = (live or {}).get(uuid) + tinfo = (tmux or {}).get((s["tmux_socket"], s["tmux_pane"])) if s["tmux_pane"] else None + pid_known = bool(s["pid"]) and procs is not None + pid_alive = pid_known and procs.get(int(s["pid"])) is not None and ( + not s["pid_comm"] or procs.get(int(s["pid"])) == s["pid_comm"]) + reason = None + if s["ended"]: + reason = "SessionEnd" + elif pid_known and not pid_alive: + reason = f"процесс claude {s['pid']} завершён" + elif s["tmux_pane"]: + if tmux is not None and tinfo is None: + reason = f"панель tmux {s['tmux_pane']} закрыта" + elif tmux is None and not pid_known and now - s["last"] > STALE_SEC: + reason = "давно нет событий (tmux недоступен)" + elif live is not None and not pid_alive: + # старые события без PID — судим по вкладке iTerm2, как раньше + if not uuid: + reason = "нет ITERM_SESSION_ID в событиях" + elif not info: + reason = "вкладка iTerm2 не найдена" + elif is_shell(info.get("job")): + reason = f"в вкладке оболочка ({info.get('job')}), claude не запущен" + elif live is None and not pid_alive and now - s["last"] > STALE_SEC: + reason = "давно нет событий" + debug.append({"session": s["id"], "iterm": s["iterm"], "tab": info, "pid": s["pid"], + "tmux": [s["tmux_socket"], s["tmux_pane"]] if s["tmux_pane"] else None, + "tmux_pane_info": tinfo, + "last_event_ago_s": int(now - s["last"]), "hidden": reason}) + if reason: + continue + if s["tmux_pane"]: + target = f"tmux:{s['tmux_socket']}|{s['tmux_pane']}" + # tmux -CC: каждая панель tmux — отдельная сессия iTerm2, её и выводим на передний план + twin = next((u for u, i in (live or {}).items() if i.get("tmux_pane") == s["tmux_pane"]), None) + if twin: + tmux_twins[target] = twin + s["tab_name"] = (tinfo or {}).get("title") or "" + s["tmux_where"] = (tinfo or {}).get("where") or "" + else: + target = uuid if info else "" + if info: + s["tab_name"] = info.get("name") or "" + key = target or s["id"] + if key not in by_tab or by_tab[key]["last"] < s["last"]: + by_tab[key] = s # в одной вкладке/панели показываем только последнюю сессию + s["uuid"] = target + + tree = [] + for s in sorted(by_tab.values(), key=lambda x: -x["last"]): + main_model = s["model"] or model_from_transcript(s["transcript_path"]) + agents = [] + alive = {a["id"] for a in s["agents"].values() + if a["status"] == "running" and not agent_looks_dead(s, a, now)} + busy = set() # агенты, у которых кто-то из потомков ещё работает + for aid in alive: + p = s["agents"][aid].get("parent") + while p and p not in busy and p in s["agents"]: + busy.add(p) + p = s["agents"][p].get("parent") + for a in s["agents"].values(): + if a["status"] == "running" and a["id"] not in alive: + continue + if (a["status"] == "done" and a["id"] not in busy + and now - (a["end"] or now) > DONE_LINGER_SEC): + continue # фоновый контроллер, ждущий своих исполнителей, остаётся видимым + model = a["model"] if pretty_model(a["model"]) else None + model = model or model_from_transcript(subagent_transcript(s, a["id"])) + apath = subagent_transcript(s, a["id"]) + agents.append({**a, "model_label": pretty_model(model or main_model), + "inherited": not model, + "tokens": tokens_from_transcript(apath), + "git": git_info(transcript_cwd(apath)), + "transcript": apath}) + tree.append({**{k: s[k] for k in ("id", "uuid", "status", "detail", "cwd")}, + "name": session_name(s), "model_label": pretty_model(main_model), + "tokens": tokens_from_transcript(s["transcript_path"], skip_sidechain=True), + "git": git_info(transcript_cwd(s["transcript_path"], True) or s["cwd"]), + "transcript": s["transcript_path"], + "agents": agents}) + state["debug"] = {"live_tabs": live, "sessions": debug} + state["tmux_twins"] = tmux_twins + return tree + + +SHELLS = {"zsh", "bash", "fish", "sh", "dash", "tcsh", "csh", "nu", "login"} + + +def is_shell(job) -> bool: + """Вкладка вернулась в оболочку — значит claude в ней завершился. + Имя процесса самого claude бывает разным (claude, node, номер версии), поэтому + проверяем наоборот: скрываем только когда на переднем плане обычная оболочка.""" + j = os.path.basename((job or "").strip().lstrip("-")).lower() + return j in SHELLS + + +GENERIC_TAB_NAMES = {"", "bash", "zsh", "fish", "sh", "node", "claude", "claude code"} +SPINNER = re.compile(r"^[^\w«\"'(\[]+") # значки-спиннеры в начале заголовка +JOB_SUFFIX = re.compile(r"\s*\((?:claude|node|\d+(?:\.\d+)+)\)\s*$", re.I) + + +def session_name(s): + name = JOB_SUFFIX.sub("", SPINNER.sub("", s.get("tab_name") or "")).strip() + if name.lower() in GENERIC_TAB_NAMES: + name = "" # вкладка не переименована — лучше заголовок сессии или папка + if name and s.get("tmux_pane") and name == os.uname().nodename.split(".")[0]: + name = "" # tmux по умолчанию ставит в заголовок имя хоста + return (name or s.get("title") or s.get("tmux_where") or os.path.basename(s.get("cwd") or "") + or s["id"][:8]) + + +# ---------- последнее сообщение ---------- + +MAX_MESSAGE_CHARS = 20000 +_REMINDER = re.compile(r".*?", re.S) +_COMMAND = re.compile(r"(.*?).*?(?:(.*?))?", re.S) + + +def _user_text(content): + """Текст реплики человека/родителя; результаты инструментов и служебные вставки — None.""" + if isinstance(content, list): + content = "\n\n".join(b.get("text") or "" for b in content + if isinstance(b, dict) and b.get("type") == "text") + if not isinstance(content, str): + return None + text = _REMINDER.sub("", content).strip() + cmd = _COMMAND.search(text) + if cmd: # слэш-команда: показываем её саму, а не XML-обёртку + return f"{cmd.group(1).strip()} {(cmd.group(2) or '').strip()}".strip() + if text.startswith((" 2 * 1024 * 1024: + lines = lines[1:] # первая строка обрезана + except OSError: + return [] + out = [] + for line in lines: + try: + r = json.loads(line) + except ValueError: + continue + if not isinstance(r, dict) or not isinstance(r.get("message"), dict): + continue + if (skip_sidechain and r.get("isSidechain")) or r.get("isMeta") or r.get("isCompactSummary"): + continue + msg = r["message"] + if r.get("type") == "user": + text = _user_text(msg.get("content")) + if text: + out.append({"key": "u:" + str(r.get("uuid") or len(out)), "role": "user", + "text": text, "ts": r.get("timestamp")}) + elif r.get("type") == "assistant": + c = msg.get("content") + parts = [c] if isinstance(c, str) else [ + b.get("text") or "" for b in (c or []) if isinstance(b, dict) and b.get("type") == "text"] + text = "\n\n".join(p.strip() for p in parts if p and p.strip()) + if not text: + continue + key = "a:" + str(msg.get("id") or r.get("uuid")) + if out and out[-1]["key"] == key: # одно сообщение модели записано несколькими строками + out[-1]["text"] += "\n\n" + text + else: + out.append({"key": key, "role": "assistant", "text": text, "ts": r.get("timestamp")}) + return out[-limit:] + + +def md_html(text: str) -> str: + """Минимальный markdown: блоки кода, `код`, **жирный**, заголовки. Остальное — как есть.""" + if len(text) > MAX_MESSAGE_CHARS: + text = text[:MAX_MESSAGE_CHARS] + "\n\n… (обрезано)" + out = [] + for i, part in enumerate(re.split(r"```[^\n]*\n(.*?)(?:```|$)", text, flags=re.S)): + if i % 2: + out.append(f"
{html.escape(part.rstrip())}
") + continue + if i and part.startswith("\n"): + part = part[1:] # перевод строки сразу после блока кода дал бы пустую строку + h = html.escape(part) + h = re.sub(r"`([^`\n]+)`", r"\1", h) + h = re.sub(r"\*\*(.+?)\*\*", r"\1", h) + h = re.sub(r"(?m)^#{1,6}\s+(.+)$", r'\1', h) + h = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r"\1", h) + out.append(h) + return "".join(out).strip() + + +def _fmt_ts(ts): + if not ts: + return "" + try: + from datetime import datetime + return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().strftime("%H:%M") + except ValueError: + return "" + + +def message_payload(sid, aid, after=""): + """Сообщения после `after` (ключ последнего показанного). Без after — только последнее.""" + s = next((x for x in state["tree"] if x["id"] == sid), None) + if s is None: + return {"error": "Сессия больше не активна", "messages": []} + if aid: + a = next((x for x in s["agents"] if x["id"] == aid), None) + if a is None: + return {"error": "Агент больше не показывается", "uuid": s["uuid"], "messages": []} + msgs = recent_messages(a.get("transcript")) + title, who = a["desc"] or a["type"], {"user": "Задание", "assistant": "Агент"} + else: + msgs = recent_messages(s.get("transcript"), skip_sidechain=True) + title, who = s["name"], {"user": "Вы", "assistant": "Claude"} + keys = [m["key"] for m in msgs] + if after in keys: + i = keys.index(after) + msgs = msgs[i:] # включая сам after — у модели он мог дописаться + elif not after: + msgs = msgs[-1:] + return {"title": title, "uuid": s["uuid"], "max_chars": HISTORY_MAX_CHARS, + "error": "" if msgs or after else "Сообщений пока нет", + "messages": [{"key": m["key"], "role": m["role"], "who": who[m["role"]], + "time": _fmt_ts(m.get("ts")), "chars": len(m["text"]), + "html": md_html(m["text"])} for m in msgs]} + + +# ---------- отрисовка ---------- + +STATUS = {"working": "green", "waiting": "yellow", "idle": "dim", + "running": "green", "done": "dim"} + + +def elapsed(t0, t1=None): + sec = int((t1 or time.time()) - t0) + return f"{sec // 60}:{sec % 60:02d}" if sec < 3600 else f"{sec // 3600} ч {sec % 3600 // 60} м" + + +def wt_html(g, uuid) -> str: + if not g: + return "" + label = html.escape(g["name"]) + (f' · {html.escape(g["branch"])}' if g["branch"] else "") + return (f'⎇ {label}') + + +def render_agents(agents, parent, uuid="", parent_root=None): + """Дерево вложенными блоками: отступ и тонкая линия слева вместо псевдографики.""" + kids = [a for a in agents if a.get("parent") == parent] + if not kids: + return "" + out = ['
'] + for a in kids: + color = STATUS.get(a["status"], "dim") + model = html.escape(a["model_label"]) + model_html = f'{model}' if model else "" + desc = html.escape(a["desc"]) if a["desc"] else "" + atype = html.escape(a["type"]) + took = elapsed(a["start"], a["end"]) if a.get("start") else "" + g = a.get("git") + show = {"always": True, "never": False}.get(AGENT_DIR, bool(g) and g["root"] != parent_root) + wt = wt_html(g, uuid) if g and show else "" + meta = " · ".join(x for x in (took, tokens_html(a.get("tokens")), wt) if x) + out.append(f""" +
+
● + {desc or atype}{model_html}
+ {f'
{meta}
' if meta else ''} + {render_agents(agents, a["id"], uuid, (g or {}).get("root") or parent_root)} +
""") + out.append("
") + return "".join(out) + + +def render_tree() -> str: + if state.get("config_error"): + return f'
{html.escape(state["config_error"])}
' + _render_tree() + return _render_tree() + + +def _render_tree() -> str: + if state["error"]: + return f'
{html.escape(state["error"])}
' + if not state["tree"]: + return '
Нет активных сессий Claude Code
' + parts = [] + for s in state["tree"]: + color = STATUS.get(s["status"], "dim") + model = html.escape(s["model_label"]) + meta = " · ".join(x for x in (tokens_html(s.get("tokens")), wt_html(s.get("git"), s["uuid"])) if x) + parts.append(f""" +
+
● + {html.escape(s['name'])} + {f'{model}' if model else ''}
+ {f'
{meta}
' if meta else ''} + {render_agents(s['agents'], None, s['uuid'], (s.get('git') or {}).get('root'))} +
""") + return "".join(parts) + + +def render_page() -> str: + t = state["theme"] + return f""" + +
{render_tree()}
+
{render_limits()}
+
+
+ ↗ вкладка + ✕
+
+
+
+ +""" + + +# ---------- лимиты подписки и баланс ---------- + +USAGE_URL = "https://api.anthropic.com/api/oauth/usage" +KIND_TITLES = {"session": "Сессия · 5 ч", "weekly_all": "Неделя · все модели"} +CURRENCY_SIGNS = {"EUR": "€", "USD": "$", "GBP": "£"} +SESSION_KEYCHAIN_SERVICE = "claude-ai-sessionKey" # запасной способ для баланса +BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/129.0 Safari/537.36") + + +def keychain(service: str): + try: + out = subprocess.run(["security", "find-generic-password", "-s", service, "-w"], + capture_output=True, text=True, timeout=10).stdout.strip() + return out or None + except Exception: + return None + + +def get_token() -> str: + """OAuth-токен Claude Code: на macOS — из Keychain, иначе из ~/.claude/.credentials.json.""" + raw = keychain("Claude Code-credentials") + if raw: + try: + return json.loads(raw)["claudeAiOauth"]["accessToken"] + except (ValueError, KeyError): + pass + with open(os.path.expanduser("~/.claude/.credentials.json")) as f: + return json.load(f)["claudeAiOauth"]["accessToken"] + + +def _get_json(url: str, headers: dict) -> dict: + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=15) as r: + return json.load(r) + + +def _oauth_headers(token): + return {"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20", + "Content-Type": "application/json", "User-Agent": "iterm2-claude-panel/1.0"} + + +def fetch_usage() -> dict: + return _get_json(USAGE_URL, _oauth_headers(get_token())) + + +def fetch_balance(): + """Пробует способы по очереди и запоминает сработавший. Возвращает (json, способ).""" + global ORG_UUID + token = get_token() + if not ORG_UUID: + try: + with open(os.path.expanduser("~/.claude.json"), encoding="utf-8") as f: + ORG_UUID = (json.load(f).get("oauthAccount") or {}).get("organizationUuid") or "" + except (OSError, ValueError): + pass + if not ORG_UUID: + ORG_UUID = _get_json("https://api.anthropic.com/api/oauth/profile", + _oauth_headers(token))["organization"]["uuid"] + path = f"/api/organizations/{ORG_UUID}/prepaid/credits" + oauth = _oauth_headers(token) + attempts = [("oauth-api", f"https://api.anthropic.com/api/oauth{path[4:]}", oauth), + ("oauth-claude.ai", f"https://claude.ai{path}", {**oauth, "User-Agent": BROWSER_UA})] + session = keychain(SESSION_KEYCHAIN_SERVICE) + if session: + attempts.append(("sessionKey", f"https://claude.ai{path}", { + "Cookie": f"sessionKey={session}", "User-Agent": BROWSER_UA, "Accept": "application/json"})) + if state["balance_via"]: # сначала то, что уже срабатывало + attempts.sort(key=lambda a: a[0] != state["balance_via"]) + last = None + for name, url, headers in attempts: + try: + return _get_json(url, headers), name + except Exception as e: + last = e + raise last + + +async def usage_poller(): + loop = asyncio.get_running_loop() + while True: + try: + state["usage"] = await loop.run_in_executor(None, fetch_usage) + state["usage_error"], state["usage_updated"] = None, time.time() + except urllib.error.HTTPError as e: + state["usage_error"] = ("Токен истёк — запустите claude, он обновит токен" + if e.code == 401 else f"Лимиты: API ответил HTTP {e.code}") + except FileNotFoundError: + state["usage_error"] = "Не найден токен Claude Code — выполните claude login" + except Exception as e: + state["usage_error"] = f"Лимиты: {e}" + await asyncio.sleep(USAGE_REFRESH_SEC) + + +async def balance_poller(): + loop = asyncio.get_running_loop() + while True: + if SHOW_BALANCE: + try: + state["balance"], state["balance_via"] = await loop.run_in_executor(None, fetch_balance) + state["balance_error"] = None + except urllib.error.HTTPError as e: + state["balance_error"] = (f"нет доступа (HTTP {e.code}) — нужен sessionKey" + if e.code in (401, 403) else f"HTTP {e.code}") + except Exception as e: + state["balance_error"] = str(e) + await asyncio.sleep(BALANCE_REFRESH_SEC) + + +def parse_limits(data: dict): + """[(подпись, used%, resets_at, severity)] из массива `limits` (его рисует Desktop); + для старого формата ответа — из five_hour / seven_day.""" + rows = [] + for lim in data.get("limits") or []: + scope = lim.get("scope") or {} + model = (scope.get("model") or {}).get("display_name") + surface = scope.get("surface") + if isinstance(surface, dict): + surface = surface.get("display_name") + scoped = model or surface + label = KIND_TITLES.get(lim.get("kind", "")) or (f"Неделя · {scoped}" if scoped else lim.get("kind", "")) + rows.append((label, float(lim.get("percent") or 0), lim.get("resets_at"), lim.get("severity"))) + if rows: + return rows + for key, label in (("five_hour", "Сессия · 5 ч"), ("seven_day", "Неделя · все модели")): + w = data.get(key) + if isinstance(w, dict) and w.get("utilization") is not None: + rows.append((label, float(w["utilization"]), w.get("resets_at"), None)) + return rows + + +def parse_balance(b): + """(баланс, валюта, пополнено в активных траншах, дата последнего пополнения).""" + if not isinstance(b, dict): + return None + m = (b.get("balance") or {}).get("money") + if m: + amount, cur = m["amount_minor"] / 10 ** m.get("exponent", 2), m.get("currency", "") + elif b.get("amount") is not None: + amount, cur = b["amount"] / 100, b.get("currency", "") + else: + return None + granted, last = 0.0, None + for t in (b.get("tranches") or []) + (b.get("promo_tranches") or []): + g = (t.get("granted") or {}).get("money") + if g: + granted += g["amount_minor"] / 10 ** g.get("exponent", 2) + if t.get("granted_at") and (last is None or t["granted_at"] > last): + last = t["granted_at"] + return amount, cur, granted or None, last + + +def fmt_reset(value) -> str: + if not value: + return "" + try: + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return "" + sec = int((dt - datetime.now(timezone.utc)).total_seconds()) + if sec <= 0: + return "сбрасывается" + if sec >= 86400: + return dt.astimezone().strftime("%a %H:%M") + h, sec = divmod(sec, 3600) + m = sec // 60 + return " ".join(([f"{h} ч"] if h else []) + ([f"{m} мин"] if m else [])) or "<1 мин" + + +def money(v: float, cur: str) -> str: + return f"{CURRENCY_SIGNS.get(cur, cur + ' ')}{v:,.2f}".replace(",", " ") + + +def render_limits() -> str: + data = state["usage"] or {} + parts = [] + if state["usage_error"]: + parts.append(f'
{html.escape(state["usage_error"])}
') + for label, used, resets, severity in parse_limits(data): + used = max(0.0, min(100.0, used)) + lvl = ("crit" if severity == "critical" or (severity is None and used >= 90) else + "warn" if severity == "warning" or (severity is None and used >= 75) else "") + reset = fmt_reset(resets) + parts.append(f""" +
+
{html.escape(label)} + {used:.0f}%
+
+ {f'
сброс {html.escape(reset)}
' if reset else ''} +
""") + if SHOW_BREAKDOWN: + rows = (data.get("seven_day_breakdown") or {}).get("rows") or [] + items = [f"{html.escape(r.get('display_name') or r.get('key', ''))} {r.get('percent', 0)}%" + for r in rows if r.get("percent")] + if items: + parts.append(f'
{" · ".join(items)}
') + if SHOW_BALANCE: + bal = parse_balance(state["balance"]) + if bal: + amount, cur, granted, last = bal + sub = ([f"пополнено {money(granted, cur)}"] if granted else []) + ( + [datetime.fromisoformat(last.replace("Z", "+00:00")).astimezone().strftime("%d.%m")] + if last else []) + parts.append(f""" +
+
Баланс + {money(amount, cur)}
+ {f'
{html.escape(" · ".join(sub))}
' if sub else ''} +
""") + elif state["balance_error"]: + parts.append(f'
Баланс' + f'—
' + f'{html.escape(state["balance_error"])}
') + if not parts: + parts.append('
Лимиты: загрузка…
') + 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}
') + 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 + + +def known_roots(): + roots = set() + def walk(g): + if g: + roots.add(g["root"]) + for s in state["tree"]: + walk(s.get("git")) + for a in s["agents"]: + walk(a.get("git")) + return roots + + +_last_open = {} # path -> time последнего открытия (защита от двойных запросов) + + +async def open_worktree(uuid, path): + """Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер. + + Команда и папка задаются настройками новой панели, а не вводом текста в оболочку: + так не возникает гонки «текст отправлен раньше, чем оболочка готова», + и панель закрывается сама, когда выходите из lf.""" + if path not in known_roots() or not os.path.isdir(path): + return # открываем только папки, которые панель сама показала + now = time.time() + if now - _last_open.get(path, 0) < 3: + return # повторный клик/запрос — уже открываем + _last_open[path] = now + 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(): + return {s["uuid"] for s in state["tree"] if s.get("uuid")} + + +def _tmux_target(target): + sock, _, pane = target[5:].partition("|") + return sock, pane + + +def _tmux(sock, *args): + tmux = _which("tmux") + if tmux: + subprocess.run([tmux, "-S", sock, *args], capture_output=True, timeout=5) + + +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) + + +async def refresher(connection): + last_cleanup = 0 + while True: + try: + load_config() + live = await live_sessions(connection) if connection is not None else None + loop = asyncio.get_running_loop() + sessions = await loop.run_in_executor(None, load_sessions) + alive_now = [x for x in sessions if not x["ended"]] + procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now]) + tmux = await loop.run_in_executor( + None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]}) + state["tree"] = build_tree(sessions, live, procs, tmux) + state["error"] = None + if time.time() - last_cleanup > 3600: + cleanup_logs() + last_cleanup = time.time() + except Exception as e: + state["error"] = f"Ошибка: {e}" + await asyncio.sleep(POLL_SEC) + + +def cleanup_logs(): + for path in glob.glob(os.path.join(LOG_DIR, "*.jsonl")): + try: + if time.time() - os.path.getmtime(path) > KEEP_LOGS_SEC: + os.remove(path) + except OSError: + 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): + try: + request = (await reader.read(2048)).decode("latin-1") + target = request.split(" ", 2)[1] if request.count(" ") >= 2 else "/" + url = urllib.parse.urlparse(target) + ctype = "text/html; charset=utf-8" + if url.path == "/limits": + body = render_limits() + elif url.path == "/fragment": + body = render_tree() + elif url.path == "/open-wt": + q = urllib.parse.parse_qs(url.query) + await open_worktree(q.get("id", [""])[0], q.get("path", [""])[0]) + body = "ok" + elif url.path == "/message": + q = urllib.parse.parse_qs(url.query) + body = json.dumps(message_payload(q.get("sid", [""])[0], q.get("aid", [""])[0], + q.get("after", [""])[0]), ensure_ascii=False) + ctype = "application/json; charset=utf-8" + elif url.path == "/activate": + uuid = urllib.parse.parse_qs(url.query).get("id", [""])[0] + await activate(uuid) + 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"), + "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 + glob.glob(os.path.join(LOG_DIR, "*.jsonl"))), + "error": state["error"], "debug": state.get("debug"), + "tree": state["tree"]}, + ensure_ascii=False, indent=2, default=str) + ctype = "application/json; charset=utf-8" + else: + body = render_page() + data = body.encode("utf-8") + writer.write( + f"HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nCache-Control: no-store\r\n" + f"Connection: close\r\nContent-Length: {len(data)}\r\n\r\n".encode() + data + ) + await writer.drain() + finally: + writer.close() + + +async def main(connection): + 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)) + await iterm2.tool.async_register_web_view_tool( + connection, + display_name="Claude", + identifier="com.andrey.claude-agents", + 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) diff --git a/files/config.example.json b/files/config.example.json new file mode 100644 index 0000000..23186c9 --- /dev/null +++ b/files/config.example.json @@ -0,0 +1,18 @@ +{ + "file_manager": "lf", + "agent_dir": "changed", + "done_linger_sec": 20, + "agent_idle_min": 15, + "poll_sec": 1.5, + "keep_logs_days": 2, + "use_profile_theme": true, + "port": 8766, + "context_warn": 500000, + "context_crit": 700000, + "history_max_chars": 30000, + "usage_refresh_sec": 120, + "balance_refresh_sec": 600, + "show_breakdown": true, + "show_balance": true, + "theme_variant": "auto" +} diff --git a/files/settings_hooks.py b/files/settings_hooks.py new file mode 100755 index 0000000..5309f09 --- /dev/null +++ b/files/settings_hooks.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Добавляет или убирает хуки панели в ~/.claude/settings.json, не трогая остальное. + + settings_hooks.py add + settings_hooks.py remove +""" +import json +import os +import sys + +MARK = "agents_panel_hook" +COMMAND = 'sh "$HOME/.claude/hooks/agents_panel_hook.sh"' +EVENTS = { # событие -> matcher (None — без фильтра) + "SessionStart": None, "SessionEnd": None, "UserPromptSubmit": None, "Stop": None, + "Notification": None, "SubagentStart": None, "SubagentStop": None, + "PostModelSwitch": None, "PreToolUse": "Agent|Task", +} + + +def ours(hook): + return isinstance(hook, dict) and MARK in str(hook.get("command", "")) + + +def add(cfg): + hooks = cfg.setdefault("hooks", {}) + added = [] + for event, matcher in EVENTS.items(): + groups = hooks.setdefault(event, []) + if any(ours(h) for g in groups for h in (g.get("hooks") or [])): + continue # уже стоит — повторная установка ничего не дублирует + group = {"hooks": [{"type": "command", "command": COMMAND}]} + if matcher: + group = {"matcher": matcher, **group} + groups.append(group) + added.append(event) + return added + + +def remove(cfg): + hooks = cfg.get("hooks") or {} + removed = [] + for event in list(hooks): + groups = [] + for g in hooks[event]: + kept = [h for h in (g.get("hooks") or []) if not ours(h)] + if len(kept) != len(g.get("hooks") or []): + removed.append(event) + if kept: + groups.append({**g, "hooks": kept}) + if groups: + hooks[event] = groups + else: + del hooks[event] + if "hooks" in cfg and not cfg["hooks"]: + del cfg["hooks"] + return sorted(set(removed)) + + +def main(): + action, path = sys.argv[1], os.path.expanduser(sys.argv[2]) + cfg = {} + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + text = f.read().strip() + cfg = json.loads(text) if text else {} + changed = add(cfg) if action == "add" else remove(cfg) + if changed: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(tmp, path) + print(", ".join(changed) if changed else "—") + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9cdebef --- /dev/null +++ b/install.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Установка панели «Claude» для iTerm2 Toolbelt: дерево сессий и агентов Claude Code, +# лимиты подписки и баланс. Повторный запуск безопасен — обновляет установленное. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +FILES="$HERE/files" +AUTOLAUNCH="$HOME/Library/Application Support/iTerm2/Scripts/AutoLaunch" +HOOKS_DIR="$HOME/.claude/hooks" +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 + +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/"; } + +# ---------- проверки ---------- +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)" +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 + +# ---------- хук Claude Code ---------- +say "Хук Claude Code" +mkdir -p "$HOOKS_DIR" +install -m 755 "$FILES/agents_panel_hook.sh" "$HOOKS_DIR/agents_panel_hook.sh" +ok "$HOOKS_DIR/agents_panel_hook.sh" +[ -f "$SETTINGS" ] && backup "$SETTINGS" +added=$(python3 "$FILES/settings_hooks.py" add "$SETTINGS") \ + || die "не смог обновить $SETTINGS (битый JSON?) — копия в $BACKUP_DIR" +if [ "$added" = "—" ]; then ok "settings.json: хуки уже на месте" +else ok "settings.json: добавлены хуки $added (копия старого — в $BACKUP_DIR)"; fi + +# ---------- скрипт iTerm2 ---------- +say "Скрипт iTerm2" +mkdir -p "$AUTOLAUNCH" +if [ -f "$AUTOLAUNCH/claude_usage.py" ]; then # прежняя отдельная панель лимитов + backup "$AUTOLAUNCH/claude_usage.py" && rm "$AUTOLAUNCH/claude_usage.py" + ok "старый claude_usage.py убран из AutoLaunch (копия в $BACKUP_DIR)" +fi +[ -f "$AUTOLAUNCH/claude_agents.py" ] && backup "$AUTOLAUNCH/claude_agents.py" +install -m 644 "$FILES/claude_agents.py" "$AUTOLAUNCH/claude_agents.py" +ok "$AUTOLAUNCH/claude_agents.py" + +# ---------- настройки панели ---------- +mkdir -p "$CONFIG_DIR" +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 + +# ---------- Python API в iTerm2 ---------- +say "iTerm2" +iterm_running=0 +pgrep -xq iTerm2 2>/dev/null && iterm_running=1 +api=$(defaults read com.googlecode.iterm2 EnableAPIServer 2>/dev/null || echo 0) +if [ "$api" != 1 ]; then + if [ $iterm_running = 0 ] && command -v defaults >/dev/null; then + defaults write com.googlecode.iterm2 EnableAPIServer -bool true + ok "Python API включён" + else + warn "включите Python API: iTerm2 → Settings → General → Magic → Enable Python API" + [ $iterm_running = 1 ] && warn "(пока iTerm2 запущен, установщик не меняет его настройки — он бы их перезаписал)" + fi +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 + if osascript -e 'tell application "iTerm2" to launch API script named "claude_agents"' >/dev/null 2>&1; then + ok "панель запущена" + else + warn "не смог запустить сам — Scripts → AutoLaunch → claude_agents.py" + fi +else + warn "панель запустится сама при следующем запуске iTerm2" +fi + +cat </dev/null; then + pids=$(lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true) + [ -n "$pids" ] && kill $pids 2>/dev/null && ok "панель остановлена" +fi + +rm -f "$AUTOLAUNCH/claude_agents.py" && ok "скрипт убран из AutoLaunch" + +if [ -f "$SETTINGS" ]; then + mkdir -p "$BACKUP_DIR" && cp -p "$SETTINGS" "$BACKUP_DIR/" + removed=$(python3 "$HERE/files/settings_hooks.py" remove "$SETTINGS") + ok "settings.json: убраны хуки ${removed} (копия — в $BACKUP_DIR)" +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" + ok "настройки и логи удалены" +else + echo " Настройки (~/.config/claude-agents) и логи (~/.cache/claude-agents) оставлены; --purge удалит и их." +fi +echo "Панель «Claude» исчезнет из меню Toolbelt после перезапуска iTerm2."