feat: headless panel server and Toolbelt registrar entry points (#4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 20:54:29 +04:00
parent 4686cd1a8f
commit 43a76eab19
2 changed files with 221 additions and 7 deletions

View File

@ -25,7 +25,9 @@ import json
import os import os
import re import re
import shutil import shutil
import socket
import subprocess import subprocess
import sys
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@ -1383,21 +1385,66 @@ async def handle(reader, writer):
writer.close() writer.close()
async def main(connection): # ---------- запуск ----------
# Два режима одного файла: служба launchd (--headless) держит порт и всё считает,
# скрипт AutoLaunch в iTerm2 только показывает её страницу в Toolbelt.
SERVICE_LABEL = "com.andrey.claude-agents" # метка LaunchAgent и идентификатор web view
async def serve():
"""Сервер панели без iTerm2: страница, дерево сессий, лимиты и баланс. Работает до отмены."""
global _server global _server
load_config() load_config()
_server = await asyncio.start_server(handle, "127.0.0.1", PORT) _server = await asyncio.start_server(handle, "127.0.0.1", PORT)
asyncio.create_task(refresher()) tasks = [asyncio.create_task(c) for c in (refresher(), usage_poller(), balance_poller())]
asyncio.create_task(usage_poller()) try:
asyncio.create_task(balance_poller()) async with _server:
await _server.serve_forever()
finally:
for t in tasks:
t.cancel()
def port_alive(port, host="127.0.0.1", timeout=0.5) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def kickstart_service():
"""Просит launchd запустить службу панели, если она не запущена."""
try:
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/{SERVICE_LABEL}"],
capture_output=True, timeout=10)
except Exception:
pass
async def register_toolbelt(connection):
"""Скрипт AutoLaunch: регистрирует web view со страницей службы; свой сервер не поднимает."""
load_config()
if not port_alive(PORT):
kickstart_service()
await iterm2.tool.async_register_web_view_tool( await iterm2.tool.async_register_web_view_tool(
connection, connection,
display_name="Claude", display_name="Claude",
identifier="com.andrey.claude-agents", identifier=SERVICE_LABEL,
reveal_if_already_registered=False, reveal_if_already_registered=False,
url=f"http://127.0.0.1:{PORT}/", url=f"http://127.0.0.1:{PORT}/",
) )
if __name__ == "__main__" and iterm2 is not None: def run(argv):
iterm2.run_forever(main) if "--headless" in argv:
asyncio.run(serve())
elif iterm2 is not None:
iterm2.run_forever(register_toolbelt)
else:
raise SystemExit("нужен iTerm2 или флаг --headless")
if __name__ == "__main__":
run(sys.argv[1:])

167
tests/test_service.py Normal file
View File

@ -0,0 +1,167 @@
import asyncio
import json
import os
import socket
import sys
import types
import unittest
import urllib.request
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files"))
import claude_agents as ca # noqa: E402
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def http_get(port, path):
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=3) as r:
return r.status, r.read().decode("utf-8")
class PortAlive(unittest.TestCase):
def test_port_alive(self):
s = socket.socket()
s.bind(("127.0.0.1", 0))
s.listen()
port = s.getsockname()[1]
self.assertTrue(ca.port_alive(port))
s.close()
self.assertFalse(ca.port_alive(port))
class Serve(unittest.TestCase):
def setUp(self):
self.port = free_port()
patches = [
mock.patch.object(ca, "PORT", self.port),
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
mock.patch.object(ca, "load_sessions", lambda: []),
mock.patch.object(ca, "fetch_usage", lambda: {}),
mock.patch.object(ca, "fetch_balance", lambda: ({}, "test")),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
async def _with_server(self, check):
task = asyncio.create_task(ca.serve())
loop = asyncio.get_running_loop()
try:
for _ in range(60):
if ca.port_alive(self.port):
break
await asyncio.sleep(0.05)
return await check(loop)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def test_serve_answers(self):
async def check(loop):
status, body = await loop.run_in_executor(None, http_get, self.port, "/")
self.assertEqual(status, 200)
self.assertIn('<div id="root">', body)
status, raw = await loop.run_in_executor(None, http_get, self.port, "/raw")
self.assertIn("tree", json.loads(raw))
asyncio.run(self._with_server(check))
self.assertFalse(ca.port_alive(self.port))
def test_serve_survives_usage_failure(self):
def no_token():
raise FileNotFoundError("no credentials")
async def check(loop):
body = ""
for _ in range(60):
_, body = await loop.run_in_executor(None, http_get, self.port, "/limits")
if "Не найден токен Claude Code" in body:
break
await asyncio.sleep(0.05)
self.assertIn("Не найден токен Claude Code", body)
status, _ = await loop.run_in_executor(None, http_get, self.port, "/")
self.assertEqual(status, 200)
with mock.patch.object(ca, "fetch_usage", no_token):
asyncio.run(self._with_server(check))
def test_serve_busy_port_raises(self):
s = socket.socket()
s.bind(("127.0.0.1", self.port))
s.listen()
self.addCleanup(s.close)
with self.assertRaises(OSError):
asyncio.run(ca.serve())
class RegisterToolbelt(unittest.TestCase):
def setUp(self):
self.registered = []
self.kicks = []
async def register(connection, **kwargs):
self.registered.append(kwargs)
fake = types.SimpleNamespace(tool=types.SimpleNamespace(async_register_web_view_tool=register))
patches = [
mock.patch.object(ca, "iterm2", fake),
mock.patch.object(ca, "PORT", 8766),
mock.patch.object(ca, "CONFIG_PATH", "/nonexistent/claude-agents/config.json"),
mock.patch.object(ca, "kickstart_service", lambda: self.kicks.append(1)),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
def assert_registered(self):
self.assertEqual(self.registered, [{
"display_name": "Claude", "identifier": "com.andrey.claude-agents",
"reveal_if_already_registered": False, "url": "http://127.0.0.1:8766/"}])
def test_register_toolbelt_kickstarts_when_port_dead(self):
with mock.patch.object(ca, "port_alive", lambda *a, **k: False):
asyncio.run(ca.register_toolbelt(object()))
self.assertEqual(self.kicks, [1])
self.assert_registered()
def test_register_toolbelt_skips_kickstart_when_alive(self):
with mock.patch.object(ca, "port_alive", lambda *a, **k: True):
asyncio.run(ca.register_toolbelt(object()))
self.assertEqual(self.kicks, [])
self.assert_registered()
class Kickstart(unittest.TestCase):
def test_kickstart_command(self):
with mock.patch.object(ca.subprocess, "run") as run:
ca.kickstart_service()
self.assertEqual(run.call_args.args[0],
["launchctl", "kickstart", f"gui/{os.getuid()}/com.andrey.claude-agents"])
with mock.patch.object(ca.subprocess, "run", side_effect=OSError("no launchctl")):
ca.kickstart_service() # не бросает
class Run(unittest.TestCase):
def test_run_headless_calls_serve(self):
called = []
async def fake_serve():
called.append(1)
with mock.patch.object(ca, "serve", fake_serve):
ca.run(["--headless"])
self.assertEqual(called, [1])
def test_run_without_iterm_exits(self):
with mock.patch.object(ca, "iterm2", None):
with self.assertRaises(SystemExit):
ca.run([])
if __name__ == "__main__":
unittest.main()