feat(assistant): add opencode launcher and phase 6/7 plans

This commit is contained in:
autonomic-bot
2026-06-01 12:59:03 +00:00
parent df6ca04611
commit 24bf379b5b
5 changed files with 333 additions and 126 deletions
+79 -23
View File
@@ -98,9 +98,13 @@ PHASES = [p.split("|") for p in PHASES_SPEC.split(";")]
PHASE_IDX_FILE = os.environ.get("PHASE_IDX_FILE", f"{LOG_DIR}/.phase-idx")
# Regex patterns for session-state detection
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool")
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool|▣|Build ·|· \d+")
LIMIT_RE = re.compile(r"spend limit|usage limit|limit reached|reached your .*limit|out of (credits|tokens)", re.I)
FATAL_RE = re.compile(r"redacted_thinking|blocks cannot be modified|cannot be modified", re.I)
RECENT_ACTIVITY_RE = re.compile(r"thinking|inferring|running tool|remote control (active|connecting)|tool call|schedulewake?up", re.I)
OPENCODE_STALL_IDLE = int(os.environ.get("OPENCODE_STALL_IDLE", 900))
OPENCODE_LOG_GRACE = int(os.environ.get("OPENCODE_LOG_GRACE", 180))
# ── logging ───────────────────────────────────────────────────────────────────
@@ -127,27 +131,42 @@ def capture_pane(name, lines=40):
r = subprocess.run(["tmux", "capture-pane", "-pt", name], capture_output=True, text=True)
return "\n".join(r.stdout.splitlines()[-lines:]) if r.returncode == 0 else ""
def _session_log_path(session):
return Path(LOG_DIR) / f"{session}.log"
def _log_recently_touched(session, age_seconds):
try:
return (time.time() - _session_log_path(session).stat().st_mtime) <= age_seconds
except FileNotFoundError:
return False
def _last_nonempty_line(text):
for line in reversed(text.splitlines()):
if line.strip():
return line.strip()
return ""
def pipe_to_log(session, log_path):
subprocess.run(["tmux", "pipe-pane", "-o", "-t", session, f"cat >> '{log_path}'"])
def ping_session(session, msg, submit_key="Enter"):
"""Type a message into a tmux session and submit it.
submit_key: "Enter" for claude (default); "C-s" for opencode (Ctrl+S sends in opencode TUI).
Retries the submit key until the typed prefix is no longer visible in the input area.
submit_key: "Enter" for claude; "C-m" for opencode (Ctrl+M = Enter).
Retries the submit key until the typed prefix is no longer visible in the content area.
opencode renders the input in the content area, so we check more lines.
"""
if not session_alive(session):
return
prefix = msg[:28]
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
time.sleep(0.5)
for _ in range(5):
for _ in range(10):
subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True)
time.sleep(1)
if prefix not in capture_pane(session, 4):
# Check the top 20 lines of content (not just last 4 bottom UI)
if prefix not in capture_pane(session, 20):
return # message was accepted
subprocess.run(["tmux", "send-keys", "-t", session, "C-m"], capture_output=True)
time.sleep(0.5)
# ── phase helpers ─────────────────────────────────────────────────────────────
@@ -217,38 +236,40 @@ def start_agent(role, session, workdir):
model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else ""
session_cwd = workdir
if BACKEND == "claude":
rc = f"--remote-control '{session}'" if REMOTE_CONTROL else ""
cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\""
log(f"starting {session} (backend=claude, phase={pid}, plan={plan}, model={LOOP_MODEL or 'default'})")
elif BACKEND == "opencode":
# Plain `opencode` (no subcommand) launches the persistent TUI and connects to the
# shared server automatically. `opencode attach` requires a TTY and exits in tmux;
# the plain TUI works because tmux allocates a PTY for the pane's child process.
# --dir pins the working directory; the kickoff is sent via ping_session after startup.
# Note: --dir causes opencode to exit immediately (likely a non-git-root issue).
# The working directory is set via tmux -c instead; opencode uses that as its cwd.
# Attach each TUI to the shared opencode web server so sessions are recorded the same
# way as browser-created sessions, including a populated `path` in the DB.
# We still pin the visible project root with --dir, while the kickoff instructions use
# absolute repo paths for builder/adversary work.
session_cwd = "/srv/cc-ci-orch/cc-ci"
cmd = (
f"set -a; . /srv/cc-ci/.testenv; set +a; "
f"NO_COLOR=1 {OPENCODE_BIN} {model_flag}"
f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {session_cwd}"
)
log(f"starting {session} (backend=opencode, phase={pid}, model={LOOP_MODEL or 'default'})")
log(f" visible at http://oc.commoninternet.net (tailnet only)")
else:
die(f"unknown BACKEND '{BACKEND}' — set LOOP_BACKEND=claude or LOOP_BACKEND=opencode")
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", workdir, cmd])
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", session_cwd, cmd])
pipe_to_log(session, f"{LOG_DIR}/{session}.log")
# opencode: send a short bootstrap once the TUI is ready (Ctrl+S submits in opencode).
# opencode: send a short bootstrap once the TUI is ready.
# opencode TUI uses C-m (Ctrl+M = Enter) to submit messages.
# The full kickoff lives in the kickoff file; we point to it to stay under send-keys limits.
if BACKEND == "opencode":
time.sleep(8) # opencode TUI needs more time to connect to the server than 4s
time.sleep(12) # opencode TUI needs more time to connect to the server
bootstrap = (
f"Your full kickoff prompt is in {kf} — read it now with: "
f"`cat '{kf}'` — then follow its instructions exactly."
)
ping_session(session, bootstrap, submit_key="C-s")
ping_session(session, bootstrap, submit_key="C-m")
def start_loops():
start_agent("builder", BUILDER_SESSION, BUILDER_DIR)
@@ -279,7 +300,7 @@ def heal_session(role, session, workdir):
start_agent(role, session, workdir)
return
if LIMIT_RE.search(pane):
if BACKEND != "opencode" and LIMIT_RE.search(pane):
log(f"limit-stall on {role} ({session}) — nudging to resume")
ping_session(session,
"watchdog: the usage/spend limit appears lifted — RESUME your loop now. "
@@ -289,10 +310,37 @@ def heal_session(role, session, workdir):
# ── stall detection ───────────────────────────────────────────────────────────
_idle_since: dict[str, float] = {}
_limit_nudged_at: dict[str, float] = {}
def _maybe_nudge_limit(role, session, pane):
if not LIMIT_RE.search(pane):
return False
now = time.time()
last = _limit_nudged_at.get(session, 0.0)
if now - last < 300:
return True
_limit_nudged_at[session] = now
log(f"limit-stall on {role} ({session}) — nudging to resume")
ping_session(
session,
"watchdog: the usage/spend limit appears lifted or is about to reset. "
"RESUME your loop now. Pull latest, re-read your phase STATUS/REVIEW files, "
"and continue from where you stopped; re-arm your loop pacing.",
submit_key=_SUBMIT,
)
return True
def _parse_waiting_until(pane):
"""Extract the epoch timestamp from a WAITING-UNTIL marker, or None."""
m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane)
if BACKEND == "opencode":
line = _last_nonempty_line(pane)
if not line.startswith("WAITING-UNTIL:"):
return None
m = re.search(r"WAITING-UNTIL:\s*(\S+)", line)
else:
m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane)
if not m:
return None
try:
@@ -305,12 +353,19 @@ def _parse_waiting_until(pane):
def stall_check_one(role, session, workdir):
if not session_alive(session):
_idle_since[session] = 0.0
_limit_nudged_at[session] = 0.0
return
now = time.time()
pane = capture_pane(session, 40)
if ACTIVE_RE.search(pane):
if BACKEND == "opencode" and _maybe_nudge_limit(role, session, pane):
_idle_since[session] = now
return
if ACTIVE_RE.search(pane) or (BACKEND == "opencode" and (
RECENT_ACTIVITY_RE.search(pane) or _log_recently_touched(session, OPENCODE_LOG_GRACE)
)):
_idle_since[session] = 0.0
return
@@ -326,7 +381,8 @@ def stall_check_one(role, session, workdir):
return
reason = f"past its WAITING-UNTIL by {int(now - until)}s — self-wake did not fire"
else:
if idle < STALL_IDLE:
stall_idle = OPENCODE_STALL_IDLE if BACKEND == "opencode" else STALL_IDLE
if idle < stall_idle:
return
reason = f"idle {int(idle)}s with no WAITING-UNTIL marker"
@@ -405,7 +461,7 @@ def _show_pushed(path):
return r.stdout
return ""
_SUBMIT = "C-s" if BACKEND == "opencode" else "Enter"
_SUBMIT = "C-m" if BACKEND == "opencode" else "Enter"
def handoff_check():
global _last_sha, _adv_inbox_seen, _builder_inbox_seen