feat(operator): Phase 6 — relay-mode read (screen) + stop-condition engine (watch)

Completes the remote-drive triad for a broker-owned sandbox the operator can't
exec into directly: keys (in) → watch (wait) → screen (out).

- _sbx:data PTY-relay frames are absorbed into a capped rolling terminal buffer
  (not surfaced as chat); `screen` returns it ansi-stripped (or raw). sandbox.
  strip_ansi handles CSI/OSC/CR noise so output greps cleanly.
- `watch` is a formal stop-condition engine: blocks until a regex matches in the
  screen buffer or chat events, or an idle-quiescence window, or a hard timeout,
  then reports which fired — the autonomy loop's principled wait.
- Skill doctrine updated with the type→wait→read relay loop.

28 offline tests green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-26 20:13:08 -07:00
parent 60215bbdd4
commit 338e843e85
5 changed files with 226 additions and 6 deletions
+96 -2
View File
@@ -36,6 +36,7 @@ class OperatorBridge(Client):
_RECONNECT_MAX_BACKOFF = 30.0
_RECONNECT_STABLE_SECS = 30.0
MAX_EVENTS = 2000 # in-RAM inbox ring; full history lives in inbox.jsonl
TERM_CAP = 65536 # rolling relayed-PTY buffer (bytes) kept for `screen`
def __init__(self, server: str, port: int, name: str,
password: str | None = None, insecure: bool = False,
@@ -66,6 +67,12 @@ class OperatorBridge(Client):
self._own_engine: str | None = None
self._own_name: str = ""
# Relay terminal buffer (Phase 6): rolling raw PTY bytes the broker
# relays as `_sbx:data`. Lets the operator *read* a remote/broker-owned
# sandbox it drives via `keys` — the output half of relay mode.
self._term = bytearray()
self._term_seq = 0 # bumps on each chunk so `watch` can poll for growth
# Live websocket (None while reconnecting); set in _connect_and_serve.
self._ws = None
self._stop = asyncio.Event()
@@ -143,13 +150,37 @@ class OperatorBridge(Client):
await self._emit("message", **{"from": sender}, text=text,
addressed=self._is_addressed(text))
def _absorb_term(self, b64: str) -> None:
"""Append a relayed PTY chunk to the rolling terminal buffer (capped).
High-volume, so we don't emit an inbox event per chunk — `watch`/`screen`
poll `_term_seq` instead."""
if not b64:
return
try:
chunk = base64.b64decode(b64)
except (ValueError, TypeError):
return
self._term += chunk
if len(self._term) > self.TERM_CAP:
del self._term[:-self.TERM_CAP]
self._term_seq += 1
def _screen_text(self, tail: int | None, ansi: bool) -> str:
raw = bytes(self._term[-tail:] if tail else self._term)
s = raw.decode(errors="replace")
return s if ansi else sbx.strip_ansi(s)
async def _record_control(self, text: str) -> None:
"""Surface ACL + sandbox-status changes into the inbox (awareness only).
Ignore high-volume / irrelevant control frames (_sbx:data, _ai, _ft)."""
"""Surface ACL + sandbox-status changes into the inbox (awareness only)
and absorb relayed PTY output (`_sbx:data`) into the terminal buffer.
Ignore other high-volume control frames (_ai, _ft)."""
try:
frame = json.loads(text)
except json.JSONDecodeError:
return
if frame.get("_sbx") == "data":
self._absorb_term(frame.get("b64", ""))
return
if frame.get("_sbx") == "status":
ready = frame.get("state") == "ready"
self.sbx_engine = frame.get("engine") if ready else None
@@ -307,6 +338,10 @@ class OperatorBridge(Client):
return await self._op_keys(req)
if op == "spawn":
return await self._op_spawn(req)
if op == "screen":
return await self._op_screen(req)
if op == "watch":
return await self._op_watch(req)
if op == "down":
asyncio.get_running_loop().call_soon(self._begin_shutdown)
return {"ok": True, "stopping": True}
@@ -474,6 +509,65 @@ class OperatorBridge(Client):
await self._emit("keys", bytes=len(data))
return {"ok": True, "bytes": len(data)}
# ── relay read + stop-condition engine (Phase 6) ─────────────────────
async def _op_screen(self, req: dict) -> dict:
"""Return the current relayed terminal buffer — the output half of relay
mode (drive with `keys`, read with `screen`). ANSI-stripped by default;
`tail` bounds the bytes returned."""
tail = req.get("tail")
tail = int(tail) if tail else None
ansi = bool(req.get("ansi", False))
return {"ok": True, "text": self._screen_text(tail, ansi),
"bytes": len(self._term), "seq": self._term_seq}
async def _op_watch(self, req: dict) -> dict:
"""Block until a stop condition fires, then report which one. Formalizes
the `/loop` autonomy stops so a driver can `keys` then wait for a result.
Conditions (first to fire wins):
- ``for`` regex matched in the chosen source ``in`` = "events" (default,
matches event text) or "screen" (matches relayed terminal output);
- ``idle`` seconds with no new event/output (quiescence);
- ``timeout`` seconds elapsed (the hard ceiling).
"""
pattern = req.get("for")
source = req.get("in", "events")
timeout = float(req.get("timeout", 30.0))
idle = req.get("idle")
idle = float(idle) if idle is not None else None
match_since = int(req.get("since", self.seq)) # fixed scan cursor
try:
rx = re.compile(pattern) if pattern else None
except re.error as e:
return {"ok": False, "error": f"bad regex: {e}"}
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
last_change = loop.time()
prev_seq, prev_term = self.seq, self._term_seq
while True:
if rx and source == "screen":
m = rx.search(self._screen_text(None, False))
if m:
return {"ok": True, "reason": "match", "where": "screen",
"match": m.group(0), "seq": self.seq}
if rx and source == "events":
for e in self.events:
if e["seq"] <= match_since:
continue
if rx.search(str(e.get("text", "")) or json.dumps(e)):
return {"ok": True, "reason": "match", "where": "events",
"event": e, "seq": self.seq}
now = loop.time()
if self.seq != prev_seq or self._term_seq != prev_term:
last_change, prev_seq, prev_term = now, self.seq, self._term_seq
if idle is not None and now - last_change >= idle:
return {"ok": True, "reason": "idle", "idle": idle, "seq": self.seq}
if now >= deadline:
return {"ok": True, "reason": "timeout", "matched": False,
"seq": self.seq}
await asyncio.sleep(min(0.3, max(0.0, deadline - now)))
# ── recursive spawn (Phase 5) ────────────────────────────────────────
async def _op_spawn(self, req: dict) -> dict:
"""Stand up a nested Claude Code operator against another room.