#!/usr/bin/env python3 """ Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает iTerm2 Toolbelt. Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов — название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии. Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage. Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов. Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd). Настройки (необязательно): ~/.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 shutil import socket import subprocess import sys 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" # что открывать в новой панели tmux по клику на worktree # лимиты подписки и баланс (нижняя часть панели) 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), "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}" # Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91", "green": "#859900", "yellow": "#b58900", "red": "#dc322f", "font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13} state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None, "usage": None, "usage_error": None, "usage_updated": None, "balance": None, "balance_error": None, "balance_via": 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) # Фон панели выбирается на клиенте (⚙ внизу) и хранится в 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'' for c in BG_PRESETS) return (f'
{swatches}' f'' f'сброс
' f'⚙
') def render_page() -> str: t = state["theme"] return f"""
{render_tree()}
{render_limits()}
{render_bgctl()}
↗ вкладка ✕
""" # ---------- лимиты подписки и баланс ---------- 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}' f'
') return "".join(parts) # ---------- действия по клику (через tmux) ---------- 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): """Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер. Команда передаётся tmux при создании панели, а не вводом текста в оболочку: так не возникает гонки «текст отправлен раньше, чем оболочка готова», и панель закрывается сама, когда выходите из 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 # повторный клик/запрос — уже открываем _last_open[path] = now state["open_log"] = (state.get("open_log") or [])[-9:] + [ {"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}] sock, pane = _tmux_target(uuid) cmd = f"exec {FILE_MANAGER}" await asyncio.get_running_loop().run_in_executor( None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd)) 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() 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(): last_cleanup = 0 while True: try: load_config() loop = asyncio.get_running_loop() sessions = await loop.run_in_executor(None, load_sessions) alive_now = [x for x in sessions if not x["ended"]] procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now]) tmux = await loop.run_in_executor( None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]}) state["tree"] = build_tree(sessions, None, procs, tmux) state["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 # ---------- 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) 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() 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"], "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() # ---------- запуск ---------- # Два режима одного файла: служба 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) 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=SERVICE_LABEL, reveal_if_already_registered=False, url=f"http://127.0.0.1:{PORT}/", ) def run(argv): if "--headless" in argv: asyncio.run(serve()) elif iterm2 is not None: iterm2.run_forever(register_toolbelt) else: raise SystemExit("нужен iTerm2 или флаг --headless") if __name__ == "__main__": run(sys.argv[1:])