feat: панель как фоновая служба без iTerm2 + цвет фона на клиенте #5

Merged
aep merged 16 commits from feat/4-headless-panel into main 2026-10-18 21:58:14 +03:00
4 changed files with 123 additions and 139 deletions
Showing only changes of commit 4686cd1a8f - Show all commits

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.superpowers/
.bounded
.DS_Store
__pycache__/

View File

@ -1,22 +1,19 @@
#!/usr/bin/env python3
"""
Claude — панель для iTerm2 Toolbelt.
Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает
iTerm2 Toolbelt.
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии.
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии.
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в
~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов.
Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их
не сообщил, берутся из транскриптов.
Установка:
1. Хук: скопировать agents_panel_hook.sh в ~/.claude/hooks/ и добавить блок
"hooks" из settings-hooks.json в ~/.claude/settings.json
2. Панель: этот файл — в ~/Library/Application Support/iTerm2/Scripts/AutoLaunch/
3. Toolbelt → Claude (старый скрипт лимитов claude_usage.py из AutoLaunch удалить)
Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd).
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта).
Изменения подхватываются на лету, кроме port (нужен перезапуск службы).
Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
Только стандартная библиотека.
@ -27,7 +24,6 @@ import html
import json
import os
import re
import shlex
import shutil
import subprocess
import time
@ -58,9 +54,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого
CONTEXT_CRIT = 700_000 # … и красное
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree
USE_PROFILE_THEME = True
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree
# лимиты подписки и баланс (нижняя часть панели)
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
@ -78,8 +72,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме
"keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400),
"agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"),
"file_manager": ("FILE_MANAGER", str),
"use_profile_theme": ("USE_PROFILE_THEME", bool),
"theme_variant": ("THEME_VARIANT", lambda v: v if v in ("auto", "dark", "light") else "auto"),
"context_warn": ("CONTEXT_WARN", int),
"context_crit": ("CONTEXT_CRIT", int),
"history_max_chars": ("HISTORY_MAX_CHARS", int),
@ -112,14 +104,14 @@ def load_config():
state["config_error"] = None
except Exception as e:
state["config_error"] = f"config.json: {e}"
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90",
# Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой
DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
"font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12}
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}
state = {"theme": dict(DEFAULT_THEME), "live": None, "tree": [], "error": None,
state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None,
"usage": None, "usage_error": None, "usage_updated": None,
"balance": None, "balance_error": None, "balance_via": None}
_app = None
_server = None
@ -1248,27 +1240,7 @@ def render_limits() -> str:
return "".join(parts)
# ---------- связь с iTerm2 ----------
async def live_sessions(connection):
global _app
if _app is None:
_app = await iterm2.async_get_app(connection)
live = {}
for w in _app.terminal_windows:
for tab in w.tabs:
for sess in tab.sessions:
pane = None
try:
name = await sess.async_get_variable("name")
job = await sess.async_get_variable("jobName")
pane = await sess.async_get_variable("tmuxWindowPane") # tmux -CC: номер панели
except Exception:
name, job = sess.name, ""
live[sess.session_id] = {"name": name or "", "job": job or "",
"tmux_pane": f"%{pane}" if pane is not None else None}
return live
# ---------- действия по клику (через tmux) ----------
def known_roots():
roots = set()
@ -1286,13 +1258,15 @@ _last_open = {} # path -> time последнего открытия (защи
async def open_worktree(uuid, path):
"""Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер.
"""Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер.
Команда и папка задаются настройками новой панели, а не вводом текста в оболочку:
Команда передаётся tmux при создании панели, а не вводом текста в оболочку:
так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
и панель закрывается сама, когда выходите из lf."""
и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются."""
if path not in known_roots() or not os.path.isdir(path):
return # открываем только папки, которые панель сама показала
if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()):
return
now = time.time()
if now - _last_open.get(path, 0) < 3:
return # повторный клик/запрос — уже открываем
@ -1300,28 +1274,10 @@ async def open_worktree(uuid, path):
state["open_log"] = (state.get("open_log") or [])[-9:] + [
{"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}]
if uuid and uuid.startswith("tmux:") and uuid in known_targets():
sock, pane = _tmux_target(uuid)
cmd = f"exec {FILE_MANAGER}"
await asyncio.get_running_loop().run_in_executor(
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd))
return
if _app is None:
return
sess = _app.get_session_by_id(uuid) if uuid and not uuid.startswith("tmux:") else None
if sess is None:
w = _app.current_terminal_window
sess = w.current_tab.current_session if w else None
if sess is None:
return
prof = iterm2.LocalWriteOnlyProfile()
prof.set_use_custom_command("Yes")
# через login-оболочку, чтобы PATH был как обычно (Homebrew и т.п.)
shell = os.environ.get("SHELL") or "/bin/zsh"
prof.set_command(f"{shell} -lc {shlex.quote('cd ' + shlex.quote(path) + ' && exec ' + FILE_MANAGER)}")
new = await sess.async_split_pane(vertical=True, profile_customizations=prof)
if new is not None:
await new.async_activate()
sock, pane = _tmux_target(uuid)
cmd = f"exec {FILE_MANAGER}"
await asyncio.get_running_loop().run_in_executor(
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd))
def known_targets():
@ -1340,41 +1296,27 @@ def _tmux(sock, *args):
async def activate(uuid):
if uuid not in known_targets():
return # только то, что панель сама показала
twin = (state.get("tmux_twins") or {}).get(uuid)
if twin and _app is not None: # tmux -CC — переключаемся средствами iTerm2: вкладка и окно спереди
sess = _app.get_session_by_id(twin)
if sess:
await sess.async_activate(select_tab=True, order_window_front=True)
return
if uuid.startswith("tmux:"):
sock, pane = _tmux_target(uuid)
loop = asyncio.get_running_loop()
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
("select-pane", "-t", pane)):
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a))
return
if _app is None:
return
sess = _app.get_session_by_id(uuid)
if sess:
await sess.async_activate(select_tab=True, order_window_front=True)
if uuid not in known_targets() or not uuid.startswith("tmux:"):
return # только сессии в tmux, которые панель сама показала
sock, pane = _tmux_target(uuid)
loop = asyncio.get_running_loop()
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
("select-pane", "-t", pane)):
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a))
async def refresher(connection):
async def refresher():
last_cleanup = 0
while True:
try:
load_config()
live = await live_sessions(connection) if connection is not None else None
loop = asyncio.get_running_loop()
sessions = await loop.run_in_executor(None, load_sessions)
alive_now = [x for x in sessions if not x["ended"]]
procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now])
tmux = await loop.run_in_executor(
None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]})
state["tree"] = build_tree(sessions, live, procs, tmux)
state["tree"] = build_tree(sessions, None, procs, tmux)
state["error"] = None
if time.time() - last_cleanup > 3600:
cleanup_logs()
@ -1393,49 +1335,6 @@ def cleanup_logs():
pass
def _hex(c) -> str:
return "#%02x%02x%02x" % (round(c.red), round(c.green), round(c.blue))
def _mix(c1, c2, k: float) -> str:
return "#%02x%02x%02x" % tuple(round(a * k + b * (1 - k)) for a, b in
[(c1.red, c2.red), (c1.green, c2.green), (c1.blue, c2.blue)])
async def theme_poller(connection):
"""Цвета и шрифт — из профиля текущей сессии. Если в профиле включены отдельные цвета
для светлой и тёмной темы, берётся вариант, который iTerm2 сейчас показывает."""
while True:
try:
app = _app or await iterm2.async_get_app(connection)
p = await app.current_terminal_window.current_tab.current_session.async_get_profile()
suffix = ""
if getattr(p, "use_separate_colors_for_light_and_dark_mode", False):
mode = [THEME_VARIANT] if THEME_VARIANT != "auto" else await app.async_get_theme()
suffix = " (Dark)" if "dark" in mode else " (Light)"
def color(key):
return p.get_color_with_key(key + suffix) or p.get_color_with_key(key)
theme = dict(DEFAULT_THEME)
bg, fg = color("Background Color"), color("Foreground Color")
if bg is not None and fg is not None:
theme.update(bg=_hex(bg), fg=_hex(fg), dim=_mix(fg, bg, 0.6))
for name, idx in (("red", 1), ("green", 2), ("yellow", 3)):
c = color(f"Ansi {idx} Color")
if c is not None:
theme[name] = _hex(c)
font = (p.normal_font or "").rsplit(" ", 1)
if len(font) == 2:
theme["font"] = f"'{font[0].split('-')[0]}', '{font[0]}', ui-monospace, monospace"
theme["size"] = min(int(float(font[1])), 13)
theme["source"] = f"profile {p.name!r}{suffix or ''}"
state["theme"] = theme
except Exception as e:
state["theme_error"] = str(e)
await asyncio.sleep(30)
# ---------- HTTP ----------
async def handle(reader, writer):
@ -1463,7 +1362,7 @@ async def handle(reader, writer):
body = "ok"
elif url.path == "/raw":
body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"),
"theme": state["theme"], "theme_error": state.get("theme_error"),
"theme": state["theme"],
"usage_error": state["usage_error"], "balance_via": state["balance_via"],
"balance_error": state["balance_error"],
"log_files": sorted(os.path.basename(p) for p in
@ -1488,11 +1387,9 @@ async def main(connection):
global _server
load_config()
_server = await asyncio.start_server(handle, "127.0.0.1", PORT)
asyncio.create_task(refresher(connection))
asyncio.create_task(refresher())
asyncio.create_task(usage_poller())
asyncio.create_task(balance_poller())
if USE_PROFILE_THEME:
asyncio.create_task(theme_poller(connection))
await iterm2.tool.async_register_web_view_tool(
connection,
display_name="Claude",

View File

@ -5,7 +5,6 @@
"agent_idle_min": 15,
"poll_sec": 1.5,
"keep_logs_days": 2,
"use_profile_theme": true,
"port": 8766,
"context_warn": 500000,
"context_crit": 700000,
@ -13,6 +12,5 @@
"usage_refresh_sec": 120,
"balance_refresh_sec": 600,
"show_breakdown": true,
"show_balance": true,
"theme_variant": "auto"
"show_balance": true
}

88
tests/test_core.py Normal file
View File

@ -0,0 +1,88 @@
import asyncio
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
import claude_agents as ca # noqa: E402
class DefaultTheme(unittest.TestCase):
def test_default_theme_matches_chat_profile(self):
self.assertEqual(ca.DEFAULT_THEME, {
"bg": "#002b36", "fg": "#aacace", "dim": "#668a91",
"green": "#859900", "yellow": "#b58900", "red": "#dc322f",
"font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13})
class NoITermCoupling(unittest.TestCase):
def test_iterm_coupling_removed(self):
for name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT",
"_app", "_mix", "_hex"):
self.assertFalse(hasattr(ca, name), name)
self.assertNotIn("use_profile_theme", ca.CONFIG_KEYS)
self.assertNotIn("theme_variant", ca.CONFIG_KEYS)
class Config(unittest.TestCase):
def setUp(self):
self.saved = (ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime)
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime = self.saved
self.tmp.cleanup()
def test_old_theme_keys_ignored(self):
path = os.path.join(self.tmp.name, "config.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"use_profile_theme": False, "theme_variant": "dark", "poll_sec": 2}, f)
ca.CONFIG_PATH, ca._config_mtime = path, None
ca.load_config()
self.assertIsNone(ca.state.get("config_error"))
self.assertEqual(ca.POLL_SEC, 2.0)
class Actions(unittest.TestCase):
def setUp(self):
self.calls = []
self.saved = (ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"])
ca._tmux = lambda sock, *args: self.calls.append((sock, *args))
ca._last_open.clear()
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"] = self.saved
self.tmp.cleanup()
def test_activate_tmux_target(self):
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
asyncio.run(ca.activate("tmux:/tmp/s|%3"))
self.assertEqual(self.calls, [("/tmp/s", "switch-client", "-t", "%3"),
("/tmp/s", "select-window", "-t", "%3"),
("/tmp/s", "select-pane", "-t", "%3")])
def test_activate_non_tmux_is_noop(self):
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
asyncio.run(ca.activate("w0t0p0:ABC"))
self.assertEqual(self.calls, [])
def test_open_worktree_non_tmux_is_noop(self):
ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}]
ca.known_roots = lambda: {self.tmp.name}
asyncio.run(ca.open_worktree("w0t0p0:ABC", self.tmp.name))
self.assertEqual(self.calls, [])
def test_open_worktree_tmux_splits(self):
ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}]
ca.known_roots = lambda: {self.tmp.name}
ca.FILE_MANAGER = "lf"
asyncio.run(ca.open_worktree("tmux:/tmp/s|%3", self.tmp.name))
self.assertEqual(self.calls, [("/tmp/s", "split-window", "-h", "-t", "%3",
"-c", self.tmp.name, "exec lf")])
if __name__ == "__main__":
unittest.main()