1404 lines
65 KiB
Python
1404 lines
65 KiB
Python
#!/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 => ({{'&':'&','<':'<','>':'>','"':'"'}})[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)
|