Files
hack-house/cmd_chat/operator/session.py
T
leetcrypt 7e81a059b9 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>
2026-06-26 12:19:47 -07:00

124 lines
3.9 KiB
Python

"""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()]