From 64c6afadb9a1d5c563d9a9e36eda2b99b2bf3ba1 Mon Sep 17 00:00:00 2001 From: huyedits Date: Sat, 26 Sep 2026 12:00:38 +1000 Subject: [PATCH 1/5] symb pet is a real subcommand now :3 start/stop/status/run + --demo, one cat at a time, and it survived a real lora run end to end (kept + swatted off) >//< also the yawn was a scream and old z's got stuck on its head T_T fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ryuzz9EtCjeeR8ofVxhGW --- .gitignore | 1 + README.md | 9 ++- symbio/app/cli.py | 26 ++++++++ symbio/app/pet.py | 147 ++++++++++++++++++++++++++++++++++++++++++++ symbio/constants.py | 3 + symbio_pet/cat.py | 10 ++- symbio_pet/cli.py | 41 +++++++++++- symbio_pet/view.py | 40 +++++++++++- tests/test_pet.py | 130 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 symbio/app/pet.py diff --git a/.gitignore b/.gitignore index 244acc8..46d376e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ config.json.bak.* cron_jobs.json gateway.pid daemon.pid +pet.pid # The Merkle root over THIS install's adapters, and the per-adapter seals # under it. Every install has its own adapters, so shipping one person's root # would only ever tell someone else their own weights are wrong. diff --git a/README.md b/README.md index 9383b6d..e1a0343 100644 --- a/README.md +++ b/README.md @@ -181,10 +181,15 @@ default. ### The pet ```bash -symbio-pet # a cat on your desktop, watching this install -symbio-pet --demo # the same cat playing a scripted run; nothing is trained +symb pet # put a cat on your desktop, watching this install +symb pet --demo # the same cat playing a scripted run; nothing is trained +symb pet status # is it out +symb pet stop # call it in ``` +`symb pet` starts it in the background, like `symb daemon start`; `symb pet run` +keeps it in the terminal instead, and `symbio-pet` does the same on its own. + A tilcayo — *Leopardus tilcayo*, the newest cat species, described on 17 September 2026: light-brown coat, big irregular rosettes, short round ears. It is the fine-tune, drawn: diff --git a/symbio/app/cli.py b/symbio/app/cli.py index 60320c6..432ad48 100644 --- a/symbio/app/cli.py +++ b/symbio/app/cli.py @@ -118,6 +118,20 @@ def _build_parser() -> argparse.ArgumentParser: daemon_sub.add_parser("run", help=argparse.SUPPRESS) # internal: the daemon process daemon_parser.set_defaults(daemon_command="status") + # One optional action rather than subparsers, so `symb pet --demo` and + # `symb pet start --demo` both parse: flags declared on both a parser and + # its subparser are overwritten by the subparser's defaults. + pet_parser = sub.add_parser( + "pet", help="The desktop cat that shows training as it happens (macOS)") + pet_parser.add_argument( + "pet_command", nargs="?", default="start", + choices=["start", "stop", "status", "run"], + help="start (default) puts it on the desktop; run keeps it in this terminal") + pet_parser.add_argument("--demo", action="store_true", + help="Play a scripted run instead of watching this install") + pet_parser.add_argument("--port", type=int, default=8742, + help="Port of the chat window a double-click opens") + train_parser = sub.add_parser("train", help="Run LoRA training") train_parser.add_argument( "skill", @@ -1389,6 +1403,18 @@ def main(argv: list[str] | None = None) -> int: print("Usage: symb daemon [start | stop | status]") return 1 + if command == "pet": + from symbio.app import pet + + action = getattr(args, "pet_command", None) or "start" + if action == "stop": + return pet.stop_pet() + if action == "status": + return pet.pet_status() + if action == "run": + return pet.run_pet(demo=args.demo, port=args.port) + return pet.start_pet(demo=args.demo, port=args.port) + parser.print_help() return 1 diff --git a/symbio/app/pet.py b/symbio/app/pet.py new file mode 100644 index 0000000..4af1101 --- /dev/null +++ b/symbio/app/pet.py @@ -0,0 +1,147 @@ +"""`symb pet`: the desktop cat, started and stopped like the daemon. + +The pet is its own process, `python -m symbio_pet`, and deliberately not this +one: by the time this CLI runs it has imported the agent package (~105 MB), +and a pet that sits on the desktop all day must not carry that. `start` +launches it detached, `run` replaces this process with it, and the pet keeps +its own pid file, so one started as `symbio-pet` is found here too. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +from symbio import constants + +# How long `start` waits to see the pet write its pid file before saying it is +# out. Drawing the first frame takes under a second; importing AppKit cold +# can take a few. +_START_WAIT_S = 8.0 + + +def pet_pid() -> int | None: + """The running pet's pid, or None. + + A pid file outlives a killed process, and after a reboot its number is + soon somebody else's, so the process has to be alive and has to be the + pet. + """ + try: + pid = int(constants.PET_PID_FILE.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + return None + except PermissionError: + pass + try: + command = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], + capture_output=True, text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return None + return pid if "symbio_pet" in command else None + + +def _argv(demo: bool, port: int) -> list[str]: + argv = [sys.executable, "-m", "symbio_pet"] + if demo: + argv.append("--demo") + if port != 8742: + argv += ["--port", str(port)] + return argv + + +def _package_root() -> str: + """Where `-m symbio_pet` resolves: the checkout, or site-packages.""" + return str(Path(__file__).resolve().parent.parent.parent) + + +def _env() -> dict[str, str]: + """The pet watches exactly the install this CLI resolved. It runs from the + package root, so a relative SYMBIO_HOME would otherwise resolve somewhere + else in the child.""" + env = dict(os.environ) + env["SYMBIO_HOME"] = str(constants.PROJECT_DIR) + return env + + +def start_pet(demo: bool = False, port: int = 8742) -> int: + pid = pet_pid() + if pid is not None: + print(f"The pet is already out (PID {pid}). `symb pet stop` calls it in.") + return 0 + constants.PET_PID_FILE.unlink(missing_ok=True) # stale, per pet_pid + log_path = constants.LOG_DIR / "pet.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + with open(log_path, "a", encoding="utf-8") as log: + process = subprocess.Popen( + _argv(demo, port), cwd=_package_root(), env=_env(), start_new_session=True, + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + _START_WAIT_S + while time.monotonic() < deadline: + if process.poll() is not None: + # It said why before it left (no PyObjC, another pet already out). + tail = log_path.read_text(encoding="utf-8", errors="replace").strip() + print("The pet did not start:") + print("\n".join(tail.splitlines()[-6:])) + return 1 + if pet_pid() == process.pid: + what = "the demo" if demo else f"watching {constants.PROJECT_DIR}" + print(f"The pet is out (PID {process.pid}), {what}.") + print("Double-click it to chat, drag it anywhere, right-click for " + "more. `symb pet stop` calls it in.") + return 0 + time.sleep(0.1) + print(f"The pet is starting (PID {process.pid}); it has not drawn yet. " + f"See {log_path} if it never appears.") + return 0 + + +def stop_pet() -> int: + pid = pet_pid() + if pid is None: + constants.PET_PID_FILE.unlink(missing_ok=True) + print("No pet is out.") + return 0 + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except PermissionError: + print(f"Permission denied: cannot stop process {pid}.") + return 1 + # It saves where it sat and removes its own pid file on the way out. + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline and pet_pid() == pid: + time.sleep(0.1) + if pet_pid() == pid: + print(f"Asked the pet (PID {pid}) to go; it is still there.") + return 1 + constants.PET_PID_FILE.unlink(missing_ok=True) + print(f"The pet (PID {pid}) went home.") + return 0 + + +def pet_status() -> int: + pid = pet_pid() + print(f"Pet out: {'yes' if pid else 'no'}") + if pid: + print(f"PID: {pid}") + print(f"Watching: {constants.PROJECT_DIR}") + return 0 + + +def run_pet(demo: bool = False, port: int = 8742) -> int: + """This terminal becomes the pet: exec, so none of this process stays.""" + sys.stdout.flush() + os.chdir(_package_root()) + argv = _argv(demo, port) + os.execve(argv[0], argv, _env()) + return 1 # not reached diff --git a/symbio/constants.py b/symbio/constants.py index 7273234..0ca1f79 100644 --- a/symbio/constants.py +++ b/symbio/constants.py @@ -103,6 +103,9 @@ def _workspace_or_packaged(name: str, packaged: str) -> Path: # file is for stop/status. DAEMON_SOCKET = PROJECT_DIR / "daemon.sock" DAEMON_PID_FILE = PROJECT_DIR / "daemon.pid" +# The desktop pet (`symb pet`). Written by the pet process itself, so one +# started directly as `symbio-pet` is found by `symb pet status` too. +PET_PID_FILE = PROJECT_DIR / "pet.pid" # The fine-tune in progress, as one JSON file under LOG_DIR for anything # outside the training process that wants to watch it (symbio_pet draws it). # Written by symbio/app/training_live.py. A name rather than a path: the suite diff --git a/symbio_pet/cat.py b/symbio_pet/cat.py index 5c9c57c..10e5fc2 100644 --- a/symbio_pet/cat.py +++ b/symbio_pet/cat.py @@ -622,7 +622,13 @@ def _toward(self, x: float, y: float) -> tuple[float, float]: def _ambient(self, dt: float) -> None: mood = self.mood - if self.sleepy > 0.6: + if mood != "asleep": + # Awake: the z's still drifting up go at once. Sitting up puts the + # head right where they are. + for p in self.particles: + if p.kind == "z": + p.life = max(p.life, p.ttl - 0.25) + elif self.sleepy > 0.6: self.z_clock += dt if self.z_clock > 1.8: self.z_clock = 0.0 @@ -791,7 +797,7 @@ def _face_parts(self, mouth_at: tuple[float, float]) -> dict: elif mood == "kept" or self.t < self.happy_until or self.groom > 0.5: eyes = "happy" elif self.yawn_t is not None: - eyes = "squeeze" + eyes = "closed" # squeezed shut with the mouth open read as a scream elif mood == "held": eyes = "wide" elif mood == "waking": diff --git a/symbio_pet/cli.py b/symbio_pet/cli.py index 18ca710..82a83f3 100644 --- a/symbio_pet/cli.py +++ b/symbio_pet/cli.py @@ -4,6 +4,9 @@ symbio-pet --demo play a scripted run, no model or training needed symbio-pet --snapshot DIR render the demo's key frames to PNGs and exit +`symb pet` (symbio/app/pet.py) starts and stops this same process in the +background; it keeps the pid file this writes, so either finds the other. + The model is not in here. The pet reads what the daemon, the trainer and the golden gate leave on disk, so it costs a window and a timer, not a copy of the weights. @@ -12,6 +15,8 @@ from __future__ import annotations import argparse +import os +import subprocess import sys from pathlib import Path @@ -37,6 +42,32 @@ def available() -> tuple[bool, str]: return True, "" +def other_pet(pid_file: Path) -> int | None: + """A different pet process already on the desktop, by its pid file. + + Alive is not enough: after a reboot a stale file's number is soon somebody + else's, so it has to be a pet too. One cat at a time. + """ + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + if pid == os.getpid(): + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + return None + except PermissionError: + pass + try: + command = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], + capture_output=True, text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return None + return pid if "symbio_pet" in command else None + + def snapshot(directory: Path) -> list[Path]: """Step the demo at 30 fps and write its key frames as PNGs.""" from symbio_pet.cat import Cat @@ -72,7 +103,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--demo", action="store_true", help="Play a scripted run instead of watching Symbio") parser.add_argument("--port", type=int, default=8742, - help="Port of the chat window a click opens (default 8742)") + help="Port of the chat window a double-click opens (default 8742)") parser.add_argument("--snapshot", metavar="DIR", help="Render the demo's key frames to PNGs in DIR and exit") args = parser.parse_args(argv) @@ -91,6 +122,12 @@ def main(argv: list[str] | None = None) -> int: from symbio_pet.view import Pet constants = load_constants() + pid_file = getattr(constants, "PET_PID_FILE", constants.PROJECT_DIR / "pet.pid") + other = other_pet(pid_file) + if other is not None: + print(f" A pet is already out (PID {other}). `symb pet stop` calls it in.", + flush=True) + return 1 if args.demo: from symbio_pet.demo import DemoFeed @@ -104,7 +141,7 @@ def main(argv: list[str] | None = None) -> int: # buffers, so a piped banner would otherwise never appear. print(" Ctrl+C to stop\n", flush=True) pet = Pet(feed, position_file=constants.LOG_DIR / "pet_position.json", - log_dir=constants.LOG_DIR, port=args.port) + log_dir=constants.LOG_DIR, port=args.port, pid_file=pid_file) return pet.run() diff --git a/symbio_pet/view.py b/symbio_pet/view.py index 6a78489..2b69d8e 100644 --- a/symbio_pet/view.py +++ b/symbio_pet/view.py @@ -15,6 +15,7 @@ import json import math +import os import signal import socket import subprocess @@ -59,7 +60,7 @@ NSWindowStyleMaskBorderless, NSWindowStyleMaskNonactivatingPanel, ) -from Foundation import NSMakeRect, NSRunLoop, NSRunLoopCommonModes, NSTimer +from Foundation import NSMakeRect, NSObject, NSRunLoop, NSRunLoopCommonModes, NSTimer from symbio_pet.cat import ( CX, FAR_FRONT, FAR_HIND, GROUND, HEIGHT, PANEL, WIDTH, Cat, @@ -714,13 +715,22 @@ def quitPet_(self, sender): NSApplication.sharedApplication().terminate_(None) +class PetDelegate(NSObject): + def applicationWillTerminate_(self, notification): + pet = getattr(self, "pet", None) + if pet is not None: + pet.guard("quit", pet.shutdown) + + class Pet: def __init__(self, feed, position_file: Path | None, log_dir: Path | None, - port: int = 8742): + port: int = 8742, pid_file: Path | None = None): self.feed = feed self.position_file = position_file self.log_dir = log_dir self.port = port + self.pid_file = pid_file + self._delegate = None self.cat = Cat() self.roamer = Roamer() self.painter = Painter() @@ -849,6 +859,18 @@ def _roam(self, dt): if self.roamer.arrived: self._save_position() + def shutdown(self): + """On the way out: remember where it sat, and give up the pid file — + only if it is still ours, so a stale pet never removes a live one's.""" + self._save_position() + if self.pid_file is None: + return + try: + if self.pid_file.read_text(encoding="utf-8").strip() == str(os.getpid()): + self.pid_file.unlink() + except OSError: + pass + def toggle_roam(self): self.roamer.enabled = not self.roamer.enabled if not self.roamer.enabled: @@ -1007,8 +1029,20 @@ def run(self) -> int: threading.Thread(target=self._poll_forever, name="pet-feed", daemon=True).start() self._pace() + # Every way out — the menu, a signal, logging out — goes through + # applicationWillTerminate_, which is where the place is saved and the + # pid file removed. AppKit ends the process with exit(), so nothing + # after app.run() and no Python atexit handler would ever run. + self._delegate = PetDelegate.alloc().init() + self._delegate.pet = self + app.setDelegate_(self._delegate) + if self.pid_file is not None: + try: + self.pid_file.write_text(str(os.getpid()), encoding="utf-8") + except OSError: + pass + def stop(*_): - self._save_position() app.terminate_(None) # The handler runs at the next timer tick, at most 1/6 s away. diff --git a/tests/test_pet.py b/tests/test_pet.py index 130697c..e3683d8 100644 --- a/tests/test_pet.py +++ b/tests/test_pet.py @@ -515,6 +515,136 @@ def test_it_stays_put_when_it_should(snap, hovered, enabled): assert x == 700.0 +def test_waking_up_clears_the_zs(): + """Sitting up puts the head where the z's were still drifting.""" + cat = Cat(seed=1) + _step(cat, 4.0, Snapshot(presence="asleep")) + assert any(p.kind == "z" for p in cat.particles) + _step(cat, 1.0, Snapshot(presence="awake")) + assert not any(p.kind == "z" for p in cat.particles) + + +# ---- `symb pet` --------------------------------------------------------------- + +from symbio.app import pet as pet_cmd # noqa: E402 +from symbio.app.cli import _build_parser # noqa: E402 + + +@pytest.mark.parametrize("argv,action,demo", [ + (["pet"], "start", False), + (["pet", "--demo"], "start", True), + (["pet", "start", "--demo"], "start", True), + (["pet", "run", "--demo"], "run", True), + (["pet", "stop"], "stop", False), + (["pet", "status"], "status", False), +]) +def test_symb_pet_parses_every_shape(argv, action, demo): + args = _build_parser().parse_args(argv) + assert (args.command, args.pet_command, args.demo) == ("pet", action, demo) + + +@pytest.fixture +def pet_home(tmp_path, monkeypatch): + monkeypatch.setattr(constants, "PROJECT_DIR", tmp_path) + monkeypatch.setattr(constants, "PET_PID_FILE", tmp_path / "pet.pid") + monkeypatch.setattr(constants, "LOG_DIR", tmp_path / "logs") + return tmp_path + + +def test_a_stale_or_foreign_pid_is_not_a_pet(pet_home): + constants.PET_PID_FILE.write_text(str(_dead_pid()), encoding="utf-8") + assert pet_cmd.pet_pid() is None + # Alive, but this is pytest, not a pet: after a reboot a stale number is + # soon somebody else's. + constants.PET_PID_FILE.write_text(str(os.getpid()), encoding="utf-8") + assert pet_cmd.pet_pid() is None + + +def test_the_pet_runs_as_its_own_light_process(pet_home): + """Never the CLI itself, which has the whole agent package loaded.""" + argv = pet_cmd._argv(demo=True, port=9000) + assert argv[1:] == ["-m", "symbio_pet", "--demo", "--port", "9000"] + assert pet_cmd._env()["SYMBIO_HOME"] == str(pet_home) + + +def test_start_finds_a_pet_already_out(pet_home, monkeypatch, capsys): + monkeypatch.setattr(pet_cmd, "pet_pid", lambda: 4242) + monkeypatch.setattr(pet_cmd.subprocess, "Popen", lambda *a, **k: pytest.fail("spawned")) + assert pet_cmd.start_pet() == 0 + assert "already out (PID 4242)" in capsys.readouterr().out + + +def test_start_reports_a_pet_that_could_not_start(pet_home, monkeypatch, capsys): + class Gone: + pid = 777 + + def poll(self): + return 1 + + def spawn(argv, **kwargs): + kwargs["stdout"].write(" The pet draws with AppKit through PyObjC ...\n") + kwargs["stdout"].flush() + return Gone() + + monkeypatch.setattr(pet_cmd.subprocess, "Popen", spawn) + assert pet_cmd.start_pet() == 1 + assert "PyObjC" in capsys.readouterr().out + + +def test_start_waits_for_the_pet_to_claim_its_pid(pet_home, monkeypatch, capsys): + class Running: + pid = 778 + + def poll(self): + return None + + answers = iter([None, None, 778]) + monkeypatch.setattr(pet_cmd, "pet_pid", lambda: next(answers)) + monkeypatch.setattr(pet_cmd.subprocess, "Popen", lambda *a, **k: Running()) + assert pet_cmd.start_pet() == 0 + assert "The pet is out (PID 778)" in capsys.readouterr().out + + +def test_stop_sends_it_home(pet_home, monkeypatch, capsys): + answers = iter([555, None, None]) + sent = [] + monkeypatch.setattr(pet_cmd, "pet_pid", lambda: next(answers)) + monkeypatch.setattr(pet_cmd.os, "kill", lambda pid, sig: sent.append((pid, sig))) + assert pet_cmd.stop_pet() == 0 + assert sent == [(555, pet_cmd.signal.SIGTERM)] + assert "went home" in capsys.readouterr().out + + +def test_stop_with_no_pet_out(pet_home, capsys): + assert pet_cmd.stop_pet() == 0 + assert "No pet is out" in capsys.readouterr().out + + +def test_one_cat_at_a_time(tmp_path): + from symbio_pet.cli import other_pet + + pid_file = tmp_path / "pet.pid" + assert other_pet(pid_file) is None + pid_file.write_text(str(os.getpid()), encoding="utf-8") + assert other_pet(pid_file) is None # that is this process + pid_file.write_text(str(_dead_pid()), encoding="utf-8") + assert other_pet(pid_file) is None # stale + + +def test_a_pet_gives_up_only_its_own_pid_file(tmp_path): + pytest.importorskip("AppKit") + from symbio_pet.view import Pet + + pid_file = tmp_path / "pet.pid" + pet = Pet(feed=None, position_file=None, log_dir=None, pid_file=pid_file) + pid_file.write_text("999999", encoding="utf-8") + pet.shutdown() + assert pid_file.exists(), "a pet must never remove another pet's pid file" + pid_file.write_text(str(os.getpid()), encoding="utf-8") + pet.shutdown() + assert not pid_file.exists() + + # ---- the demo, and drawing it ----------------------------------------------- def test_the_demo_passes_through_every_state(): From 445f574e4c064ff74576097c976635266eaaf700 Mon Sep 17 00:00:00 2001 From: huyedits Date: Sat, 26 Sep 2026 12:23:07 +1000 Subject: [PATCH 2/5] no more browser tab!! double-clicking the cat opens symbio as its own real app now, own window + cat in the dock + edit menu so copy paste works :3 <3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ryuzz9EtCjeeR8ofVxhGW --- README.md | 6 ++- symbio/app/cli.py | 6 +-- symbio/app/pet.py | 12 ++--- symbio_desktop/static/icon.png | Bin 0 -> 124809 bytes symbio_desktop/window.py | 42 +++++++++++++++- symbio_pet/cli.py | 4 +- symbio_pet/view.py | 88 ++++++++++++++++++++++++--------- tests/test_desktop_app.py | 16 ++++++ tests/test_pet.py | 32 +++++++++++- 9 files changed, 164 insertions(+), 42 deletions(-) create mode 100644 symbio_desktop/static/icon.png diff --git a/README.md b/README.md index e1a0343..0f88833 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,10 @@ It is the fine-tune, drawn: is kept, and the cat swats it off its collar when it is rolled back. Between runs it sleeps while no model is loaded, thinks while the model works, -and strolls along the top of the Dock now and then. Drag it anywhere, -double-click it for the chat window, right-click to stop it wandering. +and strolls along the top of the Dock now and then. Drag it anywhere, and +right-click to stop it wandering. Double-click it and the chat opens as its +own app — a native Symbio window with the cat in the Dock and the usual +menus, not a browser tab. It never imports `symbio`. Training writes `logs/training_live.json` from the trainer's own lines and the gate's verdict; the pet reads that, the daemon's diff --git a/symbio/app/cli.py b/symbio/app/cli.py index 432ad48..43ebc2e 100644 --- a/symbio/app/cli.py +++ b/symbio/app/cli.py @@ -129,8 +129,6 @@ def _build_parser() -> argparse.ArgumentParser: help="start (default) puts it on the desktop; run keeps it in this terminal") pet_parser.add_argument("--demo", action="store_true", help="Play a scripted run instead of watching this install") - pet_parser.add_argument("--port", type=int, default=8742, - help="Port of the chat window a double-click opens") train_parser = sub.add_parser("train", help="Run LoRA training") train_parser.add_argument( @@ -1412,8 +1410,8 @@ def main(argv: list[str] | None = None) -> int: if action == "status": return pet.pet_status() if action == "run": - return pet.run_pet(demo=args.demo, port=args.port) - return pet.start_pet(demo=args.demo, port=args.port) + return pet.run_pet(demo=args.demo) + return pet.start_pet(demo=args.demo) parser.print_help() return 1 diff --git a/symbio/app/pet.py b/symbio/app/pet.py index 4af1101..14c8b21 100644 --- a/symbio/app/pet.py +++ b/symbio/app/pet.py @@ -49,12 +49,10 @@ def pet_pid() -> int | None: return pid if "symbio_pet" in command else None -def _argv(demo: bool, port: int) -> list[str]: +def _argv(demo: bool) -> list[str]: argv = [sys.executable, "-m", "symbio_pet"] if demo: argv.append("--demo") - if port != 8742: - argv += ["--port", str(port)] return argv @@ -72,7 +70,7 @@ def _env() -> dict[str, str]: return env -def start_pet(demo: bool = False, port: int = 8742) -> int: +def start_pet(demo: bool = False) -> int: pid = pet_pid() if pid is not None: print(f"The pet is already out (PID {pid}). `symb pet stop` calls it in.") @@ -82,7 +80,7 @@ def start_pet(demo: bool = False, port: int = 8742) -> int: log_path.parent.mkdir(parents=True, exist_ok=True) with open(log_path, "a", encoding="utf-8") as log: process = subprocess.Popen( - _argv(demo, port), cwd=_package_root(), env=_env(), start_new_session=True, + _argv(demo), cwd=_package_root(), env=_env(), start_new_session=True, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) deadline = time.monotonic() + _START_WAIT_S while time.monotonic() < deadline: @@ -138,10 +136,10 @@ def pet_status() -> int: return 0 -def run_pet(demo: bool = False, port: int = 8742) -> int: +def run_pet(demo: bool = False) -> int: """This terminal becomes the pet: exec, so none of this process stays.""" sys.stdout.flush() os.chdir(_package_root()) - argv = _argv(demo, port) + argv = _argv(demo) os.execve(argv[0], argv, _env()) return 1 # not reached diff --git a/symbio_desktop/static/icon.png b/symbio_desktop/static/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..66700f4184e5ad9a393f62ac46d0fc3f526068c1 GIT binary patch literal 124809 zcmeFZWmgK!6Z}6ClBY1qe>#65QS0LvU?egIjQShsK(2`tt65 z_PyuB9pn6g(=}==uNuqde4aU1b>s(4WgJXOOaK6YqpG5y0{|etMiBw%sILLoa~|>< zAh_x%%K>U9sgGY1B40nNetZ8O!0{SK2Oxyo0+9bx<@Kg~y|BL^p zkc~GD1OUhYR25`DdLo>4VRRZRy9loccr0++H&~7Sv06^`FiLdj8S)$sKp3vZFh~4? zwViO;AHub#4mM4syHeTmqciIhAV*hEM8gpIj{2P!)rsEd#?dR`wOtdB{_6U{0;v5o zJ6GyZBTqo!;bc7MOr8Qbzg zj_?sBv{4qkgcw?Ss&(1K@0?X*KlL~qs-k11q$|Vdx(+4t{&|WpVY_Q;;Dn&`44kF$ zrPwVrEopK;Tu6T@)-lvXG8ffT0{!Z#Fc$T~51m1X_)w@4$`C-z5t4?Err`W1K~DM$ zBBroPF88L0pSC}(7vY)Qvpjq*-v)LqEyD=cwnK-_7tI$ccc`T(E2FVWH{T=C{kt?& zjA&^=Z4`F8eD~A$R1>~<67EmHATnh&3bTbPShBl>_m6>V(*}S9H+?PmFv;ue&gm71zby(S2 zMyWZ~(QH1MMKATJztVo7C{BuDD zVq?Fw_*1205;{a?{ZpTVEPldFBS{1{&%TFHUN^Ho^hTkT0`UU*;$+N)7bAR6lO9)k z7|z6M9KJ!qlWG$7J*dbwYp(<6YP#dDV^IFt_9z~=01dMMzD~nyjlkTF7}9-&HgQZo z|2a#FAvUZbIW+`Xxr$Vds8{}SM18PAX8%nh_#5Hf!M4t^@I&`AV+Y}Pc{z1^86{nH zVFvWN`C~H}bT;?LgSZU^+f2+XS&i+Td{U4HzhKU;uK%&!NA-)?(5_F#|?lKZX~z z#UMjcDaF8a_R?3Q>w0~mD&sZ)OYRWT*`ClUeUD2M!QCGxtyRkEDV!MhhXkN<`TJ|P zr^wqe$^11$Zh2*Z@G93uJZqt&S6aTdzv8&^1}zGK1Y-NL;;HNSsoJh(5%gCo-gygz zdVTAdUbv&Yd;P{`%Dx)GAMN;L2d@I!QYHy{CTpoSl{~w_y^gFx0Q;bG32gUDzKS7|&fpaV)e z`RAx(9x#v`{>L7TlaQL1>+xK;+3P?VjiVv7)z7EZ$nUyay89qhu=|aJg)M#818ZtD z4cEl78GNUnF=dRkyl6u)i8dEx?e~a1Unbbn*%5l`jz1aHx84wvtb=AnK#ku!u~czA zle6`;o@j**LRVCLcrIguE1i0rV!^Z+O}W9khmA*%Wv*m*PHvG~0;d}f33nIdSl9h^2+>ve zEb*w5?H6?V(HwWSKgylx%T#1MwEJYm_*toWa~MYr1XNJsCiA0md&R+{5DV79!pK`h z1=u{UEfT8;+9;BO^YOZGnP;L@qx6aEBI}@)KFW27)c#rB1`64AvMMIDXq2-^I%7zz z^(N${u9-|+&AX_wGxb_0i`BIQ zW9=!K>8B(RsFF*F8mh_@^db-#qwS0z$_e3a-0)M4psSy?Et=;tj_U)9o}>tTp+(EQ zjb>H+U{HhHP0a6=r|#<|8~^ha*bB1{RNr4ii_NUzqHF2<)dIoIuqo0`?ofB>2l+}= zZs^d-i)8kUAY?)8Kbj_XYm5v_c#=@Td2~#?;2m#RA%TJ;BayS>SH~xwHwZo#Ay8 z(9^v_2SWJut*gKc-;dc4iC%=cpty~+yrBja!y=+EBYkZzKXA8i%S++)^oxbjq7;nu zoQJN9XOlIKM;Z(?fx#6@K0aMZUOq+TJ%OoihLwgbNRb2FSY&*PH&I&#Sc%uE!i~}wN#DS6tH6oHONB$S6|J-q#=#cW9gQ($V{2_2a=KL;%%OPQ z(SN48!>n675Ia)ZvDYzaywh?RxhEV8VA^@|9<+X9Co%eHAKs$0v{r3Q!;6_HLO!H# zDFHEH0YkX8awegxjUnOJb@ww9Q$Qoy3`Ym7oUa^$9yf67nd?05B!!LC(bvm z8FNat#<5ToTUn(p_{R~J*1rPSbv3pL?FB~v@zgZ@ni5dyvV;`d9udG+jF-SJVs6N$ za(NyX1f5m*GuL^yJkS%h=yY_v5igXoUE8wp++$@PDe}b z4TfLnOWY9tP+Somg>3-(==T1RR??tyxS8N5zHg!;LLxKCm=($R2+R`Ez#6oYpA|E+ zdDT8}p(XqEu-(tL07l*rzS$o3O{4YI4xg1n)lnera=VRjv@DZn_x*jsp$>$1Ru zCf|ohcQ5)R6%QxH5Z%2n=GKE4f8|nxzKK=vm_jCG8onT0FC-rr&o$dAeUqLxI>bfqLRjq02lZdBV@m!&C`H#PI-Ag5A_Z<%a2Z{~P)Wh# zC%pfWA8j4%H=jg$K6=;nZ(A&*IeF>#osfPUHa<>TfE-tyVx4ROvE~Jt4OPPc$web*f}xRP$njX zc2>>?xE4Ge*NMyq24RzXgtQiwZz0nSNwa@&!>qm!lKD=C|HfsMZKnhy5gfBBz3|l5 zblDcx9jC*uV`I=)?~fDVzA*JB*$T(F)!Y5yB2>4IG4$p7b9r&OY#YCzp?~#L8+6^X z=tkJEd-U*uU@6_1IY1Z)H`FBG9zxf~YbT+fE`+9tw47$Akm0(=&P@-c6?1-r+ ze|A4V8BLj;ztkQg@wJdu5i~C%am_|W?ba_4V`XF0Az8b*ek!`q@fhwc(W>35`scIn zK?-%e+ql8-D)0S4+LxG5F>Nx0?mpgu7;^73M8&S+_IdqQ?0^cSy*bjBK4;c_qa#c& zXEEHrT~_id&hhDW!C~5;UYlu4bLpXO{JBS}dKhM8Fu${*@9$dduM`;1{;^K>?JhPJ znoj+%O^B5@<1asTX_e$sFEh(o5Y+gk7Kv;bfq?K%6r1qoiRCKjhUKw?=##8}?Bn&0 zoN$`q`S-x~|0)c^hd(_sS2~Y{zc{sB1C`C% zpzY$DJ`=GDn_(eq{~4LJN;SgVmxU>=lgebEtSD;-QR!*V+N_lKCPl*dW{C$)W}Uw< zhz)4wWBelJ-6B~V%)^oQzhyzDUslBB6rYF7fd5qu*bemr7rS2+`sYj*y#b*(S`!UhoYj-VE-S@M5J0N(lON|ec0QKlP-j^a1 znolW~c~X9DyF5A`ijw*qai-lDetDniN>G?uQ2P++TPiL3USGeaS~+`XMA_)Ng-$?- z^e^MX8Z0 zEz3ieh8+{{XmvkY{>*YP)?Mab_7sXcOXsp&bIjp5Cru9rjpGTaXQuW!HpEzu+PpRp z>f%a@v37BtQ+y`_BZEjU%9F#%b%|`CQx)-A-!{*1_wef~5UvGGv28=G(3scO1Y*kAYcP~RMm`$=9Te)%T8MT{Yye+}@kmB7=u zsl#7T0PlY!qY7ompgr3Zk*JfmV4RJ`Dt8?nDAU>K$b4+Mi#DkYl-anXZ?VMcqfT9 zf$kL^<_!VD+-vcNfGPbd|8x&m;52?nCTSa96GCV|Vv2BCPfo7x5ACE(Z(MCwk}s3q znpGIK}33b=)a*X;HB2{xJr{T>%2O0@!;eNeKfLVj=hdiudzn zx3{zzdD{i6gs<n63QqFLQU%Q~Ow8u5sZP4o5?gubeOY|o_ zLIw<+5FY>`hYR7+U3UkWMqBLACCSJ(#$2XEsNsQ^3{@HwSy191H@da*mwXYNK(?q+ z1$t+$a2b4?<&eB?TA5=C#ASZ|Nq2jxzrmdSvS_+~HlU-7c?Pk-FhWSN<;~d{{ltM;|uf@p~%7P%#@xOnZ|bHJ_;dHBwtd59ivy%BHIS#hbVqQCkFo_ zf`jo55tL1SHl!32(@6#*Ec0A9Of_6Vu$O&}Xf8C=1 z&Uav8GQgRq*-B-#CKrMVqjvwX|5&^tLAr*O|FniUT_&Or8>{*9aF}%)Ep_?QS~V z7T^Ndf0i8H{iYjSzq0c<_W>y3t2phx#DdF1Blin+^5t-fT6}GxDfZ8|qwm&o{V$}G z->vf0on4!g((%`y{wYskA+RSim`{)ZCBZsm=7#9!anH$MqSpxr)_+Nl+Uk9Yxpjs{ z65X9tdA&G!Jg7X3b-bK0YEkwg{uab11*GyLOYZwr`S^Hfw&ip}4X)p1iwA{@VTloy zdi4E#6rUM7?@eIk3)B`b)^tpir~4YjrgfF_uov}KCrAC_*H(!KdC2{65~xc@CCoyxX+E>o};^|ciF4H&Y6dM;L+B@JHt2rcu&*3%b~>+MktLA*f>v;no5$+ z#?z+S6A zPfX^0g@kK!n~Hp(NFjRcZq!6uyp9+dS;1I4^(r~k?~G;ksa*2JH?iwCp}^}TC*a)ddq zImBgM-nbt7Jxv@OR!vR3>WE!OpEcjKJoU?Qr+3n3evaKnAS#d5-GdIOvA3Cj!_(7= z>+L#ZU8Je(BX`%A^=IjyU0DmGF6Dg4MS%h!Xa;l1k0nXs9E$h%yszwoCcYLyRN^T6 z*CZ7<#%zSp*fFSARgnn^o!VbhP&Ss~r%j_&mOjTt!V=e9xkLesTwwWh?%V0=ep*Bn zZveU@+VT7mIYjy&=urY2vv>F({Jip`(x?r0b_Av9JQME`<2|8v5VCa zz8s{lkEl$|O;?^oPEPyz526Y!NXGDEh<=(Bfd^JAb`GK<%?}&k5uEoW7}k;xH`3*v z@O)aq^EDrF38bOGc>tt4($$=`hfTZkUauxevnWB2fjEJ#_%?;_@cRLKbgckkU_J`B ztn=v7t?(ovID_^mK;8K`f!j9v6f3!1vqC?=VyRR)i{L-@vRCTiaiD zyVZC8ig0uIO3Qm0xX0x0`@0I*qHW}T_N=W#+=|bMG;DW9(uDDJbh@nPXbuTg@CMuy zd+p{eg5yHJ#p+24q?5!RmD>HKz_;-HD32=sc+y}iM7u)wAS%dU`D#}AQGGO69<5qa z*m%*9n07t|Vrqu+@Ld??|+`#8M`cS;fgHZiA zCf%8NcEll|b*q3)9Y!keVB38CrRNP`#dA?4Q2;1I6?Z(CiQwWuAWE58sgaq?D}Xjt z{I2%aA>+HTTE{Ev-c}xlRquUx z{=>OV&{!o^X1*%w0S3RU#1Nx91!k=LElzj)!pr~E4yCy%FW0!&`4nBt22v3*pb{a0 zJht7mzf$9A_!lVJvy%(_VIaC2R`y?P+XTMIejU4k@XsqBt}7UjHcC$#F-b-p4|I#5 z$1kQrH)ipc$u)jYnTCLX61Dd0>xU6F!1Pc?5}f{4iXKi+<}3p#_!P*HCgc3SHKOiB z#xx6*V~NKg5~5!yp~}Bu_iAsEq^(hJ_TB8s0=Q1wf1Bkpt=1Wwy4M zKRjX*^9O2>H<45%GR7ws*95$)#qxh3<)%m$m#>$D%CophFq{S5Q z(PwdxaZM3J>tx;#jf6x}#@lKCv)mYR|8mK9_-NLAxK*s>ozcV>SaSW&@vGY0rVnQBu+}c_`3wa321^Iw1ZNEnnvZg6Qr{ zhbo2V{|i0j_En$nswBAuj_^po{uxxFFh5 zTmBKgBbIl4RkMx8hEn$R{!}$v6@NFF4COoi&VfXiw#sOFZ~=`Z8IdNzeZ11wpeRGD>Ol{|7a9*F0xj2X0fn`9-Y*|qt z_xgJxkx9xzEF*qbsu3c#8I>OzWx9{i@}BrpzYOqsi$&6e@%0vSYm=TLQIOI<_j=6b zS6d<*DhhG@k#|J4EhkYlViIjMBQE-K1T>TxB`@428j_B|x?$~oY4Y1*lft`DbiP*Z zhPqq<^H}OPke9{Sk2a3;N$a3xQ#pc$l@eB+dJ@~?dpy>AwVJ$qm^e4cWUrDu7)ppL zcejF@#%h%idBUNVXQLH-^DfJ5=AO93skw9FK)+E^@i>diso{ylK}#F*ZG82+zv8NH z@++78U)5N>IQP|#lilec1Kvw_O|G50KLJV|YCzR-t?Y}_Q8N~2@So7}JcIX zxG_rFwlRUj=-?5H{OV+sUmDuBbY0mw8TTRg;V*HdG5ir$*xC|Hk4;DYkHV?8Zi(u^ z!UsXNcA*ci5bf7hY)SIn4BGP&Wb7WuEXD^_*Ew7$oYf5Q{&^abszasq{0drDOVr{B z)B-Ybi$*E&V-AEXb&QcJ*gw%;5xCy9S#*j|`PC%9)shL~tIqn+(+yeVwB#B~W}~D74@|OY$=G zm4K`fH+rpjqME%@l3ZEZ6{pkrA;mBM^9Y-VwwNr$pE>Vxd#5g3Umt)6`#)5eI)QTw zC|i-*w_wlsb~JI$#9OEtvRyq({i*K_0ZrMLF9CExGBj0w#D==Lr%V!6 z{zfcAwiaaR{-HAC$%8zJLe{WXD_-FTh>3X$xBY{6HWkF|l;Q9ZYj?B$&>S8<2RErX zjQ*FsN^%-e2KYH~3d%mSrJdy1`;MV0`QvJ>BOPEK;>}<#7n>9+3P{Nv# zj)3Uxs}F=p+hD6SLPo)X8kZ2f!S?kgydc~O;rHQZ}$B^2Vlqm8kY z@9j??E@#I-1Es>I`m<#-Ar&PX%?jcc2r{vcIc^sdG_;~0b~U~dezUd^{Ws!86^^_6 zgC6N`)UwT+QM3UqS{=dvvE&m(`%v^#*W_NwScLPwRGo{?n*0XRvRq)K ziJRiKz>9=MXwNQmZkPPGk~+>^)*U@pF9FfmdK((uopc*mEEQekzF98j{M!ZAb61s( zt3_g0;0oix z6&xlne=@PRUPpwt8TvoOTUZ1P6Ut{Z$Ws(gLb-p)<+LzZK9V>S?AW|2<>ZHf z`mw6G?GJs_O);(&7Ve1b7$SoR1f055vQsq9Gx_E3#LxZf#WZu7@5~bwSTjmbUgI6^ zzuvy``3c-tI){F8b0S_KLtK~_ZOvORR|M_cuW2VIF#zZWWzj&SFI>yVtDI_NR@lf* zf5OAz!@BGoj#0(U-N@5=7yiRGq4$8+ewTL|*7!$Wm{P&!4DZL-sHAz54*r@g2#S4j zSKwpsr|2_!q*$genq0cMQ7H*g@WRKy`dk?H9;N{$57K;>FhN1|6Sg|Nx zPz+(Qz-Jsu}4?Bi+78!iQtL2_IKOo<@Dgo@$S( zb4VsNAT$wro7TQ5dku=2UBR|JMFF@!rYxB9-=`1NjX}pIKV>U;({*rveiwg7%tllo z^B)w9+v+Jc&-{of#3H-9kdf<5k?$>@u`|Q2qC>mBsDkLIyV?ZeuN)Kin00F5z&q0} z>3aU?b!n<6=BC1#L82sfbn>Z4x3qk0o}H4GPZx(yG|v=ep&v#7$y?y%8-^d1b2zM4 zwzoLwmiRqC>eK!!BZiREu!0;t>zgSeH6hpaZb9iIRSa z2`hP2HZ5}GEt9PiBq*d=xLz!<_r;z^#Bc;YLp|G#1y~iU?%jYKWtbUD>fV7J$}j{D z{cu&0!ii>(NN|h1Wt7VrhIa0L(E9|DY8E=Db&J4Hq-I-M4Y1G&Kg?FLH(01*uqr$& zb5`2VvFL5IYP&o6YrA(n7INU=eiJJECe}S6rA9Px6@+4EwXJREd2LZL#xKEQ^DHo| z@2*=?SA=2}bq8Eptf@TBu+|g4b0@oVwe@Um4-~Z|IhmRfb)CP0GA^*)-A>O$C7;Yh zb+5dYgAXV13x)==iXGd)g$*ewv;8iWKV4Qz`Wp(g_;~=lAv`^MdFS|Fosk$m;ysE? znD0d3Sb2B1(_`>ImE96?1oCpfMQltU5SDUXh{85uq_Nzw;h(aLi^rTNLTt77T0;r1 z6|M!!HRVkLI^H?zNNPz{wCDM%rQlIVe4IrOqLOWIcHGTtha(7y*(OpN`Ug>pT{8)J z4`5qxS<2ZFI1~ciGG!KDS-Z}!qjGPFj$PGqWVxl3V6=~ymQ)|7Aa|Aa!?@bs^1nms z8@^(}r-xx+FS57buvn8yrPR0DBn?Q!5AN+G60E?H=l1(HxfYdK!oix|2=%5aQT7p{X=c8jGy(q0v$@P&|iHij%m<^O8RjO^;;^Dh<7I6KFt0NhMe2=;P#`{BtbO?s;&2;5Q&b zyaVO;ulCQ)3kHS)fhS9cz4sG5xO_qRnq{zObuAEBGB;6Fy^st)kR z6Yai5E95W0;%4f@{16l)Ao-Qt+m@3)XqC35J97SoKPTm3cG++$498OH&F>#Nxa*C( z(|#~uMZwqc`QEhbA`XGl2FD^4vSU%Obh3FeYf>47lbAdz*Wllv2ygf#&A~U=@Jw=zoI8w_Z3@ipg5lA zf49a38jl@=?%wHbfHp@(J7)-1Fhm}8R-O!e-8#bpRl4t=Zq~m&9Z|j7Yg~7^fx?^T z(rMi`-Qyr_|Lz+{KgTt$%Y!9Hvy$0$0r(4^|Mw`P>V;hgFN^?3`t}GhR1->+jlM#d zvnZBFiw$GnTVy13+59NiQg>&xY=NFN0k_of$xN&9iJj!w9c--kR_KIfh-LE%cECuM zz>SWJPhF>U1u+;#1+mlN?7tk2yWLNZ4*FwGdYIF0l907Qt2-;LoK?rU<=JzRG;{EN z#>z_%kggw|#F3qi+(1XOlfJIMcQ9pXmK@$JbN&qDVaYhN9~b_|cNeLcFuD$=qtd2f zV1*#R=bNS-9~(9C(AuAN(T!hUNYZ@MQP&KphkJ_Xw5n0Zm^eSA-F|-iNKfuBQDAX8 z?TK);Bgn$*)<^gi8LgP!9ciU?a&U%AMuXSDVw?o(j9*TW6ZRH)QXpPc$nOG@9yk)e zr?3B9@m>;7qL>AKc3Am5$mQnxvn5l&CpA-RY2D{&rbfO|Q9^8JDWo8?vp|BkXQ@K@ z+k{R^MyqN(B3KR7z7qF`W*%O$?lYfE<*&4~nO7R7mHnZVCO1RiTRDp01&da0Vc2nG z@C=Rk@wWlxqnTMhXmW&q^6$}IP(MWa$Cp<=D@go3&wBTojbJ^h_rrGo30zIwv~ycu zI&$A0ubRsPaAd({($G7h_-$InETg`-h@TRH-P~fI&w7Xd-yXJ*m%ho~Um>K!k^LTB z{juufe9IL;B`J17(*Us&{X}|FOmuSJSNn4tkvjpRFB1ffL>Q%AmTNDwlO}eeCP2xw ziy_lHVR$eL?8$CrzT$19-#_)Fi-U=*g%$UQav_h+nq0pNFL9r>>zm$SJO1|;Xj8y8 zO0j(_os1bWri$4z#>^t%q99E%dbX+f1JG;ylh6~ZwBA>V4q$q}Mtk1wS;M5xVRn-m zjq6_vA=d*9y;OB^rRL!Isnws~_3@@P2QM!`W;I#j`ZR{^A!2{QpML{qiBX-0W8qPM zKiG1b-+l9Gv|nhdV#UL|$305;buh@SzdLJHyfo%<=i+%xWHiJi=1X+oKvT?>xRtb& z#^Z~!sJyAZ(^Z^9v94ZO;UF4bJ2A(gZo40@jA1h8HrlM!H;_9osGb zt$=3nIq>H+rX1J5@IRdNbO+l#^dL0ZKS%q%DS3)xTk)<)yk*32S1#`QSOs*MXYaC2FeiOZ#*?h)`>Jz|`L!2~MWZYr z=N-t==={FX{F!sTOI~-$-~4W%J(9U%M2?-`h?qk#A#w`3vzaHlQgU&JQse2A;3HXe$8Sa^3%C?nG`y42ZXGEaWt# zQQMHv-+aD0&*SeBO!=?NfJ@T5R_xo1@dBCToJp3zN~i5O_uXCtPo)p7ubSj7ba4g6 zZf_^7zE@z19y9O7K90^U7%W;z)Vc5s(%`x2Z3ae4m3-0?u^1wDUpyY4cTqpA8pJxB zEh+HTXIHk7{q1s#dpGZDG#}VYd-n^ATaPuz(?XKrV@mJW3b^l%>Ya%<=eDf(FM8Yp z_??B&IH+;+u}o<1mya~~*k_k-8j^m|wdkZgA0*HX_HbhMfWV(e>u&#JXheR>%-<{rE z4!pFuPv@TVz_RJ4_&)%W$Hu?p{9s|mr}?_OlF=9MaEQMP!Q?9aoD)`5 zjKSlA7%`m0Cn1WG8tB|_mR43iDU_uAw(N?3k3~9lnX|4!ZRJSrBd2Xo7gR;ZR$#8E zQ&pb2Q{me{LewHxmY4}_eRO=`Of_kD=!bv3d`2^VyX{U{oy$j(Y{9RkhClko7hJWf zwZn1GD#pgCGNe69A;IHQ+GO2e;=8%5rR!RmjX(LPpj}NNwGJxJREfn3N6;b?LBfwM zWUL1`dKX%rsk?EhfR?+>2X}%=qhq(HqK^fGnEP|lLm)zD$U8*oN|p9X^Q`vL?tZqjlNNp^#p!WZ z9;!a2lT3rhEt(! z-#px>F~*HB<4-iY@PNe5LEG2T^{XI>LuXrxKg~vf=^oR`+H}-Ja&1E=6;mr>@8&30 zE{nd)BLo3VR0aG7#xxCSt z^8~D$A)G5ea}>pbP!HQPJM&JoC;V3dRKLxrKIqbXyMF|;dnbpSQSrX!qkpsm( z`8D3GVd&NUKF(hL+vB?=ynQo*mALGW9cSfr-m2lk6|okn!S}#@gDjoQ>n>yS(Q=tH zu~Lpfej~#ng|R%pO1GOSA~K_sOdL= z^t&?$LK`AdjkqNi5`-6~_uD_8nz$CCQF#5znJ*}LZ!)M)f zN^$V~McJZk85rIu{T5J2AT3Mq$K`^_Pl?pYkSvFnXd-EO&eJ*97z1r@OO8n`v$R}{ z^4&~G4{or90T!yHUvMb9YJLbxpn^78*|FVF1I&3huT;F|Gy}RV(tfPO^A>P|q0D7c zkn6wqNl4-&ToR$&V{Fg=ZllGd(%)^@@-JvSYk-PXk`99ebQ% z{8~f%n`q{}*^%z!>j!Y~TB9aog*U!xSc%x^EHvpP%p8Mc-5q;F#Y4;o{tSFgG(f zfxc`Mx4Kd2F6Fn-oB(fbtg|wj9K3bk25)~%6YrxB&vBChV#P3%lHAY--$x=UDq1(9 z5ZOtbB@Bid<>;B2FYPaNaNZ}RxhO#9#^Xm5`K^mMR&%wuaZ6Jw@~X13s41vD zM}Z-8M!kPKpYuf!5&vz}x2@8~+bPisn|c{Dc_d{zp^l0q_@1rn{23!v9`p_WPegB@ zj^llq?_Y!rwR40rE{YzjbT#!P-P+KaH(c(uPGqu5))5CUF^EB|9~$5OHmnk3@d}96 z{Sx?UF#Qs%n$)5T0J=%VJeqNA$QTZRO=?vJ>_y)FFW5K zpDl~c=84tTz5YekMpzM7F|ePuwlZ1B6l(-s-wMYI%PUmrmy~S-GC%I>(Zp93YrAO zX(NTy$q-wa5+E^l*v2&)nZI^$$**iW>uKY0Oh0k%j4ud+kj+VZ9z}W1lb9DoO4@M1 zrE6sB$p)m5k9VXXXfPj}356OTU?&k6cd5LrKO>JDw*Op^*9J zXDsp#+d00SLTg;uc0_AL+3OFRtW=UAXBcuG)7zMD89p7uS*=BIRI4-MNJz~^H z(uW1UI*k<$D*Qrah)JoeB)B$<-$;MSOBQ}}M%SmUGI9vXYP>fXBkn>+)}qq7%A-Fo zYx?pTkl(;!QT}nvP2O8Vbrtn+jMED-!7S#Ka523EnjSTc={7!`e4PGweFj4!K{a!t z>KAC?-N8zLM4x`XN!d^vYT1O^-FjCl=;FRo7^Hf4zt@q0we&pvRXk^s$$HYHu-U=; zBi1D~-*vUmm1@)d3oFSa#E5-sM(C(MjXQ6P5VO|cVV34erJvEk6Ym8dCP@9|^OO

