cc-agents-iterm-plugin/files/claude_agents.py
Andrey Epifancev 4686cd1a8f refactor: drop live iTerm2 coupling from panel server (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:53:36 +04:00

1404 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 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" # что открывать в новой панели 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'<span class="tok{level}" title="{tip}">{fmt_tokens(t["all"])} tokens</span>'
# ---------- разбор событий ----------
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"<system-reminder>.*?</system-reminder>", re.S)
_COMMAND = re.compile(r"<command-name>(.*?)</command-name>.*?(?:<command-args>(.*?)</command-args>)?", 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(("<local-command", "<bash-", "<task-notification")):
return None
return text or None
def recent_messages(path, skip_sidechain=False, limit=30):
"""Последние текстовые реплики транскрипта по порядку: от человека (или родителя-агента)
и от модели. У каждой есть key — по нему окно понимает, какие сообщения уже показаны."""
if not path or not os.path.exists(path):
return []
try:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
f.seek(max(0, size - 2 * 1024 * 1024))
lines = f.read().decode("utf-8", "replace").splitlines()
if size > 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"<pre>{html.escape(part.rstrip())}</pre>")
continue
if i and part.startswith("\n"):
part = part[1:] # перевод строки сразу после блока кода дал бы пустую строку
h = html.escape(part)
h = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", h)
h = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", h)
h = re.sub(r"(?m)^#{1,6}\s+(.+)$", r'<b class="h">\1</b>', 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'<span class="wt" data-path="{html.escape(g["root"])}" data-id="{html.escape(uuid or "")}"'
f' title="Открыть {html.escape(FILE_MANAGER)} в {html.escape(g["root"])}">⎇ {label}</span>')
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 = ['<div class="children">']
for a in kids:
color = STATUS.get(a["status"], "dim")
model = html.escape(a["model_label"])
model_html = f'<span class="m{" inh" if a["inherited"] else ""}">{model}</span>' 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"""
<div class="agent {a['status']}{' has-kids' if any(x.get('parent') == a['id'] for x in agents) else ''}" data-aid="{html.escape(a['id'])}">
<div class="row"><span class="dot {color}">●</span>
<span class="name" title="{desc or atype}">{desc or atype}</span>{model_html}</div>
{f'<div class="meta">{meta}</div>' if meta else ''}
{render_agents(agents, a["id"], uuid, (g or {}).get("root") or parent_root)}
</div>""")
out.append("</div>")
return "".join(out)
def render_tree() -> str:
if state.get("config_error"):
return f'<div class="err">{html.escape(state["config_error"])}</div>' + _render_tree()
return _render_tree()
def _render_tree() -> str:
if state["error"]:
return f'<div class="err">{html.escape(state["error"])}</div>'
if not state["tree"]:
return '<div class="empty">Нет активных сессий Claude Code</div>'
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"""
<div class="session{' has-kids' if any(not a.get('parent') for a in s['agents']) else ''}" data-id="{html.escape(s['uuid'] or '')}" data-sid="{html.escape(s['id'])}">
<div class="row"><span class="dot {color}">●</span>
<span class="name">{html.escape(s['name'])}</span>
{f'<span class="m">{model}</span>' if model else ''}</div>
{f'<div class="meta">{meta}</div>' if meta else ''}
{render_agents(s['agents'], None, s['uuid'], (s.get('git') or {}).get('root'))}
</div>""")
return "".join(parts)
def render_page() -> str:
t = state["theme"]
return f"""<!doctype html><html><head><meta charset="utf-8">
<style>
:root {{ --bg:{t['bg']}; --fg:{t['fg']}; --dim:{t['dim']};
--green:{t['green']}; --yellow:{t['yellow']}; --red:{t['red']}; }}
html, body {{ background:var(--bg); color:var(--fg); }}
html, body {{ height:100%; }}
body {{ margin:0; font:{t['size']}px/1.35 {t['font']}; display:flex; flex-direction:column; }}
#root {{ flex:1; min-height:0; overflow-y:auto; padding:8px 10px; }}
#limits {{ flex:none; max-height:50%; overflow-y:auto; padding:8px 10px 10px;
border-top:1px solid color-mix(in srgb, var(--fg) 18%, transparent); }}
::-webkit-scrollbar {{ width:6px; height:6px; }}
::-webkit-scrollbar-track {{ background:transparent; }}
::-webkit-scrollbar-thumb {{ background:color-mix(in srgb, var(--fg) 25%, transparent); border-radius:3px; }}
.lim {{ margin-bottom:7px; }}
.lim .row .name {{ font-weight:400; }}
.pct, .money {{ font-variant-numeric:tabular-nums; white-space:nowrap; }}
.money {{ font-weight:700; }}
.pct.warn {{ color:var(--yellow); }} .pct.crit {{ color:var(--red); }}
.bar {{ height:4px; border-radius:2px; margin:3px 0 2px; overflow:hidden;
background:color-mix(in srgb, var(--fg) 15%, transparent); }}
.fill {{ height:100%; border-radius:2px; background:var(--green); }}
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
.sub {{ color:var(--dim); }}
.split {{ margin:-2px 0 7px; }}
.foot {{ margin-top:2px; opacity:.8; }}
.foot.stale {{ color:var(--yellow); }}
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }}
.row {{ display:flex; align-items:baseline; gap:6px; }}
.row .name {{ font-weight:700; }}
.agent .row .name {{ font-weight:400; }}
.name {{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
.m {{ color:var(--dim); white-space:nowrap; }}
.m.inh {{ opacity:.7; }}
.tok {{ cursor:help; }}
.tok.warn {{ color:var(--yellow); }}
.tok.crit {{ color:var(--red); }}
.wt {{ cursor:pointer; text-decoration:underline dotted; text-underline-offset:2px; }}
.wt:hover {{ color:var(--fg); }}
.meta, .detail {{ color:var(--dim); padding-left:18px; }}
.detail {{ overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }}
/* дерево: линии идут от центра точки родителя к точкам детей (├─ └─) */
:root {{ --line:color-mix(in srgb, var(--fg) 28%, transparent); --mid:calc(3px + .675em); }}
.children {{ margin-left:calc(.5ch - .5px); }}
.children > .agent {{ position:relative; padding:3px 0 0 16px; }}
.children > .agent::before {{ content:""; position:absolute; left:0; top:0; bottom:0;
border-left:1px solid var(--line); }}
.children > .agent:last-child::before {{ bottom:auto; height:var(--mid); }}
.children > .agent::after {{ content:""; position:absolute; left:0; top:var(--mid); width:12px;
border-top:1px solid var(--line); }}
/* от точки родителя вниз, мимо строки с токенами, до первого ребёнка */
.has-kids > .row {{ background-image:linear-gradient(var(--line), var(--line));
background-repeat:no-repeat; background-size:1px 50%;
background-position:calc(.5ch - .5px) 100%; }}
.has-kids > .meta {{ background-image:linear-gradient(var(--line), var(--line));
background-repeat:no-repeat; background-size:1px 100%;
background-position:calc(.5ch - .5px) 0; }}
.agent.done > .row, .agent.done > .meta {{ opacity:.55; }}
.dot.green {{ color:var(--green); }} .dot.yellow {{ color:var(--yellow); }}
.dot.dim {{ color:var(--dim); }} .dot.red {{ color:var(--red); }}
.empty, .err {{ color:var(--dim); padding:6px; }} .err {{ color:var(--red); }}
.agent > .row {{ cursor:pointer; border-radius:4px; }}
.agent > .row:hover {{ background-color:color-mix(in srgb, var(--fg) 8%, transparent); }}
#ov {{ position:fixed; inset:0; background:var(--bg); display:none; flex-direction:column;
padding:8px 10px; box-sizing:border-box; z-index:10; }}
#ov.open {{ display:flex; }}
.ov-head {{ display:flex; gap:8px; align-items:baseline; }}
.ov-title {{ flex:1; min-width:0; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
.ov-btn {{ color:var(--dim); cursor:pointer; white-space:nowrap; }}
.ov-btn:hover {{ color:var(--fg); }}
.ov-meta {{ color:var(--dim); margin:2px 0 8px; }}
.ov-meta .user {{ color:var(--yellow); }} .ov-meta .assistant {{ color:var(--green); }}
.ov-body {{ flex:1; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere;
padding-top:8px; border-top:1px solid color-mix(in srgb, var(--fg) 18%, transparent); }}
.ov-body pre {{ white-space:pre; overflow-x:auto; margin:6px 0; padding:6px 8px; border-radius:4px;
background:color-mix(in srgb, var(--fg) 7%, transparent); }}
.ov-body code {{ background:color-mix(in srgb, var(--fg) 9%, transparent); border-radius:3px; padding:0 3px; }}
.ov-body .h {{ display:inline-block; margin-top:4px; }}
.msg + .msg {{ margin-top:10px; padding-top:8px;
border-top:1px dashed color-mix(in srgb, var(--fg) 18%, transparent); }}
.msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }}
.trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
</style></head><body>
<div id="root">{render_tree()}</div>
<div id="limits">{render_limits()}</div>
<div id="ov">
<div class="ov-head"><span class="ov-title"></span>
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
<span class="ov-btn" id="ov-x" title="Закрыть (Esc)">✕</span></div>
<div class="ov-meta"></div>
<div class="ov-body"></div>
</div>
<script>
async function refresh() {{
try {{
const r = await fetch('/fragment', {{cache: 'no-store'}});
if (r.ok) document.getElementById('root').innerHTML = await r.text();
}} catch (e) {{}}
}}
setInterval(refresh, {int(POLL_SEC * 1000)});
async function refreshLimits() {{
try {{
const r = await fetch('/limits', {{cache: 'no-store'}});
if (r.ok) document.getElementById('limits').innerHTML = await r.text();
}} catch (e) {{}}
}}
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
document.addEventListener('click', e => {{
const wt = e.target.closest('.wt');
if (wt) {{
e.stopPropagation();
fetch('/open-wt?id=' + encodeURIComponent(wt.dataset.id) +
'&path=' + encodeURIComponent(wt.dataset.path));
return;
}}
if (e.target.closest('#ov')) return;
const s = e.target.closest('.session');
if (!s) return;
if (e.metaKey || e.ctrlKey) {{ // ⌘-клик — сразу во вкладку
if (s.dataset.id) fetch('/activate?id=' + encodeURIComponent(s.dataset.id));
return;
}}
const a = e.target.closest('.agent');
if (a && !e.target.closest('.agent > .row, .agent > .meta')) return; // клик по пустому месту
openMsg(s.dataset.sid, a ? a.dataset.aid : '');
}});
// ---- сообщения: копятся, пока окно открыто ----
const ov = document.getElementById('ov');
const body = ov.querySelector('.ov-body');
let cur = null, msgs = [], trimmed = 0, busy = false;
const esc = t => t.replace(/[&<>"]/g, c => ({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}})[c]);
function draw(scrollToEnd) {{
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 30;
body.innerHTML = (trimmed ? `<div class="trimmed">ранние сообщения удалены: ${{trimmed}}</div>` : '') +
msgs.map(m => `<div class="msg"><div class="msg-h"><span class="${{m.role}}">${{esc(m.who)}}</span>` +
`${{m.time ? ' · ' + m.time : ''}}</div>${{m.html}}</div>`).join('');
if (scrollToEnd && atEnd) body.scrollTop = body.scrollHeight; // следим за новыми, если вы внизу
}}
async function loadMsg() {{
if (!cur || busy) return;
busy = true;
const opened = cur;
try {{
const last = msgs.length ? msgs[msgs.length - 1].key : '';
const r = await fetch('/message?sid=' + encodeURIComponent(cur.sid) + '&aid=' +
encodeURIComponent(cur.aid) + '&after=' + encodeURIComponent(last),
{{cache: 'no-store'}});
const m = await r.json();
if (cur !== opened) return; // окно успели закрыть или открыть другое
ov.querySelector('.ov-title').textContent = m.title || '';
cur.uuid = m.uuid || '';
document.getElementById('ov-tab').style.display = cur.uuid ? '' : 'none';
ov.querySelector('.ov-meta').textContent = m.error || '';
let changed = false;
for (const n of m.messages || []) {{
const i = msgs.findIndex(x => x.key === n.key);
if (i >= 0) {{ if (msgs[i].html !== n.html) {{ msgs[i] = n; changed = true; }} }}
else {{ msgs.push(n); changed = true; }}
}}
let total = msgs.reduce((s, x) => s + x.chars, 0);
while (msgs.length > 1 && total > (m.max_chars || 30000)) {{ // лимит символов
total -= msgs.shift().chars; trimmed++; changed = true;
}}
if (changed) draw(true);
}} catch (e) {{}} finally {{ busy = false; }}
}}
function openMsg(sid, aid) {{
cur = {{sid, aid}}; msgs = []; trimmed = 0;
body.innerHTML = ''; ov.querySelector('.ov-meta').textContent = '';
ov.classList.add('open');
loadMsg();
}}
function closeMsg() {{ cur = null; msgs = []; trimmed = 0; body.innerHTML = ''; ov.classList.remove('open'); }}
document.getElementById('ov-x').onclick = closeMsg;
document.getElementById('ov-tab').onclick = () => {{
if (cur && cur.uuid) fetch('/activate?id=' + encodeURIComponent(cur.uuid));
}};
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
setInterval(loadMsg, 2000);
// тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто)
setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
</script>
</body></html>"""
# ---------- лимиты подписки и баланс ----------
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'<div class="err">{html.escape(state["usage_error"])}</div>')
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"""
<div class="lim">
<div class="row"><span class="name">{html.escape(label)}</span>
<span class="pct {lvl}">{used:.0f}%</span></div>
<div class="bar"><div class="fill {lvl}" style="width:{used:.1f}%"></div></div>
{f'<div class="sub">сброс {html.escape(reset)}</div>' if reset else ''}
</div>""")
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'<div class="sub split">{" · ".join(items)}</div>')
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"""
<div class="lim">
<div class="row"><span class="name">Баланс</span>
<span class="money">{money(amount, cur)}</span></div>
{f'<div class="sub">{html.escape(" · ".join(sub))}</div>' if sub else ''}
</div>""")
elif state["balance_error"]:
parts.append(f'<div class="lim"><div class="row"><span class="name">Баланс</span>'
f'<span class="money">—</span></div><div class="sub">'
f'{html.escape(state["balance_error"])}</div></div>')
if not parts:
parts.append('<div class="sub">Лимиты: загрузка…</div>')
if state["usage_updated"]:
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>')
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)
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()
async def main(connection):
global _server
load_config()
_server = await asyncio.start_server(handle, "127.0.0.1", PORT)
asyncio.create_task(refresher())
asyncio.create_task(usage_poller())
asyncio.create_task(balance_poller())
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)