feat(assistant): add opencode launcher and phase 6/7 plans
This commit is contained in:
+79
-23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user