160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
cc-ci assistant launcher — start/resume the assistant session in tmux.
|
|
|
|
The assistant is a long-lived helper session that shares the orchestrator's access and can work
|
|
through assigned plans autonomously, but it is NOT part of the Builder/Adversary loop pair.
|
|
|
|
Usage:
|
|
launch-assistant.py start resume the persistent session (default)
|
|
launch-assistant.py fresh start a NEW session
|
|
launch-assistant.py stop kill the tmux session
|
|
launch-assistant.py status show session state
|
|
launch-assistant.py attach tmux attach to the session
|
|
|
|
Env:
|
|
LOOP_BACKEND claude (default) | opencode
|
|
LOOP_MODEL model flag, e.g. "sonnet" or "tinfoil/deepseek-v4-pro"
|
|
ASSISTANT_SESSION tmux / remote-control session name
|
|
ASSISTANT_DIR cwd for the assistant
|
|
ASSISTANT_STARTUP_PROMPT startup nudge / assignment
|
|
"""
|
|
|
|
import os, sys, subprocess, time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
SESSION = os.environ.get("ASSISTANT_SESSION", "cc-ci-assistant")
|
|
WORKDIR = os.environ.get("ASSISTANT_DIR", "/srv/cc-ci-orch")
|
|
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
|
|
|
|
BACKEND = os.environ.get("LOOP_BACKEND", "claude")
|
|
LOOP_MODEL = os.environ.get("LOOP_MODEL", "")
|
|
|
|
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
|
CLAUDE_FLAGS = os.environ.get("CLAUDE_FLAGS", "--dangerously-skip-permissions")
|
|
REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1"
|
|
DEFAULT_ID = "9db267a4-9b9e-4818-8197-0a42dba443ea"
|
|
ID_FILE = os.environ.get("ASSISTANT_ID_FILE", f"{LOG_DIR}/.assistant-session-id")
|
|
STARTUP_PROMPT = os.environ.get("ASSISTANT_STARTUP_PROMPT", (
|
|
"You are the cc-ci ASSISTANT. Read cc-ci-plan/JOURNAL.md for context, then wait for a "
|
|
"specific plan/task from the orchestrator or operator. Work autonomously until the assigned "
|
|
"task is complete, report the result, and then wait for the next assignment."
|
|
))
|
|
|
|
OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode")
|
|
OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096")
|
|
|
|
|
|
def log(msg):
|
|
ts = datetime.now().strftime("%H:%M:%S")
|
|
print(f"[assistant {ts}] {msg}", flush=True)
|
|
|
|
|
|
def die(msg):
|
|
log(f"ERROR: {msg}")
|
|
sys.exit(1)
|
|
|
|
|
|
def session_alive():
|
|
return subprocess.run(["tmux", "has-session", "-t", SESSION], capture_output=True).returncode == 0
|
|
|
|
|
|
def _ping(session, msg):
|
|
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
|
|
time.sleep(0.5)
|
|
submit_key = "C-m" if BACKEND == "opencode" else "Enter"
|
|
for _ in range(10):
|
|
subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True)
|
|
time.sleep(1)
|
|
r = subprocess.run(["tmux", "capture-pane", "-pt", session], capture_output=True, text=True)
|
|
if msg[:28] not in r.stdout:
|
|
return
|
|
|
|
|
|
def resume_id():
|
|
sid = os.environ.get("ASSISTANT_SESSION_ID")
|
|
if sid:
|
|
return sid
|
|
try:
|
|
v = Path(ID_FILE).read_text().strip()
|
|
return v or DEFAULT_ID
|
|
except FileNotFoundError:
|
|
return DEFAULT_ID
|
|
|
|
|
|
def start(mode="resume"):
|
|
import shutil
|
|
|
|
if not shutil.which("tmux"):
|
|
die("tmux not found")
|
|
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
if session_alive():
|
|
log(f"{SESSION} already running — leaving it (use 'stop' first to relaunch)")
|
|
return
|
|
|
|
model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else ""
|
|
|
|
if BACKEND == "claude":
|
|
if not shutil.which(CLAUDE_BIN):
|
|
die(f"claude CLI not found — set CLAUDE_BIN (currently: {CLAUDE_BIN})")
|
|
if not Path(ID_FILE).exists():
|
|
Path(ID_FILE).write_text(DEFAULT_ID)
|
|
|
|
rc = f"--remote-control '{SESSION}'" if REMOTE_CONTROL else ""
|
|
resume = f"--resume '{resume_id()}'" if mode == "resume" else ""
|
|
prompt = f"'{STARTUP_PROMPT}'" if STARTUP_PROMPT else ""
|
|
cmd = f"{CLAUDE_BIN} {resume} {rc} {model_flag} {CLAUDE_FLAGS} {prompt}"
|
|
detail = f"resume={resume_id()}" if mode == "resume" else "fresh"
|
|
log(f"starting {SESSION} (backend=claude, {detail}, model={LOOP_MODEL or 'default'})")
|
|
elif BACKEND == "opencode":
|
|
if not Path(OPENCODE_BIN).exists():
|
|
die(f"opencode not found at {OPENCODE_BIN}")
|
|
cmd = (
|
|
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
|
f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {WORKDIR}"
|
|
)
|
|
log(f"starting {SESSION} (backend=opencode, model={LOOP_MODEL or 'default'})")
|
|
log(" visible at http://oc.commoninternet.net (tailnet only)")
|
|
else:
|
|
die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'")
|
|
|
|
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", WORKDIR, cmd])
|
|
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_DIR}/{SESSION}.log'"])
|
|
log(f"started. attach: tmux attach -t {SESSION}")
|
|
|
|
if BACKEND == "opencode":
|
|
time.sleep(8)
|
|
_ping(SESSION, STARTUP_PROMPT)
|
|
|
|
|
|
def main():
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "start"
|
|
|
|
if cmd == "start":
|
|
start("resume")
|
|
elif cmd == "fresh":
|
|
start("fresh")
|
|
elif cmd == "stop":
|
|
if session_alive():
|
|
log(f"killing {SESSION}")
|
|
subprocess.run(["tmux", "kill-session", "-t", SESSION])
|
|
else:
|
|
log(f"{SESSION} not running")
|
|
elif cmd == "status":
|
|
state = "RUNNING" if session_alive() else "stopped"
|
|
log(f"{SESSION}: {state}")
|
|
subprocess.run(f"ps -eo pid,etime,args | grep '[r]emote-control {SESSION}' || true", shell=True)
|
|
if BACKEND == "claude":
|
|
log(f"resume id: {resume_id()} (file: {ID_FILE})")
|
|
log(f"backend: {BACKEND} model: {LOOP_MODEL or '<default>'}")
|
|
elif cmd == "attach":
|
|
os.execvp("tmux", ["tmux", "attach", "-t", SESSION])
|
|
else:
|
|
print("usage: launch-assistant.py start|fresh|stop|status|attach")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|