feat(operator): Phase 1 — Claude-driven room bridge (join/read/say)
Invert the agent model: a headless OperatorBridge(Client) owns the websocket while a Claude Code session drives it via a unix control socket. Reuses Client SRP/Fernet and AgentBridge's reconnect/serve shape. Ships an hh-bridge CLI (up/read/say/roster/status/down) with a seq'd in-RAM inbox + asyncio.Condition long-poll (read --wait) for in-turn autonomy. ACL/sandbox-status frames are recorded for later phases. Sandbox drive, delegation and nesting are out of scope here. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Session filesystem layout for the operator bridge.
|
||||
|
||||
Each running daemon owns a session directory under a per-user runtime root:
|
||||
|
||||
${XDG_RUNTIME_DIR:-/tmp}/hh-bridge/<session>/
|
||||
control.sock unix socket the CLI verbs talk to
|
||||
inbox.jsonl append-only event log (durability/debug; truth is in-RAM)
|
||||
meta.json {host, port, user, pid, trigger, no_tls, started}
|
||||
daemon.log daemon stdout/stderr
|
||||
cursor last seq the CLI `read` consumed (client-side bookmark)
|
||||
|
||||
The session name defaults to the room display name (the `user` we join as), so
|
||||
one operator-per-name maps to one session dir. `--session` overrides it when a
|
||||
single box runs several bridges.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
||||
return Path(base) / "hh-bridge"
|
||||
|
||||
|
||||
def _safe(name: str) -> str:
|
||||
"""Reduce a session label to a filesystem-safe slug (no path escapes)."""
|
||||
slug = re.sub(r"[^A-Za-z0-9._-]", "_", name.strip())
|
||||
return slug or "default"
|
||||
|
||||
|
||||
class Session:
|
||||
"""Resolves the paths for one bridge session; never opens anything itself."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = _safe(name)
|
||||
self.dir = runtime_root() / self.name
|
||||
|
||||
# ── paths ────────────────────────────────────────────────────────────
|
||||
@property
|
||||
def sock_path(self) -> Path:
|
||||
return self.dir / "control.sock"
|
||||
|
||||
@property
|
||||
def inbox_path(self) -> Path:
|
||||
return self.dir / "inbox.jsonl"
|
||||
|
||||
@property
|
||||
def meta_path(self) -> Path:
|
||||
return self.dir / "meta.json"
|
||||
|
||||
@property
|
||||
def log_path(self) -> Path:
|
||||
return self.dir / "daemon.log"
|
||||
|
||||
@property
|
||||
def cursor_path(self) -> Path:
|
||||
return self.dir / "cursor"
|
||||
|
||||
# ── lifecycle helpers ────────────────────────────────────────────────
|
||||
def ensure_dir(self) -> None:
|
||||
# 0700: the socket grants room-send rights, so keep it user-private.
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(self.dir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def write_meta(self, **fields) -> None:
|
||||
self.ensure_dir()
|
||||
self.meta_path.write_text(json.dumps(fields, indent=2))
|
||||
|
||||
def read_meta(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.meta_path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def read_cursor(self) -> int:
|
||||
try:
|
||||
return int(self.cursor_path.read_text().strip() or "0")
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
def write_cursor(self, seq: int) -> None:
|
||||
self.ensure_dir()
|
||||
self.cursor_path.write_text(str(seq))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove the socket so a stale path never confuses the next `up`."""
|
||||
for p in (self.sock_path,):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def resolve(name: str | None) -> Session:
|
||||
"""Pick the session to act on.
|
||||
|
||||
Explicit name → that session. Otherwise, if exactly one live session dir
|
||||
exists (has a control.sock), use it; if none/many, fall back to "default"
|
||||
(so a clear error surfaces when the CLI can't reach a socket).
|
||||
"""
|
||||
if name:
|
||||
return Session(name)
|
||||
root = runtime_root()
|
||||
if root.is_dir():
|
||||
live = [d for d in root.iterdir() if (d / "control.sock").exists()]
|
||||
if len(live) == 1:
|
||||
return Session(live[0].name)
|
||||
return Session("default")
|
||||
|
||||
|
||||
def list_sessions() -> list[Session]:
|
||||
root = runtime_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [Session(d.name) for d in sorted(root.iterdir()) if d.is_dir()]
|
||||
Reference in New Issue
Block a user