H=^| 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%0As2Y5b8sy;rCQk#!$>reglU;HUeE?<k!(J zpC8Zg=-+vEexiz#3j#YaLJZD=ZvffX_13@ygZ6<>e7_H%@<-+vN)p_@`|o`4==|Zw z@u!U{&bep=l2rR$U+-F1{zudjH0D}IoxC~+>sNT)4ap8HT9;#uee=*=0?2TF4NgGr#&ks++MiPC4WDIfgF z32Hx8p)5?;OP4h^zQ)A`d+(sKWwk01?Mb_^#t$4Rm5O!Zoqq}4`-<6%GI;>!F{d`i zKk*#{aQ*w@&-k*SnZ!VWe^KiACjpH$#q{x%)aKej-9~uVN32K2V4%~5MlXRJ3G zh77u^v<^yH+t2!$e<-|)+kW6T4|SE}gjnrB#(&Xjm}RxI=t%qb_Wu2sKJ%-;W56Hn z+yg(l09Xx#D_{77Kfe6wPd=+*_OJFJ^FY%;Zn|t~-c3@@P!rHTp62x5h(iOf(Gtu%Q*m<$qoGwbr_?wbt|OXFCsQ5)4QkK#d7TTps(&!ar!6hs*(aXjycS z6T_!`13Z*Dn>pMi^083&bVjCRv5Ew~aFwL?d zHef~t>0$?_esb?D9Gik$`c`b)_<5j*cu0)qB`$oQt7WoFd=9#K000LTymuC z7%|)+G+lZQy?G4w`0c0rt&Y_>b93UJc)~Sy`Lm8_`p|XK6C$AlR=w#A;ikzh3#RT+ z;CRIqkGmG^H0B33dg%BoZAlj+?)3E&T4VDUXo7*Iro!#2EKL|J!jiFG8q;xTz#Wqf zv_S_J7$~VH5I=4uI9m$C^N-xn;0hQGHxZ@b9Wy28OeewO#g$-8&}I2S!fe93uY#$Nw7umzKUO{I7nL zHNByyUo$XI0M=YA)c^a$CobRe2VV2@IxycRroLX~321%ro^n7xevh0GoN)9xIoAI1 zqb~@Yyn?rf>b;<7Uhw>a&zJ$NXogA9n}rAyk5S?Upnn%k%)Ep`NXPHM zJ2n`SSwA$I{wx7d2(>9QKg}@hXqZF%CH6?{AGw*5Yk|)q3XpVAQ!8ta!!lZYMZq#p zhs9pSG#V8bBisW^9NKmd&%Tg@B?;6@Gpax=f!>S*AZ*#VXvde7%FYfvf`_f~Y$qLy zg{K}2=D%IwUGv1tWnK`7cew)>O*CM(CtnYgDl?%l;|O_t!QZ{&_W_il&ehUivoE7t(K4N|n}TeJI}>NjJke7n(a=^GXc_@4hG`Wn$UeCpm0e*T6*KZAhg z0^pRLsecXmYcG7wtN)h{&OdXC%Hv}9sYy>;4%ZBe(~>$_mJ@kVIyZ5)zjH$!Q%vy- z+snEZtrSOi8_zn?JriwM)RtL6)F5veV8fk1_VqRbcT}b1yQW?=&{$|`$c8&F_k{@x zMYda^ixf0Qgk zo?(nofaOU+w-9FHFR*~(i)Ovj^_~9-w18bC6U>1R_`=}Hhi!1`pfo;ZpW~t;fdmR&ny)z+^I{`C^AtNO&N;Smio%k!=$m!s2I%&U za?^?8O!FJAaCST$8U2>NzEHFlyhVTV_mS(f`qt5&3xI3vYkeNzlefI)_UG#O`<}fT zCnUwdC#Hl=uf=F@aX8){(p&ma>&A_?KDPAY;p{VVQH9vf-?WNg7hZ~lYmM}r4;OFf zI+fj1+LFTtZX4Ua9^cWI#chPfUf=6IfXL`5T`{GOE+*!vQSWF5^6tQ)D*a1-ERY^K%Mc!d+D1D0m znyY?)9H8I=(uES-@E0G(6ra#*8juuL^F&*qtuH3Pwam^pGBLZ*lRxDH&wg1SdkEQk zpGeqEj=1}GOmxJAeYwaUHambtqUmP6wyz$Zgg%aa0av=`=w#|fzom;^z#2K$Qi|I} zWlL<&fA~+y_3!G1cR%z+3Qr^!z0sG`VumA0;0KK#Ks?ofPVVSGP1)z9M zN!Ouk)m`Ak10oxB=%~POpT+<@7B}DfVQdBI@MEyhrB0^>6;NT=C!45k-lO> zj;&t#^TX(rG(xl3Ytqrnw(G#LS>F!wv;r|av#_)iZMy2zU9COC)=$sN>ej~&BJv;_ z*t~#JfHPNo#;MQS=0>U;2|ZCv0gConu6Pl-GcFEQq2Jv>Uu z`^&B?D(P}W;e(KJu*4TU$DjcD?g;x8ruh{#zAA9rbKRzYM46mOv|LP7zNM_WNFZOh zbFSh&45JaJ{BTU^7Z*Mc(X&5LL|X_ecm~ua^U=J-ckvew9cJA7qI;0UOrytT`R(%+ zSLWf87p;5foqdPqeq+1fVSzlU)?#B>g8-q)D_hrDJV&S(!gV`bpd7{%)Yl>@zb)^A;UHMUsxImgM%GPIt=JB^(` zFt)9Io#y|#rwtg0R7f<8&+W_3zlONQ$%A{v*EM@Cr5D(0p@26+krYMsac+pFS z!WU0q#?gZ7yIlD}msT!i$5RA5RKMc%K5;yye2IaYF$B&R8WF5xU~|G8Y(N~W^zC1| zK=p3U#l_}A#o-8-FFvAZl~3}b2=lLu`TAnfc56AFGPll!`;7uf*wZ%~D+bOFq@48r zIq&gd3~w5Lj)E5)Oqa3aNi4pIQ!KuS zD*w_%eTg-5FiZ27%sv~JiyRo4e=dnL=Uit%i(3~hL0PxgyMMRG1SrSs;pxRbI%jh(;ftlRdb9Dd}=(R1GLxx4QA$|@b| zv<%E!0Hj?T**}Gc7XTr^EHP_Q6`q%1)bOJts!@eWJME%Vgbp?q%u| zgp;U{;Wy7L4@8whdGkrtCsz09H%-rrIJ)Ssti59{UaZ%Xbvyr#ouE=z)$?o~ ztn#NWa1KEO76davb^s!RO0n9Tf22(1kY*uf+B*8#BqB}OJ`@*2i>|>!7v*JOQTp=@Q zl(G}e%xotbf`Ulw6gZ&LheyibMZfK;w{1ChK^9FB#kYDtK>!mT2>CUj3$SG5FX5(l z+@*(a8Y#-%HV`m{CLLPF;G1jZ$l}wF*70G7;j!<~vCV>`u?>UO=MiE&V(vgRN(4AW zS0iL~99SEcc;wMp*DU?dS|@UHeanF3!7TJb2PvV|4 zJq>>I%-Bt^5I=GC%1v+h?A>=iYMt4fnStc(Otn$y47~K6fBx2s7jOEA&hgv&7_^Lo zn$@8W?uGW^bL@Q8sk8F1Z!CHmr(d6BjGLxEsqOVoLR@+0!P{!DbbH^&?|EykoT#LL z=+Plu7jnt`HMcdU4E8K@()UFJp9v5xkjxo-(X!`T{Qz9>J!aVl3R&Gji}sy%Wzlg! z^EHC%(Pxq6ZkxZ&&gT%20TX!!XcLR{B_<~O7sDUK0@F4UB4dv9`AtaR7Xw|BaaDTc z$r9;j#>5vJDK@lJ5fYimO`++^>%$fo4vFHy-&EbCq*0`>8mp(WqRVIZ~sJ)*pnRpSILD3!eL|nAY{)W15LK>88=N2H@fj^*1_gb z`z$n>l5-~I+~c5VdWL#6CgY~*X~*S<^Eor#qoao|U-`y&fA;PVJ{-i^o+ktI1mHYb z+UR`gWB={ZZ@=UB{Tm(Ve^w{k^ICNL0A3I3*6D+tai6H^_oG-G>aV=!cYhqOeP?lr z<>40Fjv0FYLi;rQ!)Vc5vB0FI$O=OsUK6zODa}}N%CA;=(P4_<~1>AS*mQ~`=vWYe<-0>$|Eqd^? z_BFq-Ws6@LUE?QGXANd%O3Vg|pJlNwxnZD*XYpUQVs){_pH)!fhk>vw`xc^uyN(S5 z)7C}5aRIYun8i;FaH)Zq@N&^>Rrnj3ZG|IP>somk6b}%!#hn0>**w`91OAmx z`8Vmvy#)~mW1j@3HSl^?XJFIxTip^L@W5-VmhTzYgNHxtu`!Oirrj5NBNk`Z_5roB z!4KzzCF(4h5YWAam!j82T_?XVfN@`VZ690AMIm#iV(qWmg@t_t^xWCAUfZ{NBosE= zMJ?ma?Q^jUry>;=813Ih9fqq%fCna}Y(lFl4S0mmRT|cY@BZ#h}Gq z7IFO3>|VOel>&nf2zkyBtWuSLaW&%6kULhhwee$U#PESHb!ZPYc0p&Xm+cW$qc9Rr z8t=a|`!s0Bk51V*c8Sq}CtmdT z?)HoY@8X4rDE^9t*}d? z3yhR{p+7id3b#*+o#~0gv-~$!ouA9OeM)%8d|b=k1z9Ur9{CHA54W*WYbE48^a~a>_oIHTn)WF?^Vpz=z8UKR%&-fIq^7vo2@FigWTs^z+Du7FtM3yK*gM>1;>=Q0rRyiJMpnxrg0X7oHrEX_NRZxs-zago9!c_Q!Ql;THD{9!iYlOA6mj5#^V zlW&7*wk1P5Fq(~ZM<7*e+CoW%o?2Yr- zf1l0skLL-%`EzwlleYt2aK~$ZN=x4#(dl&CdRna~A17g-h}{S1@D%I?oSqkFX!pk) zi_uv0v_4n*{8`R!{@=WdfD~N32%wtp zmb1JjDDj;WdQ#eEr%u__XJ&JXiu;OedcSO7RSz|#C4e1W?w z{BM25Y3Z}M3wRtO{o)1b`W@G7slU$YybD)t>uqg7v zv@dPkOO6=6oHPxGhA2-Rf*(~35z2n`Ugp85#nzhT-EtxG;So zfqc=AlUzi}C&vi7i_S{h#tzA00L&|F7e}dsffP%4)-+7=1Q}})Y8ElyVuA^Y4?iIg z!-X1M1QcZHBex0q#HC*sFUCO?CK?iGm;3Vp+Q%(J0!`2H%JGkW%h>_I<}WPpiQ?Kk zZSg8ymN(D&z7?SW04A$RL_t(qvNbrpUUW+z5{~&+Ev(fK4lS`>_ThOQ%2K9a(QoO~ z@O`dJ==0eI*Z~sv`>oE^k6gL(tT%l2o_l|JFPriCGcXqb=g-xNnvdRp|8G41wXgfB zqYIDSt_A*;>k->05B=duz;P{(PYn7k9a;3PC2@E;IaYk6YdbuF#P4%;Ie{SXwJ=s= zBy!SM;b+C?h^4gjHaLurg^YqsLzDXE1%dj9zU^56lLwYab_Lea9sH!qa1@kG=1TL4`R*nB^kd3NZ02kHYYW7TgPS{&!P?ypXaidZro$D z((ud?vGnCI@wP*x?UT7MhxAt8VD9mWFFIR&&WB(sO&_{^^qe=%r~S#)<7;yP@c8m) zYyL#=(KmhjPru;NTVAHq;`W>xoZ@{FoaCfs^t3)%PCJ3JkZ=;@L|A)HptSCD;#}*5 ziw*ky+WRlar9-cEU^bn2nJddVJwM13XEd>ZOG7ig=w>b)E$u^_?$-qZvfTX| zm7Lf4EyKy@pcI@f7cu7H_2g^?j;CU2X z`219aWs6bg*4W6h%V>{Kq@pEvrQjiXbR}+7+)Sbu!_z5ST<5yG^OO?3@Wq)9EUuC$ae{# zi-CZQ867->VH`G+^&K;Xl;-nEfq-chbozqBhGj3~oO{fy?25vJg;y~0ID=|n&Zd}wQ)X=8tG0T?{;fMG*OVWZ_gU8X4Z*b{p zO*<}g;1fyYy>IDhJ^$n|yuDr&9zw%BB7qngk zg5Qfn&aFe95X3ewViiWk2T%Q>ZYj`t3XOy+ypCnSaiJT2$;7q zBqtt^nMsZ5pkdi<3VQEYF!Lqzu^GSF;vf69&%9Ti*6Tj{v4D3{L+433|2vnJ3s1!X zB`51#U^Z`!J+nS~M0tD5!*|~||JdiGo%uL;a{=%;l6RWvqxvs`FTCTsep;V?|HC?_ zU$Pw6>+vmC206fk)VvdMIgv1-r!~F7jG(t$g;wgmb*`gtQ+i%!#nbw&s_|y6Hw0rZ zf5-}UTbwcILc$XR^t}j67Yl40M%kxeSt!2bT1a?zUsHE@sHBv+gS_Vn((L0A9K9zJbkH%*BTpvDscAbWtT9 zKW?{OocL4n1%NNGf%c2|880E^0wrVO&mFmaB^c`lBSz@vsYh`6Cq;ITScET(3g3F= z(=zcbaxZ&iOE5A9t8}bqBiU$>nB=1y3O#RlP1B#)whFD({fP$GX0|Ckue0LW>x14r zlNpfBX0H^=OZ(`NzTNMkd*)yIE*ZIj#9RQ}z+`Tr9=ZSiU%TyhzVhC$-h9(7IaBz4b;sGY1MOkZpY)Q=IJq}}iZGj6acS&N;&KJpm{2cqD-x(6mpq2h1jbIUn73b_ zDj)aareb=ZirlzH;DsILa;9P`5Ov#o5d`?uhu&Wf zC~v%|PcbfPB^Pq!(uD6`ES0kcH31tec&})nV4n&7Wx6rq{w0Ar-kBE6p3DoLL5>hw z9hGY;N7ixW=ibQ+JJ$S`m$kM&_)>M)l{DqOecdD(1P$j#Vo{-(=CCeu#cw=Ly%9m) zzekgZyLY-wqGF}*xG5TtbuVG3763o_K^FSWUcav@pmfvSdhdeTrMaX7-S43fS%kYf zrt$BKkU?q+{>=M2YSn|SPA}eN=u8jFUgV&p_%Y^i?Fp`apY{ zKDu+!!OMC@i{2svbyxcyiACG^es08?NjYqG(t^Q*8X&xt9a7#sry#P1Tj1D;!bx3= z{+X=IKWhdyqSZ|pM_3Or5Fw}8?@qJM^878|Ne@PuzZ|D3qX?y40zKcV6O?D+8hEk8 zy8#Og58zm(XV_72&&Ei~v{^z`72MlhuneB+E{_+a*072zb~?2O7YON@)vK5~S?e1v zdOD0rVO1OD{NViDs?%AW9W5c_sh5KyourWGOsA-06OdPJ|NKOqe#Y$3mm;kWH1sP0 za$A5=BCWfvlq<>DS^xgzm%sezsp~OvgvtfI6Q{M_g&mG;)5#>GHT-bP5Sa=Eedg0e zMk&1I7sKw*TzTd5@MKW)FB$gOrDh`+rCl>^C6`$W;BN9ukF&X+|Lq(Uy~ypsi(c95 z>ZRBBr;tc4*113ZIhba>hhbDFTXpZgNL~pMC2WEy2xUmWFNaT;0@en;%ka!n{Um0geA=|nW2*??qUAqcTiz4!*RJumIQ5N!#6X-BVHn0TtjZ%qO2dzU0tidMk-X^7XkND z*oYmh?I@4X%a+@UaXvnEE+z8Or@NSs+Mod{`{!wGJC=75t&u`70?r3%-y4 zp0LZEHwQBn`M3}fr7v*(69zF#t|pOivQ4U$L_V z7D$6BH3N%}pq(|18Ve>`K`DJZ8fZ0lN~va+s!#Q0WA?NB-5Jo>=YTx$1m+t++f&0S z4XD+DL6z1`hY>0PI}uLJP!v<3Quo^RHD07^mE@P1hd*%o;f66$nlIwmf5`{JvEXdE4v-~ZNNl79usphrG zqhbw%dG*6?KCggOjneWF;@Rh`ZwEMdd%8ampgz_nXCbvK8qMAvo_#Vi6HI^}=kQmi zWV;kAuM%5spw^gFj>WU%dz-W!U-$!w&+e|wb-qQ*ieas1%|Oxn)!xZUOFprcZmSo_ zMt*_bx<2<+&EUcNPhIl%h|HDW#e@}=KcVStZUuW-Bn>3mPMOw~|Lof7fAvqLb*ckm z)Sd)$vC6NS%ddK{gM|59l#I$6uH06?Vd5x-98Ig)JW#=M=J-r{>oTDcU!=*&OkCtU zmIg@3OQ?eOy`yFWiEZ+CmxFYZY8)rgT`A_d=4N-p5_<7!6p7_??Ig}iA~Uuao==+= zp}#^mt;-DpW{fe5zR2sbXtAlJ#Necht)x|NKZAy|eXqN>w3Tf|A6m=;L$dr|?^Dke z*{fHyqA?hkU@=ge$Trk(mz#Cae*JyR`vkpI8}7N7AB4)F*U|jQqh7P2j_wJC#8}Bw zqoWoFQ?BMm;Uu-tI&KEI%}yAdh(fg%(dgM5bK_XvIX=Xn)QX6YI)Ig2nM_Q6Lb^g& zMFJ*YmOHAjTr@Rpzpt>gK&foK_~=Vo<6tNs0EJah;uazZEX6#DfAG*v(&OT(VY@jT zIT8djJBZ^T;8)-BIu+i*hTe?iJN?F@WTh;G;+GO1duNYW3VNu}cG=Z3J)CD4`9qYf<%-p*8zm*(L9k0d+aMwT5cq;xJ zy7KPEf0YY=0uHTE3#n;4w`7uBXbYvHkUIB&N^fYeR%+hCc>TyHt}-P{$Y}{9@pk=F zOZ+niJ*jdV>s5*62Q0;12mN>H#hpVXqYkmuCjkb56`W}`6%SHW3-bC0_ z46kX_IAe@OV4G&y82#IO!ZJ48H_?cc(zBhS-)u3gm(rwH@?A{r;f(pq_-$jn zq3)dIDgne#;H$Xfj7$5+h7snfbQ^ugKh{*(c}7=WD0<)wiVV|RDH6l=X)LL;k7D;P zoA>mG-D#)2I&e4X8wuZu?A1UEX;p9tIZsVn_78 zM+`B^8pO}=X5P$o#)sd&lHhTP2Du#xy^44#$ySb*&2vZ>D6`q3N%KH-04q)d}QG)sescoh|y{i?}Xd#oz3e zy&dUiaSw$c2f`x0p^C*n?M2acW0IN~pwD?xcg!Vj_%N-H3!)?<;9eEo*4@*xH7^w|E4-KyyjnHx-d1c{Yw zLr_YRi9*Dsi8)LpjvzFKQbQ{XQW2pUC5!AfhN5jMbCr^lV;IQtjs&f1xX>e|d#7o* z7IyP4rY8QE|CTv!k|&2qY}E+E1Q}gjBR&1H^7o}}SvBKS4?SbYLng0U+d<1qN2#63 z#*IhdGay|cYSmZz==Y@Lqx=3rgqx(KH>h{AR^X`0YtcI*`DtgPMyId-@oC~-U>1G6E|9jmaVOR zsU-};M|YMpCD7PS{W;4Ps$v>K_ArrT^cCf(cdz)?N38rQ z%f%;yN!7UA2Ei^tg}`wC!m*I;z#zHArJ5dMp2}crX;sPb~Yn|xlD zUW2+^chfIew8mY!UtwiQs+JAx4Pv&Rcz9p(t*kN<5GnhMp6RN*GfhnU&)A7C##6GVQJ2je*K?mynvJQl>`b5t^}PHfDOxEU1TU9C}VZ5)vsuQ~}_em{DKC$^_` zfBLv*$P_SA8KPo_J2SSDtU)g|np}U6c8TJay1J||F%9O{+t!q`O;Xb23G}dxzbOgl z*=A#^ZS|J>0Uht|*-s5=7?#UL;a7||aUWamcLsW(c6NFp?ibgPM&$^fKNdM&7w@0J zqRdl*A9NO?ZEFKzDv%fF3+SfCZfn?>)}H&+Lt?@I6VPT-GV`kQf^RbL;ENUL_3CcQ8rW>aX#47|5TN%vN9si7K62X@dH8^s|W< zudJ+xLGzgtgV)pyhOfS@0)y9t4PHZO-z`` zYd1rqyPl<$6YRvS#IsN_47*;2#_dt}bR@m*-Rl5B_6FxxLfd!{!R!V{QK=+hJWUgi zdCgA3h;j0GBH~mu03CI#EXW$(`QEdO6TFf~o(W%8Yd-GzcC82B0r<-PT{4fGwpi!bqzB__JX9ZHsyQu z9=ZBqt9zU;Ls8NG@zA2f`S^yD0bUG;UQB}h9~+?i>ea1FvL+riqZV;&G1dj79{Wr0 zbNKGmfG0n4{|_M!nUyFVeCMB*idbZmi4rF~O&=288;yiaxZoR?zm%5jedk|tj90R!5TvukWav$k)U0No6n z@?-#aOI%1Eef1=DuIH$A+!e&;HJr(@A6Hb*{oEeDywmW(-n2+oZqxa`ef+D%Ry|qI zbO?2J(78To3^NR0HG=ybPY%kh3A|)t%%sYehRBTDNtyd3VYFxmvN0e`w8~ed_fH3m zYpA^t4+cl`wcJ#X014>}Duu7`n***x5D!D&)pxp25ZNP>zw+R|{g3RLIl_P88PAZ3 z;>vo1f!%MPVjK%(RE&q132gFnIHEZ4VO*Y;A06@|r=4jVWK;-B;d9u7?l`@Eb@*zz z&$W-xRfcc#pXtA-|I|DZ_Ihi(?J#Bhqm-S=U4R=_9f(=t`PL>0&c3Bthr38uUr^mh z_B##`8y_>nfP4cR9@*PYVHU>>W(P);(Cq^gd5BiQOr>yB=k$*|rugI%>>=fgw082v zY=dqdBzk*eZi*V4q?w*Al0VF0b1wkp&{^`ulQU<@Yl07-52glAqz3Jx{pvXk)^W+^ z>%K9ILQI3B|29c^K*hx22$mQn))hyRN_(v08b;Bf#NH>EzJ(s1V!Dl%h`6yfbt07% z@Y4YT0qsDT2$cQwpDrBF&fx>Pso-T8Lb6|XKEKk81C19`uqZ2-w5U`DBpau+GLA)5 zSRqpCX@opfza>YzcOVK!I?WnQOgcY?v3bmCn-k|l^-2`_*zkU|IFdB<&h>R=I|q3z ztJI%k^KX>m_7mw}!LjQC_{Xr7OtQFhPUV9`QYYqsP|y+aO7sg`S{jDIbY+x*iJA@L z7?Y$l?>2wyW%QS*F?l!60}@6hoDT;Gz`&9psA|V{xr&`md>l<^^8WE#7uXviHsl74 zp=6Gd!>X*lTz|Abd{D;jrRylYx2<68B$m~pQ0qdxEX7dU){_caISX@6<*ALYlHe;# z{a1@=hwD$~?ti^SdYx9o&jqvLyVk64Rjd7*EQ4+(_Zbqf{cVoCcE~nficjBvFLaMk zcbLxGCjx=&b@z{}>AuwcEZPUgR&R(j&EPK@)vO`9Bf^Y7|Baz0fdUJN^DREPeB^8J zEvy`-KI65E*9!}mW_)KS33>L*puwctb07s$s}9aT>I9fyR*UZaA*6;FYksE-gj3kI z33x;f<=0R#k;O4W*!O3>lgrMyh+Ceq`x1kOm*w9_-Z0AwAfgzk3_iSf$>3%P&tUxh zj@A|BQJds=UH?Sq^%&I8h`*a?D`fI!^HDtwpAGSg_tojO+P*a$Ux>3A8pXstkNsm} zNmIb)xNS|o)C>)u-pm+(la~>aVP?i8R8fO_gY^h9lApoWuY6yz zQ&;kn$@7%LE*~TKS?#O*6Vap7^@()o`QBik%0C3>K zf)Vs`XMnBM$K&#WN^As19ZnOKoQlQ_N-U*%UPa8YYw7#n&g+8{|AS?@Ox!#qXVCL{{j+J#* zN>wysiiC~(!OI%$_0v|N^SO}N`v{!t3b^Z9XG)pT4f*|r|Kasok6E9!4F1)T55B5p zq01-yfCYWz)mnyg(@E#sJaY|_UpCa zsx~s+7I;#*rk-zUb*x^$5Fu_@ST~Rk@=VaGhc|DBPbZKTOZxrMdEn)b7iHxWC+HWYAIc{f&t8-Y3> ziZ3=_?T0Z?j}8fcY^y{=E4urd_BqzX1;J)tMmV>vW}cy~IdeQzDrMr?-H|G9zv3e% z;zTb(mYj^9$w$$J+h1V;?{Jdv6?Y!f>KM{+?-+VW9d_(em=_b0ei0srYUTAi8&Ytg z=6I;hsFi3%;!bU`9Al=-qWDm%94W}fpd zUHje=?<(jyCWymV#*TbvZ8x}B6mlOJa8T9^Ke6Z%k%oygjNN66Fz5=pEGz^bJ!_iX z%3BeU>bWC(>441bzNxp7^-Vkof)#Hb{}s1E`{U|lm%cdXP3{n)3w#Lee@nU9Na?Dc zIQ{;>|9QK-=oj}g4Zv3`cT`sO_f~nYUL+6xBXLH5bJ+BiARc{tj^@j-l=|b!S@Rpq zcKP0>H+6A8GK+AJ`!n{GZi!k>+G;gz`zlhSROn$jIniC6ZtvQ2(#>+74(9W=Hw@^^ zMRG}6eWOZKdFH!7!vr$mQumi_zp;VwJ$|6}uH|3Tds8k^ct%M;Nim)0rS~%*50#e6 zMzr+O={q)2YVkqmM5!w#RI0#*=~^?HU*6Z3fvwwR2KkJ_TgjsSk#Y4bE^LiLLgz^_ zgU6OUc;6iI2-spy%Ev7YWvPmtSNJubC=AJZqaEDeq;cC;fq9jl@of_g*QCo0FQ1~k zYX?4`F|`x};q3RuldABtCnxslyUu<0<#qwtzzyK{1Ta@6IqGj8fV_3PNZQNLNzq$? zk0F&*75_OPDYzeDVe tuple[bool, str]: @@ -43,7 +70,7 @@ def open_window(url: str, width: int = 1180, height: int = 800) -> int: return 0 import objc - from Foundation import NSObject, NSURL, NSURLRequest, NSMakeRect + from Foundation import NSBundle, NSObject, NSProcessInfo, NSURL, NSURLRequest, NSMakeRect # AppKit and WebKit classes by name: pyobjc-core resolves them out of the # loaded frameworks without their wrapper packages. @@ -60,16 +87,29 @@ def open_window(url: str, width: int = 1180, height: int = 800) -> int: webbrowser.open(url) return 0 + # An app called Symbio, not "Python": the menu bar reads the bundle name, + # which has to be set before the application object exists. + info = NSBundle.mainBundle().localizedInfoDictionary() or NSBundle.mainBundle().infoDictionary() + if info is not None: + info["CFBundleName"] = "Symbio" + NSProcessInfo.processInfo().setProcessName_("Symbio") + app = NSApplication.sharedApplication() # Regular, so it gets a Dock icon, a menu bar and keyboard focus. An # accessory app would open a window nobody can type into. app.setActivationPolicy_(0) + icon = namespace["NSImage"].alloc().initWithContentsOfFile_(str(ICON)) + if icon is not None: + app.setApplicationIconImage_(icon) + app.setMainMenu_(_main_menu(namespace["NSMenu"], namespace["NSMenuItem"])) style = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) # titled, closable, mini, resizable window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( NSMakeRect(0, 0, width, height), style, 2, False) window.setTitle_("Symbio") window.center() + # Where the user last left it, and how big. + window.setFrameAutosaveName_("SymbioChat") view = WKWebView.alloc().initWithFrame_(NSMakeRect(0, 0, width, height)) view.setAutoresizingMask_((1 << 1) | (1 << 4)) # width | height diff --git a/symbio_pet/cli.py b/symbio_pet/cli.py index 82a83f3..486878b 100644 --- a/symbio_pet/cli.py +++ b/symbio_pet/cli.py @@ -102,8 +102,6 @@ def main(argv: list[str] | None = None) -> int: description="A desktop cat that shows Symbio fine-tuning itself.") parser.add_argument("--demo", action="store_true", help="Play a scripted run instead of watching Symbio") - parser.add_argument("--port", type=int, default=8742, - help="Port of the chat window a double-click opens (default 8742)") parser.add_argument("--snapshot", metavar="DIR", help="Render the demo's key frames to PNGs in DIR and exit") args = parser.parse_args(argv) @@ -141,7 +139,7 @@ def main(argv: list[str] | None = None) -> int: # buffers, so a piped banner would otherwise never appear. print(" Ctrl+C to stop\n", flush=True) pet = Pet(feed, position_file=constants.LOG_DIR / "pet_position.json", - log_dir=constants.LOG_DIR, port=args.port, pid_file=pid_file) + log_dir=constants.LOG_DIR, pid_file=pid_file) return pet.run() diff --git a/symbio_pet/view.py b/symbio_pet/view.py index 2b69d8e..b72550c 100644 --- a/symbio_pet/view.py +++ b/symbio_pet/view.py @@ -29,6 +29,7 @@ from AppKit import ( NSAffineTransform, NSApplication, + NSApplicationActivateIgnoringOtherApps, NSApplicationActivationPolicyAccessory, NSAttributedString, NSBackingStoreBuffered, @@ -50,6 +51,7 @@ NSMenu, NSMenuItem, NSPanel, + NSRunningApplication, NSScreen, NSShadow, NSView, @@ -633,6 +635,46 @@ def at(step, loss): # ── rendering without a window ─────────────────────────────────────── +def render_icon(path: Path, size: int = 512) -> None: + """The app icon: the cat's face on a rounded tile, the way macOS draws + them. Used for the chat app's Dock icon (symbio_desktop/static/icon.png).""" + NSApplication.sharedApplication() + cat = Cat(seed=5) + awake = __import__("symbio_pet.feed", fromlist=["Snapshot"]).Snapshot( + presence="awake", has_adapter=True, adapter_iters=600) + for _ in range(60): + cat.update(1 / 30, awake) + cat.look = (0.0, 0.0) + rig = cat.rig() + painter = Painter() + rep = NSBitmapImageRep.alloc().initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel_( + None, size, size, 8, 4, True, False, NSDeviceRGBColorSpace, 0, 0) + context = NSGraphicsContext.graphicsContextWithBitmapImageRep_(rep) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.setCurrentContext_(context) + try: + inset = size * 0.1 # Apple's grid leaves a margin round the tile + tile = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_( + ((inset, inset), (size - 2 * inset, size - 2 * inset)), size * 0.18, size * 0.18) + NSGradient.alloc().initWithStartingColor_endingColor_( + painter.color((0.11, 0.38, 0.36)), painter.color((0.20, 0.62, 0.56)) + ).drawInBezierPath_angle_(tile, 90.0) + tile.addClip() + hx, hy, _, _, _ = rig["head"] + place = NSAffineTransform.transform() + place.translateXBy_yBy_(size / 2, size * 0.53) + place.scaleXBy_yBy_(size / 100.0, size / 100.0) + place.translateXBy_yBy_(-hx - 3.0, -hy) + place.concat() + painter._collar(rig, cat) + painter._head(rig) + context.flushGraphics() + finally: + NSGraphicsContext.restoreGraphicsState() + data = rep.representationUsingType_properties_(NSBitmapImageFileTypePNG, {}) + data.writeToFile_atomically_(str(path), True) + + def render_png(painter: Painter, cat: Cat, path: Path, scale: float = 2.0, background=None) -> None: """Draw one frame into a PNG. For checks and screenshots; needs no window.""" @@ -658,12 +700,11 @@ def render_png(painter: Painter, cat: Cat, path: Path, scale: float = 2.0, # ── the live pet ───────────────────────────────────────────────────── -def _listening(port: int) -> bool: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.3): - return True - except OSError: - return False +def _free_port() -> int: + """A port nothing is listening on, for the chat app's own server.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] class CatView(NSView): @@ -724,13 +765,13 @@ def applicationWillTerminate_(self, notification): class Pet: def __init__(self, feed, position_file: Path | None, log_dir: Path | None, - port: int = 8742, pid_file: Path | None = None): + pid_file: Path | None = None): self.feed = feed self.position_file = position_file self.log_dir = log_dir - self.port = port self.pid_file = pid_file self._delegate = None + self._chat: subprocess.Popen | None = None self.cat = Cat() self.roamer = Roamer() self.painter = Painter() @@ -927,20 +968,20 @@ def context_menu(self, event): NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self.view) def open_chat(self): - """The desktop chat window: reused when it is already serving, - started (detached, so it outlives the pet) when it is not. It opens - the browser itself once it is listening. - - `open`, not webbrowser.open: on macOS that drives the browser over - AppleScript, and a browser that is not answering blocked this thread - (the one that draws) for the two minutes an AppleEvent takes to - time out. + """The chat as its own app: a native Symbio window with its own Dock + icon and menus (symbio_desktop --window), never a browser tab. + + One at a time: while it is open, a double-click brings it forward. + It serves itself on a free port, so a browser-mode desktop someone + started on 8742 is neither needed nor in the way, and it is detached, + so it stays open when the pet goes home. """ - url = f"http://127.0.0.1:{self.port}" - if _listening(self.port): - subprocess.Popen(["open", url], stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return + if self._chat is not None and self._chat.poll() is None: + running = NSRunningApplication.runningApplicationWithProcessIdentifier_( + self._chat.pid) + if running is not None: + running.activateWithOptions_(NSApplicationActivateIgnoringOtherApps) + return log = subprocess.DEVNULL if self.log_dir is not None: try: @@ -949,8 +990,9 @@ def open_chat(self): except OSError: log = subprocess.DEVNULL try: - subprocess.Popen( - [sys.executable, "-m", "symbio_desktop.cli", "--port", str(self.port)], + self._chat = subprocess.Popen( + [sys.executable, "-m", "symbio_desktop.cli", "--window", + "--port", str(_free_port())], cwd=str(Path(__file__).resolve().parent.parent), stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) diff --git a/tests/test_desktop_app.py b/tests/test_desktop_app.py index 518f346..1734da4 100644 --- a/tests/test_desktop_app.py +++ b/tests/test_desktop_app.py @@ -626,3 +626,19 @@ def test_a_turn_that_ends_inside_a_thinking_block_says_so(): assert not [m for m in sent if m["type"] == "token"] assert any("ended inside a thinking block" in m.get("text", "") for m in sent) + + +def test_the_window_is_an_app_with_the_menus_a_mac_app_has(): + """Opened as its own app, not a browser tab: without an Edit menu, ⌘C, + ⌘V and ⌘A do nothing in the page's text box.""" + AppKit = pytest.importorskip("AppKit") + from symbio_desktop import window + + bar = window._main_menu(AppKit.NSMenu, AppKit.NSMenuItem) + menus = {bar.itemAtIndex_(i).submenu().title(): bar.itemAtIndex_(i).submenu() + for i in range(bar.numberOfItems())} + assert list(menus) == ["Symbio", "Edit", "Window"] + actions = {menus["Edit"].itemAtIndex_(i).action() + for i in range(menus["Edit"].numberOfItems())} + assert {"copy:", "paste:", "cut:", "selectAll:", "undo:"} <= actions + assert window.ICON.is_file() and window.ICON.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/tests/test_pet.py b/tests/test_pet.py index e3683d8..fd99724 100644 --- a/tests/test_pet.py +++ b/tests/test_pet.py @@ -562,8 +562,8 @@ def test_a_stale_or_foreign_pid_is_not_a_pet(pet_home): def test_the_pet_runs_as_its_own_light_process(pet_home): """Never the CLI itself, which has the whole agent package loaded.""" - argv = pet_cmd._argv(demo=True, port=9000) - assert argv[1:] == ["-m", "symbio_pet", "--demo", "--port", "9000"] + argv = pet_cmd._argv(demo=True) + assert argv[1:] == ["-m", "symbio_pet", "--demo"] assert pet_cmd._env()["SYMBIO_HOME"] == str(pet_home) @@ -631,6 +631,34 @@ def test_one_cat_at_a_time(tmp_path): assert other_pet(pid_file) is None # stale +def test_double_click_opens_the_chat_as_its_own_app(tmp_path, monkeypatch): + """A native Symbio window, never a browser tab, and never two of them.""" + pytest.importorskip("AppKit") + from symbio_pet import view + + launched, raised = [], [] + + class Chat: + pid = 4321 + + def poll(self): + return None + + class Running: + def activateWithOptions_(self, options): + raised.append(options) + + monkeypatch.setattr(view.subprocess, "Popen", lambda argv, **k: launched.append(argv) or Chat()) + monkeypatch.setattr(view, "NSRunningApplication", types.SimpleNamespace( + runningApplicationWithProcessIdentifier_=lambda pid: Running())) + pet = view.Pet(feed=None, position_file=None, log_dir=tmp_path) + pet.open_chat() + pet.open_chat() + assert len(launched) == 1, "the second double-click must bring the first forward" + assert launched[0][1:4] == ["-m", "symbio_desktop.cli", "--window"] + assert raised + + def test_a_pet_gives_up_only_its_own_pid_file(tmp_path): pytest.importorskip("AppKit") from symbio_pet.view import Pet From a7298b522628a65e60911bb16d9e86df249bc0e6 Mon Sep 17 00:00:00 2001 From: huyedits Date: Sat, 26 Sep 2026 12:39:23 +1000 Subject: [PATCH 3/5] symbio speaks acp now!! `symb acp` for zed/vscode/jetbrains/toad, and `symb connect claude-desktop` hooks it into claude desktop over mcp (ask_symbio + symbio_status) :3 tested w the official sdk clients <3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ryuzz9EtCjeeR8ofVxhGW --- README.md | 27 +++ symbio/app/cli.py | 29 ++++ symbio/app/connect.py | 75 ++++++++ symbio_desktop/acp.py | 325 +++++++++++++++++++++++++++++++++++ symbio_desktop/mcp_bridge.py | 209 ++++++++++++++++++++++ tests/test_bridges.py | 280 ++++++++++++++++++++++++++++++ tests/test_desktop_app.py | 2 +- 7 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 symbio/app/connect.py create mode 100644 symbio_desktop/acp.py create mode 100644 symbio_desktop/mcp_bridge.py create mode 100644 tests/test_bridges.py diff --git a/README.md b/README.md index 0f88833..609de42 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,33 @@ pid and socket, and `ps`. Measured: about 80 MB resident, 4.5% of one core sitting and 3% asleep, more while it walks or eats. It needs PyObjC (`pip install "symbio-cli[pet]"`) and runs on macOS only. +### Symbio in other apps + +```bash +symb acp # an ACP agent on stdio, for Zed, the VS Code and + # JetBrains ACP plugins, Toad, and other ACP hosts +symb connect claude-desktop # Claude Desktop gets Symbio as tools (MCP) +``` + +`symb acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com): +the host starts it, and Symbio's resident model becomes the host's agent. The +reply streams in as the agent's message, Symbio's activity lines arrive as its +thinking, and its approval prompts are asked through the host's own permission +dialog. It starts `symb daemon` itself if no model is loaded. In Zed: + +```json +"agent_servers": { "Symbio": { "command": "symb", "args": ["acp"] } } +``` + +Claude Desktop hosts MCP servers rather than ACP agents, so `symb connect +claude-desktop` registers `symb mcp bridge` in its config instead: Claude gets +`ask_symbio` (a turn with Symbio, which keeps the thread between calls) and +`symbio_status` (model, adapter, and any fine-tune in progress). Approval +prompts cannot be answered from inside a tool call there, so they are declined +and the reply says what was asked. `--remove` takes it out again. + +Both bridges were checked with the official ACP and MCP SDK clients. + ### Staying online ```bash diff --git a/symbio/app/cli.py b/symbio/app/cli.py index 43ebc2e..f884fb2 100644 --- a/symbio/app/cli.py +++ b/symbio/app/cli.py @@ -257,8 +257,19 @@ def _build_parser() -> argparse.ArgumentParser: mcp_build.add_argument("name", help="Tool name") mcp_build.add_argument("--desc", default="", help="Short description of what the tool does") mcp_sub.add_parser("list", help="List generated MCP tools") + mcp_sub.add_parser( + "bridge", help="Serve Symbio's resident model to MCP hosts such as Claude Desktop") mcp_parser.set_defaults(mcp_command="run") + sub.add_parser( + "acp", help="Speak the Agent Client Protocol on stdio, for Zed, VS Code, " + "JetBrains, Toad and other ACP hosts") + connect_parser = sub.add_parser("connect", help="Hook Symbio into another app") + connect_parser.add_argument("target", choices=["claude-desktop"], + help="The app to connect Symbio to") + connect_parser.add_argument("--remove", action="store_true", + help="Disconnect instead") + benchmark_parser = sub.add_parser("benchmark", help="Benchmark Ollama models as local brains") benchmark_parser.add_argument( "--models", @@ -1291,6 +1302,24 @@ def main(argv: list[str] | None = None) -> int: ok = retrain_model(config, digest=not args.no_digest, seed=not args.no_seed) return 0 if ok else 1 + if command in ("acp", "mcp") and (command == "acp" or args.mcp_command == "bridge"): + # The host talks to the bridge on stdio. It becomes this process + # rather than a child of it, so none of the agent stack this CLI has + # loaded stays resident behind a bridge that needs none of it. + from symbio.app.connect import package_root + + module = "symbio_desktop.acp" if command == "acp" else "symbio_desktop.mcp_bridge" + env = dict(os.environ, SYMBIO_HOME=str(constants.PROJECT_DIR)) + env["PYTHONPATH"] = os.pathsep.join( + p for p in (package_root(), env.get("PYTHONPATH", "")) if p) + sys.stdout.flush() + os.execve(sys.executable, [sys.executable, "-m", module], env) + + if command == "connect": + from symbio.app import connect + + return connect.claude_desktop(remove=args.remove) + if command == "mcp": sub = getattr(args, "mcp_command", None) or "run" if sub == "run": diff --git a/symbio/app/connect.py b/symbio/app/connect.py new file mode 100644 index 0000000..30f7be2 --- /dev/null +++ b/symbio/app/connect.py @@ -0,0 +1,75 @@ +"""`symb connect`: hook Symbio into another app. + +Claude Desktop hosts MCP servers, not ACP agents, so it gets Symbio through +symbio_desktop.mcp_bridge, registered under `mcpServers` in its config file. +Everything else already in that file is kept exactly as it was, the file is +backed up before it is changed, and it is written through a rename so Claude +Desktop never reads half of one. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from pathlib import Path + +from symbio import constants + +CLAUDE_DESKTOP_CONFIG = (Path.home() / "Library" / "Application Support" / "Claude" + / "claude_desktop_config.json") +SERVER_NAME = "symbio" + + +def package_root() -> str: + """This code's own root, so the host runs this version of the bridge and + not whatever an editable install elsewhere happens to point at.""" + return str(Path(__file__).resolve().parent.parent.parent) + + +def mcp_entry() -> dict: + return { + "command": sys.executable, + "args": ["-m", "symbio_desktop.mcp_bridge"], + "env": {"SYMBIO_HOME": str(constants.PROJECT_DIR), "PYTHONPATH": package_root()}, + } + + +def claude_desktop(remove: bool = False, config_path: Path = CLAUDE_DESKTOP_CONFIG) -> int: + try: + data = (json.loads(config_path.read_text(encoding="utf-8")) + if config_path.exists() else {}) + except (OSError, ValueError) as e: + print(f"Could not read {config_path} ({e}); leaving it alone.") + return 1 + if not isinstance(data, dict): + print(f"{config_path} is not a JSON object; leaving it alone.") + return 1 + servers = data.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + if remove: + if SERVER_NAME not in servers: + print("Symbio is not connected to Claude Desktop.") + return 0 + servers.pop(SERVER_NAME) + else: + servers[SERVER_NAME] = mcp_entry() + data["mcpServers"] = servers + + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + shutil.copy2(config_path, config_path.with_name(config_path.name + ".bak-symbio")) + temporary = config_path.with_name(config_path.name + ".tmp-symbio") + temporary.write_text(json.dumps(data, indent=2), encoding="utf-8") + os.replace(temporary, config_path) + + if remove: + print("Removed Symbio from Claude Desktop. Restart Claude Desktop to drop it.") + else: + print(f"Connected: Claude Desktop will start Symbio's MCP bridge " + f"({SERVER_NAME}: ask_symbio, symbio_status), watching {constants.PROJECT_DIR}.") + print("Quit and reopen Claude Desktop, then ask Claude to use Symbio.") + print(f"(Backup of the previous config: {config_path.name}.bak-symbio)") + return 0 diff --git a/symbio_desktop/acp.py b/symbio_desktop/acp.py new file mode 100644 index 0000000..0177b26 --- /dev/null +++ b/symbio_desktop/acp.py @@ -0,0 +1,325 @@ +"""Symbio as an ACP agent: the Agent Client Protocol on stdin/stdout. + +Any ACP host — Zed, the VS Code and JetBrains ACP plugins, Toad, and Hermes +once it can host outside agents — starts this process and talks JSON-RPC 2.0 +to it, one JSON object per line. The model is not in here: like the desktop +window, this is a bridge to `symb daemon`, and it reuses the desktop's +DaemonBridge, which already knows the daemon's protocol and what to strip from +a reply (the speaker label, stop sentinels, spent tool calls, thinking spans). + + initialize capabilities; no authentication + session/new starts the daemon if it is not up, then opens + one conversation on it + session/prompt one turn: reply text as agent_message_chunk, + Symbio's activity lines as agent_thought_chunk, + its approval prompts as session/request_permission + session/cancel ends the turn for the host at once; the daemon + cannot be interrupted mid-generation, so the rest + of that turn is read and thrown away + +stdout carries the protocol and nothing else; everything else goes to stderr. +""" + +from __future__ import annotations + +import json +import os +import queue +import subprocess +import sys +import threading +import time +import uuid +from pathlib import Path +from typing import Any + +from symbio_desktop.server import DaemonBridge, _config_summary, constants + +PROTOCOL_VERSION = 1 +# A cold daemon maps the headmaster's weights and then builds a session; on +# the 14B that is tens of seconds before the first prompt can be read. +DAEMON_START_S = 300.0 +SESSION_READY_S = 180.0 + + +def log(*parts: Any) -> None: + print("[symbio-acp]", *parts, file=sys.stderr, flush=True) + + +def prompt_text(blocks: list[dict]) -> str: + """An ACP prompt as one message for the model: text as it is, embedded + resources inlined under their URI, links named.""" + parts: list[str] = [] + for block in blocks or []: + kind = block.get("type") + if kind == "text": + parts.append(block.get("text", "")) + elif kind == "resource": + resource = block.get("resource") or {} + if resource.get("text"): + parts.append(f"[{resource.get('uri', 'attached')}]\n{resource['text']}") + elif kind == "resource_link": + parts.append(f"[{block.get('name') or block.get('uri', 'link')}: {block.get('uri', '')}]") + return "\n\n".join(p for p in parts if p) + + +def ensure_daemon() -> tuple[bool, str]: + """The resident model up and answering, started if it has to be.""" + if DaemonBridge.daemon_ready(): + return True, "" + loading = False + try: + pid = int(constants.DAEMON_PID_FILE.read_text(encoding="utf-8").strip()) + os.kill(pid, 0) + loading = True # up, still mapping weights + except (OSError, ValueError): + pass + if not loading: + log("no resident model; starting `symb daemon`") + root = Path(__file__).resolve().parent.parent + subprocess.run([sys.executable, "-m", "symbio.app.cli", "daemon", "start"], + cwd=str(root), stdin=subprocess.DEVNULL, + stdout=sys.stderr, stderr=sys.stderr, check=False) + deadline = time.monotonic() + DAEMON_START_S + while time.monotonic() < deadline: + if DaemonBridge.daemon_ready(): + return True, "" + time.sleep(0.5) + return False, (f"The resident model did not come up in {DAEMON_START_S:.0f}s. " + f"See {constants.LOG_DIR / 'daemon.log'}.") + + +class Session: + """One ACP session: one conversation on the daemon.""" + + def __init__(self, session_id: str): + self.id = session_id + self.events: queue.Queue = queue.Queue() + self.bridge = DaemonBridge(self.events.put, _config_summary()["assistant_name"]) + self.cancelled = threading.Event() + self.lock = threading.Lock() # one turn at a time + self.draining = False # a cancelled turn still running + + def open(self) -> tuple[bool, str]: + ok, why = self.bridge.connect() + if not ok: + return False, why + deadline = time.monotonic() + SESSION_READY_S + while not self.bridge.ready and time.monotonic() < deadline: + time.sleep(0.1) + if not self.bridge.ready: + return False, ("Connected, but the resident model has not started a " + "conversation. It serves one client at a time; another " + "window or `symb chat` may be holding it.") + return True, "" + + def drain(self) -> None: + """Read what is left of a cancelled turn, up to its end.""" + while self.draining: + try: + event = self.events.get(timeout=600) + except queue.Empty: + break + if event.get("type") in ("done", "quit"): + self.draining = False + + +class Agent: + def __init__(self, stdin=None, stdout=None): + self.stdin = stdin or sys.stdin + self.stdout = stdout or sys.stdout + self.write_lock = threading.Lock() + self.sessions: dict[str, Session] = {} + self.waiting: dict[Any, queue.Queue] = {} + self.ids = iter(range(1, 1 << 62)) + + # ── JSON-RPC plumbing ──────────────────────────────────────────── + + def send(self, message: dict) -> None: + line = json.dumps(message, ensure_ascii=False) + with self.write_lock: + self.stdout.write(line + "\n") + self.stdout.flush() + + def notify(self, method: str, params: dict) -> None: + self.send({"jsonrpc": "2.0", "method": method, "params": params}) + + def call(self, method: str, params: dict, timeout: float = 3600.0) -> dict: + """A request to the host, answered on the reader thread.""" + request_id = f"symbio-{next(self.ids)}" + box: queue.Queue = queue.Queue(maxsize=1) + self.waiting[request_id] = box + self.send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + try: + reply = box.get(timeout=timeout) + finally: + self.waiting.pop(request_id, None) + if "error" in reply: + raise RuntimeError(reply["error"].get("message", "host error")) + return reply.get("result") or {} + + def update(self, session: Session, update: dict) -> None: + self.notify("session/update", {"sessionId": session.id, "update": update}) + + def serve(self) -> int: + for raw in self.stdin: + raw = raw.strip() + if not raw: + continue + try: + message = json.loads(raw) + except ValueError: + self.send({"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "Parse error"}}) + continue + if "method" not in message: + box = self.waiting.get(message.get("id")) + if box is not None: + box.put(message) + continue + if "id" in message: + # Requests run off the reader: a prompt turn blocks until the + # model is done, and meanwhile the host's answers to our own + # permission requests have to be read. + threading.Thread(target=self.handle, args=(message,), daemon=True).start() + else: + self.notice(message) + for session in self.sessions.values(): + session.bridge.close() + return 0 + + def handle(self, message: dict) -> None: + method, params = message["method"], message.get("params") or {} + handler = { + "initialize": self.initialize, + "authenticate": lambda p: {}, + "session/new": self.new_session, + "session/prompt": self.prompt, + }.get(method) + if handler is None: + self.send({"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32601, "message": f"Method not found: {method}"}}) + return + try: + result = handler(params) + except Exception as e: # the host sees why + log(f"{method} failed: {type(e).__name__}: {e}") + self.send({"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32603, "message": str(e)}}) + return + self.send({"jsonrpc": "2.0", "id": message["id"], "result": result}) + + def notice(self, message: dict) -> None: + if message.get("method") == "session/cancel": + session = self.sessions.get((message.get("params") or {}).get("sessionId")) + if session is not None: + session.cancelled.set() + + # ── the methods ────────────────────────────────────────────────── + + def initialize(self, params: dict) -> dict: + return { + "protocolVersion": PROTOCOL_VERSION, + "agentCapabilities": { + "loadSession": False, + "promptCapabilities": {"image": False, "audio": False, + "embeddedContext": True}, + "mcpCapabilities": {"http": False, "sse": False}, + }, + "agentInfo": {"name": "symbio", "title": "Symbio", "version": "0.2.0"}, + "authMethods": [], + } + + def new_session(self, params: dict) -> dict: + ok, why = ensure_daemon() + if not ok: + raise RuntimeError(why) + # The daemon serves one conversation at a time, so a new session + # replaces the last one rather than queueing behind it. + for old in list(self.sessions.values()): + old.bridge.close() + self.sessions.clear() + session = Session(f"sess_{uuid.uuid4().hex[:16]}") + ok, why = session.open() + if not ok: + session.bridge.close() + raise RuntimeError(why) + self.sessions[session.id] = session + log(f"session {session.id} open on {constants.DAEMON_SOCKET}") + return {"sessionId": session.id} + + def prompt(self, params: dict) -> dict: + session = self.sessions.get(params.get("sessionId")) + if session is None: + raise RuntimeError("Unknown session; start one with session/new.") + text = prompt_text(params.get("prompt") or []) + if not text.strip(): + return {"stopReason": "end_turn"} + with session.lock: + session.drain() + session.cancelled.clear() + session.bridge.say(text) + return self._turn(session) + + def _turn(self, session: Session) -> dict: + while True: + if session.cancelled.is_set(): + session.draining = True + return {"stopReason": "cancelled"} + try: + event = session.events.get(timeout=0.2) + except queue.Empty: + continue + kind = event.get("type") + if kind == "token": + self.update(session, {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": event["text"]}}) + elif kind == "system": + line = (event.get("text") or "").rstrip() + if line: + self.update(session, {"sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": line + "\n"}}) + elif kind == "confirm": + session.bridge.confirm(self._permission(session, event.get("prompt", ""))) + elif kind == "done": + return {"stopReason": "end_turn"} + elif kind == "quit": + self.sessions.pop(session.id, None) + return {"stopReason": "end_turn"} + + def _permission(self, session: Session, prompt: str) -> bool: + """Symbio's approval gate, asked through the host's own dialog.""" + call_id = f"call_{uuid.uuid4().hex[:12]}" + title = prompt.strip().splitlines()[0][:200] if prompt.strip() else "Symbio wants to act" + tool_call = {"toolCallId": call_id, "title": title, "kind": "execute", + "status": "pending", + "content": [{"type": "content", + "content": {"type": "text", "text": prompt.strip()}}]} + self.update(session, {"sessionUpdate": "tool_call", **tool_call}) + try: + result = self.call("session/request_permission", { + "sessionId": session.id, + "toolCall": tool_call, + "options": [ + {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, + {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, + ], + }) + except Exception as e: + log(f"permission request failed: {e}") + result = {} + outcome = result.get("outcome") or {} + approved = outcome.get("outcome") == "selected" and outcome.get("optionId") == "allow" + self.update(session, {"sessionUpdate": "tool_call_update", "toolCallId": call_id, + "status": "completed" if approved else "failed"}) + return approved + + +def main() -> int: + # Line-buffered text on both pipes, whatever the host set. + sys.stdout.reconfigure(line_buffering=True) + return Agent().serve() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/symbio_desktop/mcp_bridge.py b/symbio_desktop/mcp_bridge.py new file mode 100644 index 0000000..5276eb1 --- /dev/null +++ b/symbio_desktop/mcp_bridge.py @@ -0,0 +1,209 @@ +"""Symbio for MCP hosts such as Claude Desktop: its resident model as tools. + +Claude Desktop hosts MCP servers, not ACP agents, so this is the way in there: +a stdio MCP server (JSON-RPC, one object per line, no dependencies) with + + ask_symbio one turn with Symbio's resident model, through the daemon + symbio_status whether the model is up, what it is, and what training + is doing right now + +The daemon serves one conversation at a time. The bridge keeps its session +between calls, so Symbio remembers the thread, and lets it go after five idle +minutes so `symb chat` and the pet's window are not locked out. Symbio's +approval prompts cannot be answered from inside a tool call, so they are +declined, and the reply says what was asked. +""" + +from __future__ import annotations + +import json +import queue +import sys +import threading +import time +from typing import Any + +from symbio_desktop.acp import Session, ensure_daemon, log +from symbio_desktop.server import DaemonBridge, _config_summary, constants + +SERVER_INFO = {"name": "symbio", "version": "0.2.0"} +IDLE_S = 300.0 +TURN_S = 900.0 + +TOOLS = [ + { + "name": "ask_symbio", + "description": ( + "Send a message to Symbio, the user's own self-fine-tuning local model " + "running on this Mac, and return its reply. It keeps the conversation " + "between calls. Use it when the user wants Symbio's answer, or wants " + "Symbio to do something with its own tools, notes and memory."), + "inputSchema": { + "type": "object", + "properties": {"message": {"type": "string", + "description": "What to say to Symbio."}}, + "required": ["message"], + }, + }, + { + "name": "symbio_status", + "description": ("Whether Symbio's resident model is running, which model it " + "is, how much its adapter has been trained, and what any " + "fine-tune in progress is doing."), + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +class Bridge: + def __init__(self, stdin=None, stdout=None): + self.stdin = stdin or sys.stdin + self.stdout = stdout or sys.stdout + self.write_lock = threading.Lock() + self.session: Session | None = None + self.used = 0.0 + self.turn_lock = threading.Lock() + threading.Thread(target=self._release_when_idle, daemon=True).start() + + def send(self, message: dict) -> None: + with self.write_lock: + self.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + self.stdout.flush() + + def serve(self) -> int: + for raw in self.stdin: + raw = raw.strip() + if not raw: + continue + try: + message = json.loads(raw) + except ValueError: + continue + if "id" not in message or "method" not in message: + continue # notifications, stray responses + threading.Thread(target=self.handle, args=(message,), daemon=True).start() + if self.session is not None: + self.session.bridge.close() + return 0 + + def handle(self, message: dict) -> None: + method, params = message["method"], message.get("params") or {} + try: + if method == "initialize": + result = {"protocolVersion": params.get("protocolVersion", "2025-06-18"), + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": SERVER_INFO} + elif method == "ping": + result = {} + elif method == "tools/list": + result = {"tools": TOOLS} + elif method == "tools/call": + result = self.call_tool(params.get("name"), params.get("arguments") or {}) + else: + self.send({"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32601, "message": f"Method not found: {method}"}}) + return + except Exception as e: + log(f"{method} failed: {type(e).__name__}: {e}") + result = {"content": [{"type": "text", "text": f"Symbio bridge error: {e}"}], + "isError": True} + self.send({"jsonrpc": "2.0", "id": message["id"], "result": result}) + + # ── the tools ──────────────────────────────────────────────────── + + def call_tool(self, name: str, arguments: dict) -> dict: + if name == "symbio_status": + return self._text(json.dumps(status(), indent=2)) + if name == "ask_symbio": + message = str(arguments.get("message", "")).strip() + if not message: + return self._text("Nothing to send: `message` was empty.", error=True) + return self.ask(message) + return self._text(f"Unknown tool: {name}", error=True) + + @staticmethod + def _text(text: str, error: bool = False) -> dict: + return {"content": [{"type": "text", "text": text}], "isError": error} + + def ask(self, message: str) -> dict: + with self.turn_lock: + if self.session is None or not self.session.bridge.alive(): + ok, why = ensure_daemon() + if not ok: + return self._text(why, error=True) + session = Session("mcp") + ok, why = session.open() + if not ok: + session.bridge.close() + return self._text(why, error=True) + self.session = session + session = self.session + session.bridge.say(message) + reply, notes = [], [] + deadline = time.monotonic() + TURN_S + while time.monotonic() < deadline: + try: + event = session.events.get(timeout=1.0) + except queue.Empty: + continue + kind = event.get("type") + if kind == "token": + reply.append(event["text"]) + elif kind == "confirm": + # Nobody can answer this from inside a tool call. + session.bridge.confirm(False) + notes.append("Symbio asked for approval and was declined here: " + + (event.get("prompt") or "").strip().splitlines()[0][:200] + + " (approve it in `symb chat` if you want it done).") + elif kind in ("done", "quit"): + if kind == "quit": + self.session = None + break + else: + notes.append(f"Symbio was still answering after {TURN_S:.0f}s.") + self.used = time.monotonic() + text = "".join(reply).strip() or "(Symbio returned no text.)" + if notes: + text += "\n\n" + "\n".join(notes) + return self._text(text) + + def _release_when_idle(self) -> None: + while True: + time.sleep(15) + with self.turn_lock: + if self.session is not None and time.monotonic() - self.used > IDLE_S: + self.session.bridge.close() + self.session = None + + +def status() -> dict[str, Any]: + config = _config_summary() + state: dict[str, Any] = {"model_running": DaemonBridge.daemon_ready(), + "model": config.get("model_name"), + "assistant_name": config.get("assistant_name"), + "home": str(constants.PROJECT_DIR)} + try: + progress = json.loads((constants.ADAPTER_DIR / "training_progress.json") + .read_text(encoding="utf-8")) + state["adapter_steps"] = progress.get("total_iters") + except (OSError, ValueError): + state["adapter_steps"] = 0 + live_name = getattr(constants, "TRAINING_LIVE_NAME", "training_live.json") + try: + live = json.loads((constants.LOG_DIR / live_name).read_text(encoding="utf-8")) + state["last_training"] = {k: live.get(k) for k in ( + "role", "phase", "iter", "iters", "verdict", "reason")} + if live.get("train"): + state["last_training"]["loss"] = live["train"][-1][1] + except (OSError, ValueError): + pass + return state + + +def main() -> int: + sys.stdout.reconfigure(line_buffering=True) + return Bridge().serve() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bridges.py b/tests/test_bridges.py new file mode 100644 index 0000000..fe68cb9 --- /dev/null +++ b/tests/test_bridges.py @@ -0,0 +1,280 @@ +"""Symbio for other apps: the ACP agent, the MCP bridge, and `symb connect`. + +Both bridges are tested against a stand-in for the daemon's side of +DaemonBridge, so what is checked is the protocol each speaks to its host: the +shapes of initialize and session/new, a prompt turn streamed as updates, an +approval prompt asked through the host, a cancel, and the MCP tool calls. The +real daemon behind them was exercised end to end with the official ACP and MCP +SDK clients; these keep the contract from drifting. +""" +import json +import threading +import time + +import pytest + +from symbio_desktop import acp, mcp_bridge + + +class FakeBridge: + """The daemon's side of DaemonBridge: a scripted turn per say().""" + + script: list = [] + instances: list = [] + + def __init__(self, sink, assistant_name=""): + self.sink = sink + self.ready = False + self.said: list[str] = [] + self.confirmed: list[bool] = [] + self._answer = threading.Event() + FakeBridge.instances.append(self) + + def connect(self): + self.ready = True + return True, "" + + def alive(self): + return self.ready + + def close(self): + self.ready = False + + def confirm(self, approved): + self.confirmed.append(approved) + self._answer.set() + + def say(self, text): + self.said.append(text) + threading.Thread(target=self._play, args=(list(self.script),), daemon=True).start() + + def _play(self, script): + for kind, value in script: + if kind == "sleep": + time.sleep(value) + elif kind == "confirm": + self._answer.clear() + self.sink({"type": "confirm", "prompt": value}) + self._answer.wait(5) + else: + self.sink({"type": kind, "text": value}) + self.sink({"type": "done", "text": ""}) + + +class Out: + """The host's end of stdout: every line the agent writes, parsed.""" + + def __init__(self): + self.messages: list[dict] = [] + + def write(self, text): + for line in text.splitlines(): + if line.strip(): + self.messages.append(json.loads(line)) + + def flush(self): + pass + + +@pytest.fixture +def agent(monkeypatch): + FakeBridge.instances.clear() + monkeypatch.setattr(acp, "DaemonBridge", FakeBridge) + monkeypatch.setattr(acp, "ensure_daemon", lambda: (True, "")) + monkeypatch.setattr(acp, "_config_summary", lambda: {"assistant_name": "Symbio"}) + out = Out() + return acp.Agent(stdin=iter(()), stdout=out), out + + +def _answer_permissions(agent_, out, option_id, stop): + """Play the host: answer every session/request_permission the agent sends.""" + answered = set() + while not stop.is_set(): + for message in list(out.messages): + if message.get("method") == "session/request_permission" and message["id"] not in answered: + answered.add(message["id"]) + agent_.waiting[message["id"]].put( + {"id": message["id"], + "result": {"outcome": {"outcome": "selected", "optionId": option_id}}}) + time.sleep(0.01) + + +def _updates(out): + return [m["params"]["update"] for m in out.messages if m.get("method") == "session/update"] + + +def test_initialize_says_what_the_agent_can_do(agent): + agent_, _ = agent + result = agent_.initialize({"protocolVersion": 1, "clientCapabilities": {}}) + assert result["protocolVersion"] == 1 + assert result["agentInfo"]["name"] == "symbio" + assert result["authMethods"] == [] + assert result["agentCapabilities"]["promptCapabilities"]["embeddedContext"] is True + + +def test_a_prompt_turn_streams_and_asks_before_acting(agent): + agent_, out = agent + FakeBridge.script = [("token", "Hello"), ("system", "[Tool] listing files"), + ("confirm", "Run `ls ~`?"), ("token", " world")] + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + stop = threading.Event() + threading.Thread(target=_answer_permissions, args=(agent_, out, "allow", stop), + daemon=True).start() + try: + result = agent_.prompt({"sessionId": session_id, + "prompt": [{"type": "text", "text": "hi"}]}) + finally: + stop.set() + assert result == {"stopReason": "end_turn"} + updates = _updates(out) + text = "".join(u["content"]["text"] for u in updates + if u["sessionUpdate"] == "agent_message_chunk") + assert text == "Hello world" + assert any(u["sessionUpdate"] == "agent_thought_chunk" + and "[Tool] listing files" in u["content"]["text"] for u in updates) + call = next(u for u in updates if u["sessionUpdate"] == "tool_call") + assert call["kind"] == "execute" and call["status"] == "pending" + done = next(u for u in updates if u["sessionUpdate"] == "tool_call_update") + assert done["status"] == "completed" + assert FakeBridge.instances[-1].confirmed == [True] + permission = next(m for m in out.messages if m.get("method") == "session/request_permission") + assert {o["kind"] for o in permission["params"]["options"]} == {"allow_once", "reject_once"} + + +def test_a_rejected_approval_is_passed_on_as_a_refusal(agent): + agent_, out = agent + FakeBridge.script = [("confirm", "Delete the file?"), ("token", "Okay, I won't.")] + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + stop = threading.Event() + threading.Thread(target=_answer_permissions, args=(agent_, out, "reject", stop), + daemon=True).start() + try: + agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "x"}]}) + finally: + stop.set() + assert FakeBridge.instances[-1].confirmed == [False] + assert next(u for u in _updates(out) + if u["sessionUpdate"] == "tool_call_update")["status"] == "failed" + + +def test_cancel_ends_the_turn_and_the_next_prompt_still_works(agent): + agent_, out = agent + FakeBridge.script = [("token", "one"), ("sleep", 0.5), ("token", " two")] + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + results = [] + worker = threading.Thread(target=lambda: results.append(agent_.prompt( + {"sessionId": session_id, "prompt": [{"type": "text", "text": "count"}]}))) + worker.start() + time.sleep(0.2) + agent_.notice({"method": "session/cancel", "params": {"sessionId": session_id}}) + worker.join(3) + assert results == [{"stopReason": "cancelled"}] + FakeBridge.script = [("token", "still here")] + before = len(_updates(out)) + assert agent_.prompt({"sessionId": session_id, + "prompt": [{"type": "text", "text": "again"}]}) == {"stopReason": "end_turn"} + after = "".join(u["content"]["text"] for u in _updates(out)[before:] + if u["sessionUpdate"] == "agent_message_chunk") + assert after == "still here", "the cancelled turn's tail must not leak into the next" + + +def test_an_unknown_method_is_a_json_rpc_error(agent): + agent_, out = agent + agent_.handle({"jsonrpc": "2.0", "id": 7, "method": "session/teleport", "params": {}}) + assert out.messages[-1]["error"]["code"] == -32601 + + +def test_prompt_content_blocks_become_one_message(): + text = acp.prompt_text([ + {"type": "text", "text": "Look at this"}, + {"type": "resource", "resource": {"uri": "file:///a.py", "text": "print(1)"}}, + {"type": "resource_link", "uri": "file:///b.md", "name": "b.md"}, + ]) + assert text == "Look at this\n\n[file:///a.py]\nprint(1)\n\n[b.md: file:///b.md]" + + +# ---- the MCP bridge ----------------------------------------------------------- + +@pytest.fixture +def bridge(monkeypatch): + FakeBridge.instances.clear() + monkeypatch.setattr(acp, "DaemonBridge", FakeBridge) + monkeypatch.setattr(acp, "_config_summary", lambda: {"assistant_name": "Symbio"}) + monkeypatch.setattr(mcp_bridge, "ensure_daemon", lambda: (True, "")) + out = Out() + return mcp_bridge.Bridge(stdin=iter(()), stdout=out), out + + +def _rpc(bridge_, out, method, params=None, id_=1): + bridge_.handle({"jsonrpc": "2.0", "id": id_, "method": method, "params": params or {}}) + return out.messages[-1] + + +def test_mcp_handshake_and_tool_list(bridge): + bridge_, out = bridge + init = _rpc(bridge_, out, "initialize", {"protocolVersion": "2025-06-18"})["result"] + assert init["protocolVersion"] == "2025-06-18" + assert init["capabilities"]["tools"] == {"listChanged": False} + names = [t["name"] for t in _rpc(bridge_, out, "tools/list")["result"]["tools"]] + assert names == ["ask_symbio", "symbio_status"] + + +def test_ask_symbio_returns_the_reply_and_declines_approvals(bridge): + bridge_, out = bridge + FakeBridge.script = [("token", "Sure — "), ("confirm", "Run `rm -rf build`?"), + ("token", "done.")] + result = _rpc(bridge_, out, "tools/call", + {"name": "ask_symbio", "arguments": {"message": "clean up"}})["result"] + text = result["content"][0]["text"] + assert result["isError"] is False + assert text.startswith("Sure — done.") + assert "declined" in text and "rm -rf build" in text + assert FakeBridge.instances[-1].confirmed == [False] + + +def test_an_empty_message_is_an_error_not_a_turn(bridge): + bridge_, out = bridge + result = _rpc(bridge_, out, "tools/call", + {"name": "ask_symbio", "arguments": {"message": " "}})["result"] + assert result["isError"] is True and not FakeBridge.instances + + +# ---- symb connect ------------------------------------------------------------- + +def test_connect_adds_symbio_and_keeps_everything_else(tmp_path): + from symbio.app import connect + + config = tmp_path / "claude_desktop_config.json" + config.write_text(json.dumps({"preferences": {"theme": "dark"}, + "mcpServers": {"other": {"command": "x"}}})) + assert connect.claude_desktop(config_path=config) == 0 + data = json.loads(config.read_text()) + assert data["preferences"] == {"theme": "dark"} + assert data["mcpServers"]["other"] == {"command": "x"} + entry = data["mcpServers"]["symbio"] + assert entry["args"] == ["-m", "symbio_desktop.mcp_bridge"] + assert "SYMBIO_HOME" in entry["env"] and "PYTHONPATH" in entry["env"] + assert (tmp_path / "claude_desktop_config.json.bak-symbio").exists() + + assert connect.claude_desktop(remove=True, config_path=config) == 0 + data = json.loads(config.read_text()) + assert "symbio" not in data["mcpServers"] and "other" in data["mcpServers"] + + +def test_connect_leaves_an_unreadable_config_alone(tmp_path): + from symbio.app import connect + + config = tmp_path / "claude_desktop_config.json" + config.write_text("{not json") + assert connect.claude_desktop(config_path=config) == 1 + assert config.read_text() == "{not json" + + +@pytest.mark.parametrize("argv,command", [ + (["acp"], "acp"), (["mcp", "bridge"], "mcp"), (["connect", "claude-desktop"], "connect"), +]) +def test_the_bridges_are_subcommands(argv, command): + from symbio.app.cli import _build_parser + + args = _build_parser().parse_args(argv) + assert args.command == command diff --git a/tests/test_desktop_app.py b/tests/test_desktop_app.py index 1734da4..b1977db 100644 --- a/tests/test_desktop_app.py +++ b/tests/test_desktop_app.py @@ -41,7 +41,7 @@ def _imports(path: pathlib.Path) -> set[str]: return found -@pytest.mark.parametrize("name", ["server.py", "cli.py", "window.py"]) +@pytest.mark.parametrize("name", ["server.py", "cli.py", "window.py", "acp.py", "mcp_bridge.py"]) def test_the_desktop_never_imports_the_agent_package(name): """`symbio` costs 105 MB of stack the window has no use for. constants.py is loaded by path precisely so this stays true.""" From 964aba0441e36423e3a7f6089cfed5f77322265b Mon Sep 17 00:00:00 2001 From: huyedits Date: Sat, 26 Sep 2026 13:16:00 +1000 Subject: [PATCH 4/5] acp knows the fine-tune loop now!! learn/private modes, /train /save /golden in the command menu, retrains show up as a tool call w steps + loss + the gate's verdict, and the loop as a plan :3 also the golden remedy run doesn't say kept by itself anymore T_T Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ryuzz9EtCjeeR8ofVxhGW --- README.md | 9 ++ symbio/app/training_live.py | 11 +- symbio_desktop/acp.py | 250 ++++++++++++++++++++++++++++++++---- tests/test_bridges.py | 142 ++++++++++++++++++++ tests/test_pet.py | 26 ++++ 5 files changed, 410 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 609de42..5083539 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,15 @@ dialog. It starts `symb daemon` itself if no model is loaded. In Zed: "agent_servers": { "Symbio": { "command": "symb", "args": ["acp"] } } ``` +The fine-tune loop comes along. A session has two modes: **Private** (the +default; nothing is added to the corpus) and **Learn** (the conversation is +saved for training when the session closes). The loop's commands — `/save`, +`/learn`, `/train`, `/golden`, `/forget_last`, `/status` — show up in the +host's command menu. A retrain started from the session appears as a tool call +that counts the steps and the loss, then reports the golden gate's verdict: +kept, or rolled back to the previous adapter. The four stages (collect, train, +golden gate, keep or roll back) are shown as the session's plan. + Claude Desktop hosts MCP servers rather than ACP agents, so `symb connect claude-desktop` registers `symb mcp bridge` in its config instead: Claude gets `ask_symbio` (a turn with Symbio, which keeps the thread between calls) and diff --git a/symbio/app/training_live.py b/symbio/app/training_live.py index 3529509..32f00df 100644 --- a/symbio/app/training_live.py +++ b/symbio/app/training_live.py @@ -108,12 +108,21 @@ def begin(role: str | None, iters: int | None, prior_iters: int = 0) -> None: now = time.time() with _lock: armed_at = _gate_armed.pop(role, None) + # A run that starts while this process's last run still waits on its + # gate is that gate's follow-up (the golden remedy), judged with it. + # Seen live: the arm was spent on the first run, so the remedy ended + # "kept" by itself and the gate then rolled both back. + previous = _state + follows = (previous["run"] if previous is not None and previous.get("gated") + and previous.get("phase") == "trained" and not previous.get("verdict") + and previous.get("role") == role else None) _state = { "version": 1, "run": f"{os.getpid()}-{now:.3f}", "role": role, "phase": "training", - "gated": armed_at is not None and now - armed_at < GATE_WINDOW_S, + "gated": bool(follows) or (armed_at is not None and now - armed_at < GATE_WINDOW_S), + "follows": follows, "pid": os.getpid(), "iters": int(iters) if iters else None, "prior_iters": max(0, int(prior_iters or 0)), diff --git a/symbio_desktop/acp.py b/symbio_desktop/acp.py index 0177b26..50090d6 100644 --- a/symbio_desktop/acp.py +++ b/symbio_desktop/acp.py @@ -16,6 +16,23 @@ session/cancel ends the turn for the host at once; the daemon cannot be interrupted mid-generation, so the rest of that turn is read and thrown away + session/set_mode Learn or Private (below) + +The fine-tune loop is what makes Symbio different from the other agents in an +ACP host's list, so the host is told about it in the protocol's own terms: + +* **Commands.** The loop's steps are advertised as slash commands + (available_commands_update) — /save, /learn, /train, /golden, /forget_last, + /status — and pass straight through to Symbio's session. +* **Modes.** A session is Private (never trained on) or Learn (added to the + fine-tune corpus when it closes). Without this every ACP conversation was + thrown away: a host closing the pipe reads as EOF, and the session's + "Save conversation for training? [y/N]" takes EOF for no. +* **Live training.** While a session is open, logs/training_live.json — the + same account the desktop pet draws — becomes a tool call ("Fine-tuning the + headmaster adapter", step and loss as it goes, the golden gate, then kept or + rolled back) and a plan of the loop's four stages. The trainer's own + per-step lines are then left out of the thinking, where they were noise. stdout carries the protocol and nothing else; everything else goes to stderr. """ @@ -25,6 +42,8 @@ import json import os import queue +import re +import signal import subprocess import sys import threading @@ -41,6 +60,29 @@ DAEMON_START_S = 300.0 SESSION_READY_S = 180.0 +MODES = [ + {"id": "private", "name": "Private", + "description": "This conversation is not added to Symbio's fine-tune corpus."}, + {"id": "learn", "name": "Learn", + "description": ("When the session closes, this conversation is added to " + "Symbio's fine-tune corpus, so the next /train learns from it.")}, +] +COMMANDS = [ + {"name": "save", "description": "Add this conversation so far to the fine-tune corpus"}, + {"name": "learn", "description": "Learn from your last correction: it becomes training data"}, + {"name": "train", "description": ("Fine-tune now: golden baseline, a LoRA run, the golden " + "gate, then keep the adapter or roll it back")}, + {"name": "golden", "description": "Run the golden checks against the current adapter"}, + {"name": "forget_last", "description": "Drop the last exchange so it is never trained on"}, + {"name": "status", "description": "Model, adapter and corpus"}, +] +LOOP_STAGES = ("Collect: conversations and corrections in the corpus", + "Train: a LoRA run on the corpus", + "Golden gate: the golden checks against the new adapter", + "Keep the new adapter, or roll back to the last one") +# The trainer's per-step lines, which the training tool call now carries. +_TRAINER_LINE = re.compile(r"^\s*(Iter \d+:|Calculating loss)") + def log(*parts: Any) -> None: print("[symbio-acp]", *parts, file=sys.stderr, flush=True) @@ -89,6 +131,26 @@ def ensure_daemon() -> tuple[bool, str]: f"See {constants.LOG_DIR / 'daemon.log'}.") +def training_state() -> dict | None: + name = getattr(constants, "TRAINING_LIVE_NAME", "training_live.json") + try: + state = json.loads((constants.LOG_DIR / name).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return state if isinstance(state, dict) else None + + +def plan(stage: int, done: bool = False) -> dict: + """The loop's four stages, `stage` in progress (or everything done).""" + entries = [] + for i, content in enumerate(LOOP_STAGES): + status = ("completed" if done or i < stage else + "in_progress" if i == stage else "pending") + entries.append({"content": content, "priority": "high" if i else "medium", + "status": status}) + return {"sessionUpdate": "plan", "entries": entries} + + class Session: """One ACP session: one conversation on the daemon.""" @@ -99,6 +161,12 @@ def __init__(self, session_id: str): self.cancelled = threading.Event() self.lock = threading.Lock() # one turn at a time self.draining = False # a cancelled turn still running + self.mode = "private" + self.turns = 0 + self.saved_at = 0 # turns covered by the last /save + self.opened_at = time.time() + self.closed = threading.Event() + self.training = False # a run is being shown as a tool call def open(self) -> tuple[bool, str]: ok, why = self.bridge.connect() @@ -123,6 +191,25 @@ def drain(self) -> None: if event.get("type") in ("done", "quit"): self.draining = False + def finish(self) -> None: + """Close the conversation, keeping it for training first in Learn mode.""" + self.closed.set() + if self.mode == "learn" and self.turns > self.saved_at and self.bridge.alive(): + with self.lock: + self.drain() + self.bridge.say("/save") + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + event = self.events.get(timeout=1.0) + except queue.Empty: + continue + if event.get("type") == "system" and "Saved" in event.get("text", ""): + log(event["text"].strip()) + if event.get("type") in ("done", "quit"): + break + self.bridge.close() + class Agent: def __init__(self, stdin=None, stdout=None): @@ -162,30 +249,34 @@ def update(self, session: Session, update: dict) -> None: self.notify("session/update", {"sessionId": session.id, "update": update}) def serve(self) -> int: - for raw in self.stdin: - raw = raw.strip() - if not raw: - continue - try: - message = json.loads(raw) - except ValueError: - self.send({"jsonrpc": "2.0", "id": None, - "error": {"code": -32700, "message": "Parse error"}}) - continue - if "method" not in message: - box = self.waiting.get(message.get("id")) - if box is not None: - box.put(message) - continue - if "id" in message: - # Requests run off the reader: a prompt turn blocks until the - # model is done, and meanwhile the host's answers to our own - # permission requests have to be read. - threading.Thread(target=self.handle, args=(message,), daemon=True).start() - else: - self.notice(message) - for session in self.sessions.values(): - session.bridge.close() + try: + for raw in self.stdin: + raw = raw.strip() + if not raw: + continue + try: + message = json.loads(raw) + except ValueError: + self.send({"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "Parse error"}}) + continue + if "method" not in message: + box = self.waiting.get(message.get("id")) + if box is not None: + box.put(message) + continue + if "id" in message: + # Requests run off the reader: a prompt turn blocks until + # the model is done, and meanwhile the host's answers to + # our own permission requests have to be read. + threading.Thread(target=self.handle, args=(message,), daemon=True).start() + else: + self.notice(message) + finally: + # The host closed the pipe or stopped us: Learn sessions are kept + # for training on the way out. + for session in list(self.sessions.values()): + session.finish() return 0 def handle(self, message: dict) -> None: @@ -195,6 +286,7 @@ def handle(self, message: dict) -> None: "authenticate": lambda p: {}, "session/new": self.new_session, "session/prompt": self.prompt, + "session/set_mode": self.set_mode, }.get(method) if handler is None: self.send({"jsonrpc": "2.0", "id": message["id"], @@ -208,6 +300,8 @@ def handle(self, message: dict) -> None: "error": {"code": -32603, "message": str(e)}}) return self.send({"jsonrpc": "2.0", "id": message["id"], "result": result}) + if method == "session/new": + self.opened(self.sessions[result["sessionId"]]) def notice(self, message: dict) -> None: if message.get("method") == "session/cancel": @@ -237,7 +331,7 @@ def new_session(self, params: dict) -> dict: # The daemon serves one conversation at a time, so a new session # replaces the last one rather than queueing behind it. for old in list(self.sessions.values()): - old.bridge.close() + old.finish() self.sessions.clear() session = Session(f"sess_{uuid.uuid4().hex[:16]}") ok, why = session.open() @@ -246,7 +340,26 @@ def new_session(self, params: dict) -> dict: raise RuntimeError(why) self.sessions[session.id] = session log(f"session {session.id} open on {constants.DAEMON_SOCKET}") - return {"sessionId": session.id} + return {"sessionId": session.id, + "modes": {"currentModeId": session.mode, "availableModes": MODES}} + + def opened(self, session: Session) -> None: + """After the host has the session id: the loop's commands, and a + watch on training for as long as the session is open.""" + self.update(session, {"sessionUpdate": "available_commands_update", + "availableCommands": COMMANDS}) + threading.Thread(target=self.watch_training, args=(session,), daemon=True).start() + + def set_mode(self, params: dict) -> dict: + session = self.sessions.get(params.get("sessionId")) + if session is None: + raise RuntimeError("Unknown session.") + mode = params.get("modeId") + if mode not in {m["id"] for m in MODES}: + raise RuntimeError(f"Unknown mode: {mode}") + session.mode = mode + self.update(session, {"sessionUpdate": "current_mode_update", "currentModeId": mode}) + return {} def prompt(self, params: dict) -> dict: session = self.sessions.get(params.get("sessionId")) @@ -259,6 +372,10 @@ def prompt(self, params: dict) -> dict: session.drain() session.cancelled.clear() session.bridge.say(text) + if text.strip() == "/save": + session.saved_at = session.turns + elif not text.startswith("/"): + session.turns += 1 return self._turn(session) def _turn(self, session: Session) -> dict: @@ -276,7 +393,7 @@ def _turn(self, session: Session) -> dict: "content": {"type": "text", "text": event["text"]}}) elif kind == "system": line = (event.get("text") or "").rstrip() - if line: + if line and not (session.training and _TRAINER_LINE.match(line)): self.update(session, {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": line + "\n"}}) elif kind == "confirm": @@ -314,10 +431,89 @@ def _permission(self, session: Session, prompt: str) -> bool: "status": "completed" if approved else "failed"}) return approved + # ── the fine-tune loop, as the host sees it ────────────────────── + + def watch_training(self, session: Session, poll: float = 0.5) -> None: + """Runs that start while this session is open, as a tool call and a + plan. Old runs already on disk are history, not news.""" + shown: dict[str, str] = {} # run id → last thing said about it + final: set[str] = set() # runs whose call has its last status + while not session.closed.wait(poll): + state = training_state() + if not state or not state.get("run"): + continue + if float(state.get("started_at") or 0) < session.opened_at - 1: + continue + run = str(state["run"]) + call_id = f"train_{run}" + label = (state.get("role") or "headmaster").replace("_", " ") + if run not in shown: + session.training = True + shown[run] = "" + # The gate's follow-up run: the verdict lands on it, so the + # run it follows is closed here instead of left spinning. + follows = state.get("follows") + if follows in shown and follows not in final: + final.add(follows) + self.update(session, { + "sessionUpdate": "tool_call_update", "toolCallId": f"train_{follows}", + "status": "completed", + "content": [{"type": "content", "content": {"type": "text", "text": ( + "Trained. Some checks still failed, so the gate started " + "a follow-up run on them.")}}]}) + self.update(session, { + "sessionUpdate": "tool_call", "toolCallId": call_id, + "title": f"Fine-tuning the {label} adapter", "kind": "other", + "status": "in_progress", + "rawInput": {"role": state.get("role"), "steps": state.get("iters"), + "resumed_from": state.get("prior_iters") or 0}}) + self.update(session, plan(1)) + said, status, stage = self._describe(state) + if said == shown[run]: + continue + shown[run] = said + update = {"sessionUpdate": "tool_call_update", "toolCallId": call_id, + "content": [{"type": "content", + "content": {"type": "text", "text": said}}]} + if status: + update["status"] = status + final.add(run) + session.training = False + self.update(session, update) + if stage is not None: + # A rollback completes the last stage as surely as a keep does. + self.update(session, plan(stage, done=status is not None)) + + @staticmethod + def _describe(state: dict) -> tuple[str, str | None, int | None]: + """What to say about a run now: text, final status, plan stage.""" + verdict, phase = state.get("verdict"), state.get("phase") + steps = state.get("total_iters") or ((state.get("prior_iters") or 0) + (state.get("iter") or 0)) + if verdict == "kept": + return f"Kept: the adapter now carries {steps} steps of training.", "completed", 3 + if verdict == "rolled_back": + return ("Rolled back: the new adapter was not kept, and the previous one " + "is back in place."), "failed", 3 + if phase in ("failed", "stopped"): + reason = state.get("reason") or phase + return f"The run {phase}: {reason}.", "failed", None + if phase == "trained": + return ("Golden gate: re-running the golden checks against the new adapter…", + None, 2) + train = state.get("train") or [] + budget = f"/{state['iters']}" if state.get("iters") else "" + text = f"Step {state.get('iter') or 0}{budget}" + if train: + text += f" · loss {train[-1][1]:.3f} (started at {train[0][1]:.3f})" + return text, None, None + def main() -> int: # Line-buffered text on both pipes, whatever the host set. sys.stdout.reconfigure(line_buffering=True) + # A host that stops us with SIGTERM still gets Learn sessions saved: the + # exit unwinds through serve()'s finally. + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) return Agent().serve() diff --git a/tests/test_bridges.py b/tests/test_bridges.py index fe68cb9..10536e3 100644 --- a/tests/test_bridges.py +++ b/tests/test_bridges.py @@ -178,6 +178,148 @@ def test_cancel_ends_the_turn_and_the_next_prompt_still_works(agent): assert after == "still here", "the cancelled turn's tail must not leak into the next" +def test_a_session_offers_learn_and_private_and_the_loops_commands(agent): + agent_, out = agent + FakeBridge.script = [] + agent_.handle({"jsonrpc": "2.0", "id": 1, "method": "session/new", + "params": {"cwd": "/tmp", "mcpServers": []}}) + result = next(m for m in out.messages if m.get("id") == 1)["result"] + modes = result["modes"] + assert modes["currentModeId"] == "private", "nothing is trained on unless asked" + assert {m["id"] for m in modes["availableModes"]} == {"private", "learn"} + commands = next(u for u in _updates(out) + if u["sessionUpdate"] == "available_commands_update")["availableCommands"] + assert {"save", "learn", "train", "golden", "forget_last"} <= {c["name"] for c in commands} + for session in agent_.sessions.values(): + session.closed.set() + + +def test_set_mode_is_confirmed_to_the_host(agent): + agent_, out = agent + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + assert agent_.set_mode({"sessionId": session_id, "modeId": "learn"}) == {} + assert agent_.sessions[session_id].mode == "learn" + assert _updates(out)[-1] == {"sessionUpdate": "current_mode_update", "currentModeId": "learn"} + with pytest.raises(RuntimeError): + agent_.set_mode({"sessionId": session_id, "modeId": "yolo"}) + + +@pytest.mark.parametrize("mode,saved", [("learn", True), ("private", False)]) +def test_a_learn_session_is_kept_for_training_when_it_closes(agent, mode, saved): + """A host closing the pipe used to read as "no" to Save conversation.""" + agent_, _ = agent + FakeBridge.script = [("token", "ok")] + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + session = agent_.sessions[session_id] + session.mode = mode + agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "hello"}]}) + FakeBridge.script = [("system", " Saved 1 exchange(s) to training data.")] + session.finish() + assert ("/save" in FakeBridge.instances[-1].said) is saved + + +def test_training_shows_as_a_tool_call_and_the_loops_plan(agent, tmp_path, monkeypatch): + agent_, out = agent + monkeypatch.setattr(acp.constants, "LOG_DIR", tmp_path) + live = tmp_path / "training_live.json" + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + session = agent_.sessions[session_id] + watcher = threading.Thread(target=agent_.watch_training, args=(session, 0.02), daemon=True) + watcher.start() + + def write(**fields): + state = {"run": "r1", "role": None, "phase": "training", "iters": 40, "iter": 0, + "prior_iters": 0, "train": [], "started_at": time.time(), + "verdict": None, "gated": True, "total_iters": None} + state.update(fields) + live.write_text(json.dumps(state)) + time.sleep(0.15) + + write() + write(iter=10, train=[[10, 1.5]]) + write(phase="trained", iter=40, train=[[10, 1.5], [40, 0.4]], total_iters=40) + write(phase="trained", iter=40, total_iters=40, verdict="kept") + session.closed.set() + watcher.join(1) + + updates = _updates(out) + call = next(u for u in updates if u["sessionUpdate"] == "tool_call") + assert call["title"] == "Fine-tuning the headmaster adapter" + said = [u["content"][0]["content"]["text"] for u in updates + if u["sessionUpdate"] == "tool_call_update"] + assert said[:2] == ["Step 0/40", "Step 10/40 · loss 1.500 (started at 1.500)"] + assert said[2].startswith("Golden gate") + assert said[3].startswith("Kept: the adapter now carries 40 steps") + last = [u for u in updates if u["sessionUpdate"] == "tool_call_update"][-1] + assert last["status"] == "completed" + final_plan = [u for u in updates if u["sessionUpdate"] == "plan"][-1]["entries"] + assert [e["status"] for e in final_plan] == ["completed"] * 4 + + +def test_the_gates_follow_up_run_closes_the_first_and_carries_the_verdict( + agent, tmp_path, monkeypatch): + """Seen live: the first run's call was left spinning at "Golden gate…", + and the final plan still had its last stage in progress after a rollback.""" + agent_, out = agent + monkeypatch.setattr(acp.constants, "LOG_DIR", tmp_path) + live = tmp_path / "training_live.json" + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + session = agent_.sessions[session_id] + watcher = threading.Thread(target=agent_.watch_training, args=(session, 0.02), daemon=True) + watcher.start() + + def write(**fields): + state = {"run": "r1", "role": None, "phase": "trained", "iters": 100, "iter": 100, + "prior_iters": 0, "train": [], "started_at": time.time(), "verdict": None, + "gated": True, "follows": None, "total_iters": 100} + state.update(fields) + live.write_text(json.dumps(state)) + time.sleep(0.15) + + write() + write(run="r2", phase="training", iters=50, iter=0, total_iters=None, follows="r1") + write(run="r2", iters=50, iter=50, total_iters=150, follows="r1", verdict="rolled_back") + session.closed.set() + watcher.join(1) + + updates = [u for u in _updates(out) if u["sessionUpdate"] == "tool_call_update"] + first = [u for u in updates if u["toolCallId"] == "train_r1"] + assert first[-1]["status"] == "completed" + assert "follow-up run" in first[-1]["content"][0]["content"]["text"] + second = [u for u in updates if u["toolCallId"] == "train_r2"] + assert second[-1]["status"] == "failed" + assert second[-1]["content"][0]["content"]["text"].startswith("Rolled back") + final_plan = [u for u in _updates(out) if u["sessionUpdate"] == "plan"][-1]["entries"] + assert [e["status"] for e in final_plan] == ["completed"] * 4 + + +def test_an_old_run_on_disk_is_not_news(agent, tmp_path, monkeypatch): + agent_, out = agent + monkeypatch.setattr(acp.constants, "LOG_DIR", tmp_path) + (tmp_path / "training_live.json").write_text(json.dumps( + {"run": "old", "phase": "trained", "started_at": time.time() - 3600, "verdict": "kept"})) + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + session = agent_.sessions[session_id] + watcher = threading.Thread(target=agent_.watch_training, args=(session, 0.02), daemon=True) + watcher.start() + time.sleep(0.15) + session.closed.set() + watcher.join(1) + assert not [u for u in _updates(out) if u["sessionUpdate"].startswith("tool_call")] + + +def test_trainer_lines_leave_the_thinking_while_a_run_is_shown(agent): + agent_, out = agent + FakeBridge.script = [("system", "Iter 10: Train loss 1.234, Learning Rate 1e-05"), + ("system", " [Train] Backing up current adapter...")] + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + agent_.sessions[session_id].training = True + agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "/train"}]}) + thoughts = "".join(u["content"]["text"] for u in _updates(out) + if u["sessionUpdate"] == "agent_thought_chunk") + assert "Iter 10" not in thoughts and "Backing up current adapter" in thoughts + + def test_an_unknown_method_is_a_json_rpc_error(agent): agent_, out = agent agent_.handle({"jsonrpc": "2.0", "id": 7, "method": "session/teleport", "params": {}}) diff --git a/tests/test_pet.py b/tests/test_pet.py index fd99724..b255834 100644 --- a/tests/test_pet.py +++ b/tests/test_pet.py @@ -106,6 +106,32 @@ def test_a_gated_run_waits_for_the_gate(live): assert training_live.read()["verdict"] == "kept" +def test_the_golden_remedy_run_is_judged_by_the_same_gate(live): + """The gate's own follow-up run must not end "kept" on its own: seen live, + the remedy was shown kept, then the gate rolled both runs back.""" + training_live.arm_gate(None) + training_live.begin(None, 100) + training_live.end("trained", iter=100, total_iters=100) + first = training_live.read()["run"] + training_live.begin(None, 50) # the remedy, arm already spent + training_live.end("trained", iter=50, total_iters=150) + state = training_live.read() + assert (state["gated"], state["follows"], state["verdict"]) == (True, first, None) + training_live.mark_rolled_back(None) + assert training_live.read()["verdict"] == "rolled_back" + + +def test_a_run_after_a_verdict_starts_a_new_flow(live): + training_live.arm_gate(None) + training_live.begin(None, 20) + training_live.end("trained", iter=20, total_iters=20) + training_live.mark_kept() + training_live.begin(None, 20) # e.g. a later `symb train` + training_live.end("trained", iter=20, total_iters=40) + state = training_live.read() + assert (state["gated"], state["follows"], state["verdict"]) == (False, None, "kept") + + def test_the_cleanup_never_overturns_a_rollback(live): """discard_adapter_backup runs in the `finally` of every gated flow.""" training_live.arm_gate(None) From 7e74e15728776b84f34ecc9ab7989dbc662156a6 Mon Sep 17 00:00:00 2001 From: huyedits Date: Sat, 26 Sep 2026 13:28:48 +1000 Subject: [PATCH 5/5] symb connect hermes!! registers the mcp bridge thru hermes's own `hermes mcp add` so its config keeps its comments, + 900s tool timeout so /train doesn't get cut off :3 also /status /save /train thru ask_symbio came back empty when i typed them into hermes T_T commands answer w their output now (acp too, as the reply not thinking) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ryuzz9EtCjeeR8ofVxhGW --- README.md | 12 +++- symbio/app/cli.py | 4 +- symbio/app/connect.py | 78 +++++++++++++++++++++++ symbio_desktop/acp.py | 10 ++- symbio_desktop/mcp_bridge.py | 21 +++++- tests/test_bridges.py | 120 +++++++++++++++++++++++++++++++++-- 6 files changed, 233 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5083539..fd5865f 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ sitting and 3% asleep, more while it walks or eats. It needs PyObjC symb acp # an ACP agent on stdio, for Zed, the VS Code and # JetBrains ACP plugins, Toad, and other ACP hosts symb connect claude-desktop # Claude Desktop gets Symbio as tools (MCP) +symb connect hermes # so does Hermes Agent (CLI and desktop app) ``` `symb acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com): @@ -252,7 +253,16 @@ claude-desktop` registers `symb mcp bridge` in its config instead: Claude gets prompts cannot be answered from inside a tool call there, so they are declined and the reply says what was asked. `--remove` takes it out again. -Both bridges were checked with the official ACP and MCP SDK clients. +Hermes Agent can run as an ACP agent but cannot host one, so it gets the same +MCP bridge. `symb connect hermes` registers it through Hermes's own `hermes mcp +add`, which keeps the comments in its config.yaml, and raises the tool timeout +to 900 seconds so a `/train` sent through `ask_symbio` is not cut off at +Hermes's default of 300. The loop's commands work through `ask_symbio` too: +send it `/status`, `/save`, `/golden` or `/train` and their output comes back +as the reply, without the trainer's per-step lines. + +Both bridges were checked with the official ACP and MCP SDK clients, and the +MCP bridge by typing into Hermes Agent's own terminal UI. ### Staying online diff --git a/symbio/app/cli.py b/symbio/app/cli.py index f884fb2..ad88188 100644 --- a/symbio/app/cli.py +++ b/symbio/app/cli.py @@ -265,7 +265,7 @@ def _build_parser() -> argparse.ArgumentParser: "acp", help="Speak the Agent Client Protocol on stdio, for Zed, VS Code, " "JetBrains, Toad and other ACP hosts") connect_parser = sub.add_parser("connect", help="Hook Symbio into another app") - connect_parser.add_argument("target", choices=["claude-desktop"], + connect_parser.add_argument("target", choices=["claude-desktop", "hermes"], help="The app to connect Symbio to") connect_parser.add_argument("--remove", action="store_true", help="Disconnect instead") @@ -1318,6 +1318,8 @@ def main(argv: list[str] | None = None) -> int: if command == "connect": from symbio.app import connect + if args.target == "hermes": + return connect.hermes(remove=args.remove) return connect.claude_desktop(remove=args.remove) if command == "mcp": diff --git a/symbio/app/connect.py b/symbio/app/connect.py index 30f7be2..a574547 100644 --- a/symbio/app/connect.py +++ b/symbio/app/connect.py @@ -5,13 +5,20 @@ Everything else already in that file is kept exactly as it was, the file is backed up before it is changed, and it is written through a rename so Claude Desktop never reads half of one. + +Hermes Agent is an ACP server but not an ACP client, so it takes the same MCP +bridge. Its config.yaml is registered through Hermes's own `hermes mcp add` +rather than written from here: Hermes keeps that file's comments through a +round-trip writer, and a plain YAML dump would strip them. """ from __future__ import annotations import json import os +import re import shutil +import subprocess import sys from pathlib import Path @@ -20,6 +27,10 @@ CLAUDE_DESKTOP_CONFIG = (Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json") SERVER_NAME = "symbio" +# Hermes gives an MCP tool call 300s by default. ask_symbio can hold a /train +# for up to the bridge's own turn limit (symbio_desktop.mcp_bridge.TURN_S). +HERMES_TOOL_TIMEOUT_S = 900 +_ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") def package_root() -> str: @@ -73,3 +84,70 @@ def claude_desktop(remove: bool = False, config_path: Path = CLAUDE_DESKTOP_CONF print("Quit and reopen Claude Desktop, then ask Claude to use Symbio.") print(f"(Backup of the previous config: {config_path.name}.bak-symbio)") return 0 + + +def hermes_home() -> Path: + home = os.environ.get("HERMES_HOME", "").strip() + return Path(home).expanduser() if home else Path.home() / ".hermes" + + +def hermes_binary() -> str | None: + """`hermes` on PATH, or where its two installers put it: the desktop + installer under installs/, install.sh under hermes-agent/venv.""" + found = shutil.which("hermes") + if found: + return found + candidates: list[Path] = [] + for home in dict.fromkeys((hermes_home(), Path.home() / ".hermes")): + candidates += sorted(home.glob("installs/*/environments/*/venv/bin/hermes"), + key=lambda p: p.stat().st_mtime, reverse=True) + candidates.append(home / "hermes-agent" / "venv" / "bin" / "hermes") + for candidate in candidates: + if os.access(candidate, os.X_OK): + return str(candidate) + return None + + +def hermes(remove: bool = False, binary: str | None = None) -> int: + binary = binary or hermes_binary() + if not binary: + print("Hermes Agent was not found: no `hermes` on PATH or under " + f"{hermes_home()}. Install it, then run this again.") + return 1 + + def run(*args: str, answers: str = "") -> tuple[int, str]: + # Hermes asks before it overwrites or removes, and which tools to + # enable; every question here gets a yes. + try: + done = subprocess.run([binary, *args], input=answers, capture_output=True, + text=True, timeout=180, check=False) + except (OSError, subprocess.TimeoutExpired) as e: + return 1, str(e) + return done.returncode, _ANSI.sub("", done.stdout + done.stderr) + + if remove: + code, said = run("mcp", "remove", SERVER_NAME, answers="y\n") + if code != 0: + print(said.strip()) + return 1 + print("Symbio is not connected to Hermes." if "not found" in said + else "Removed Symbio from Hermes. New Hermes sessions will not have it.") + return 0 + + entry = mcp_entry() + code, said = run("mcp", "add", SERVER_NAME, "--command", entry["command"], + "--env", *(f"{k}={v}" for k, v in entry["env"].items()), + "--args", *entry["args"], answers="y\ny\n") + if code != 0 or f"Saved '{SERVER_NAME}'" not in said: + print("Hermes did not register Symbio:") + print(said.strip()) + return 1 + code, said = run("config", "set", f"mcp_servers.{SERVER_NAME}.timeout", + str(HERMES_TOOL_TIMEOUT_S)) + if code != 0: + print(f"Registered, but the tool timeout stayed at Hermes's default " + f"(a /train through ask_symbio may be cut off): {said.strip()}") + print(f"Connected: Hermes will start Symbio's MCP bridge ({SERVER_NAME}: " + f"ask_symbio, symbio_status), watching {constants.PROJECT_DIR}.") + print("Start a new Hermes session (or /reload-mcp) and ask Hermes to use Symbio.") + return 0 diff --git a/symbio_desktop/acp.py b/symbio_desktop/acp.py index 50090d6..8b5ed4c 100644 --- a/symbio_desktop/acp.py +++ b/symbio_desktop/acp.py @@ -376,9 +376,13 @@ def prompt(self, params: dict) -> dict: session.saved_at = session.turns elif not text.startswith("/"): session.turns += 1 - return self._turn(session) + return self._turn(session, command=text.lstrip().startswith("/")) - def _turn(self, session: Session) -> dict: + def _turn(self, session: Session, command: bool = False) -> dict: + """One turn's events as updates. A command answers in output lines, + not tokens, so for a command those lines are the reply; otherwise they + are Symbio's activity, shown as its thinking.""" + line_kind = "agent_message_chunk" if command else "agent_thought_chunk" while True: if session.cancelled.is_set(): session.draining = True @@ -394,7 +398,7 @@ def _turn(self, session: Session) -> dict: elif kind == "system": line = (event.get("text") or "").rstrip() if line and not (session.training and _TRAINER_LINE.match(line)): - self.update(session, {"sessionUpdate": "agent_thought_chunk", + self.update(session, {"sessionUpdate": line_kind, "content": {"type": "text", "text": line + "\n"}}) elif kind == "confirm": session.bridge.confirm(self._permission(session, event.get("prompt", ""))) diff --git a/symbio_desktop/mcp_bridge.py b/symbio_desktop/mcp_bridge.py index 5276eb1..f73d223 100644 --- a/symbio_desktop/mcp_bridge.py +++ b/symbio_desktop/mcp_bridge.py @@ -23,12 +23,14 @@ import time from typing import Any -from symbio_desktop.acp import Session, ensure_daemon, log +from symbio_desktop.acp import _TRAINER_LINE, Session, ensure_daemon, log from symbio_desktop.server import DaemonBridge, _config_summary, constants SERVER_INFO = {"name": "symbio", "version": "0.2.0"} IDLE_S = 300.0 TURN_S = 900.0 +# Output lines a command's reply keeps (its trainer steps are already left out). +MAX_LINES = 80 TOOLS = [ { @@ -139,7 +141,7 @@ def ask(self, message: str) -> dict: self.session = session session = self.session session.bridge.say(message) - reply, notes = [], [] + reply, lines, notes = [], [], [] deadline = time.monotonic() + TURN_S while time.monotonic() < deadline: try: @@ -149,6 +151,10 @@ def ask(self, message: str) -> dict: kind = event.get("type") if kind == "token": reply.append(event["text"]) + elif kind == "system": + line = (event.get("text") or "").rstrip() + if line and not _TRAINER_LINE.match(line): + lines.append(line) elif kind == "confirm": # Nobody can answer this from inside a tool call. session.bridge.confirm(False) @@ -162,7 +168,16 @@ def ask(self, message: str) -> dict: else: notes.append(f"Symbio was still answering after {TURN_S:.0f}s.") self.used = time.monotonic() - text = "".join(reply).strip() or "(Symbio returned no text.)" + text = "".join(reply).strip() + if lines and (message.startswith("/") or not text): + # A command (/status, /save, /train…) answers in output lines, + # not tokens: seen from Hermes, every one came back empty. + # The end is kept, where a retrain's verdict is. + kept = lines[-MAX_LINES:] + if len(lines) > len(kept): + kept.insert(0, f"(… {len(lines) - len(kept)} earlier lines)") + text = "\n".join(kept) + (f"\n\n{text}" if text else "") + text = text or "(Symbio returned no text.)" if notes: text += "\n\n" + "\n".join(notes) return self._text(text) diff --git a/tests/test_bridges.py b/tests/test_bridges.py index 10536e3..fd48f20 100644 --- a/tests/test_bridges.py +++ b/tests/test_bridges.py @@ -8,6 +8,8 @@ SDK clients; these keep the contract from drifting. """ import json +import subprocess +import sys import threading import time @@ -308,16 +310,31 @@ def test_an_old_run_on_disk_is_not_news(agent, tmp_path, monkeypatch): assert not [u for u in _updates(out) if u["sessionUpdate"].startswith("tool_call")] -def test_trainer_lines_leave_the_thinking_while_a_run_is_shown(agent): +def test_trainer_lines_leave_the_reply_while_a_run_is_shown(agent): + """The training tool call carries the steps; the reply keeps the rest.""" agent_, out = agent FakeBridge.script = [("system", "Iter 10: Train loss 1.234, Learning Rate 1e-05"), ("system", " [Train] Backing up current adapter...")] session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] agent_.sessions[session_id].training = True agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "/train"}]}) - thoughts = "".join(u["content"]["text"] for u in _updates(out) - if u["sessionUpdate"] == "agent_thought_chunk") - assert "Iter 10" not in thoughts and "Backing up current adapter" in thoughts + reply = "".join(u["content"]["text"] for u in _updates(out) + if u["sessionUpdate"] == "agent_message_chunk") + assert "Iter 10" not in reply and "Backing up current adapter" in reply + + +def test_a_commands_output_is_the_reply_not_thinking(agent): + agent_, out = agent + session_id = agent_.new_session({"cwd": "/tmp", "mcpServers": []})["sessionId"] + FakeBridge.script = [("system", "Model: Qwen3-14B · adapter 400 steps")] + agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "/status"}]}) + FakeBridge.script = [("system", "[Search] otters"), ("token", "Otters hold hands.")] + agent_.prompt({"sessionId": session_id, "prompt": [{"type": "text", "text": "otters?"}]}) + kinds = [(u["sessionUpdate"], u["content"]["text"].strip()) for u in _updates(out) + if u["sessionUpdate"] in ("agent_message_chunk", "agent_thought_chunk")] + assert kinds == [("agent_message_chunk", "Model: Qwen3-14B · adapter 400 steps"), + ("agent_thought_chunk", "[Search] otters"), + ("agent_message_chunk", "Otters hold hands.")] def test_an_unknown_method_is_a_json_rpc_error(agent): @@ -374,6 +391,37 @@ def test_ask_symbio_returns_the_reply_and_declines_approvals(bridge): assert FakeBridge.instances[-1].confirmed == [False] +def test_a_command_answers_with_its_output_lines(bridge): + """Seen from Hermes: /status, /save and /train all came back as + "(Symbio returned no text.)" — a command speaks in output lines, not tokens.""" + bridge_, out = bridge + FakeBridge.script = [("system", " [Train] Starting LoRA (100 iters)..."), + ("system", "Iter 10: Train loss 2.100, Learning Rate 1e-05"), + ("system", " [Golden] 12/15 checks passing (baseline 11/15) — no regression.")] + result = _rpc(bridge_, out, "tools/call", + {"name": "ask_symbio", "arguments": {"message": "/train"}})["result"] + text = result["content"][0]["text"] + assert "[Train] Starting LoRA" in text and "no regression" in text + assert "Iter 10" not in text # step lines are symbio_status's job + + +def test_a_long_command_keeps_its_end(bridge, monkeypatch): + bridge_, out = bridge + monkeypatch.setattr(mcp_bridge, "MAX_LINES", 3) + FakeBridge.script = [("system", f"line {i}") for i in range(10)] + text = _rpc(bridge_, out, "tools/call", {"name": "ask_symbio", + "arguments": {"message": "/golden"}})["result"]["content"][0]["text"] + assert text.splitlines() == ["(… 7 earlier lines)", "line 7", "line 8", "line 9"] + + +def test_a_chat_reply_leaves_the_activity_lines_out(bridge): + bridge_, out = bridge + FakeBridge.script = [("system", "[Search] otters"), ("token", "Otters hold hands.")] + text = _rpc(bridge_, out, "tools/call", {"name": "ask_symbio", + "arguments": {"message": "otters?"}})["result"]["content"][0]["text"] + assert text == "Otters hold hands." + + def test_an_empty_message_is_an_error_not_a_turn(bridge): bridge_, out = bridge result = _rpc(bridge_, out, "tools/call", @@ -412,8 +460,72 @@ def test_connect_leaves_an_unreadable_config_alone(tmp_path): assert config.read_text() == "{not json" +def _fake_hermes(monkeypatch, outputs): + """subprocess.run as Hermes's CLI: each call gets the next (code, stdout).""" + from symbio.app import connect + + calls = [] + + def run(argv, input="", **_): + calls.append((argv, input)) + code, stdout = outputs.pop(0) + return subprocess.CompletedProcess(argv, code, stdout, "") + + monkeypatch.setattr(connect.subprocess, "run", run) + return connect, calls + + +def test_connect_hermes_registers_through_hermes_own_cli(monkeypatch): + """Hermes's writer keeps config.yaml's comments; a YAML dump from here would not.""" + connect, calls = _fake_hermes(monkeypatch, [ + (0, "\x1b[32m ✓ Saved 'symbio' to ~/.hermes/config.yaml (2/2 tools enabled)\x1b[0m"), + (0, "✓ Set mcp_servers.symbio.timeout = 900"), + ]) + assert connect.hermes(binary="/bin/hermes") == 0 + (add, answers), (timeout, _) = calls + assert add[:6] == ["/bin/hermes", "mcp", "add", "symbio", "--command", sys.executable] + env = add[add.index("--env") + 1:add.index("--args")] + assert [e.split("=")[0] for e in env] == ["SYMBIO_HOME", "PYTHONPATH"] + assert add[add.index("--args") + 1:] == ["-m", "symbio_desktop.mcp_bridge"] + assert answers == "y\ny\n" # overwrite it, enable both tools + # Long enough for a /train through ask_symbio, which Hermes's 300s cuts off. + assert timeout[1:] == ["config", "set", "mcp_servers.symbio.timeout", "900"] + + +def test_connect_hermes_says_so_when_hermes_refuses(monkeypatch, capsys): + connect, _ = _fake_hermes(monkeypatch, [(1, "✗ Connection failed")]) + assert connect.hermes(binary="/bin/hermes") == 1 + assert "Connection failed" in capsys.readouterr().out + + +def test_connect_hermes_remove(monkeypatch, capsys): + connect, calls = _fake_hermes(monkeypatch, [(0, "✓ Removed 'symbio' from config"), + (0, "✗ Server 'symbio' not found in config.")]) + assert connect.hermes(remove=True, binary="/bin/hermes") == 0 + assert connect.hermes(remove=True, binary="/bin/hermes") == 0 + assert calls[0][0][1:] == ["mcp", "remove", "symbio"] + out = capsys.readouterr().out + assert "Removed Symbio from Hermes" in out and "not connected" in out + + +def test_the_hermes_binary_is_found_where_the_desktop_installer_puts_it(tmp_path, monkeypatch): + from symbio.app import connect + + monkeypatch.setattr(connect.shutil, "which", lambda _: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) # not this Mac's ~/.hermes + assert connect.hermes_binary() is None + assert connect.hermes() == 1 # nothing to run, nothing run + binary = tmp_path / "installs/72de/environments/532c/venv/bin/hermes" + binary.parent.mkdir(parents=True) + binary.write_text("#!/bin/sh\n") + binary.chmod(0o755) + assert connect.hermes_binary() == str(binary) + + @pytest.mark.parametrize("argv,command", [ (["acp"], "acp"), (["mcp", "bridge"], "mcp"), (["connect", "claude-desktop"], "connect"), + (["connect", "hermes"], "connect"), ]) def test_the_bridges_are_subcommands(argv, command): from symbio.app.cli import _build_parser