Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/opencode.py
T
GodopuandClaude Opus 5.5 622c1f8a9d feat(issue-30): a stall watcher that only notifies
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00

257 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, sys, sqlite3, shutil
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
_PROVIDER_ENV_VARS = (
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY'
)
class OpenCodeAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'opencode'
@property
def own_key(self) -> str:
return 'opencode_session_id_own'
@property
def ready_tokens(self) -> str:
return 'OpenCode|Chat|Ask anything|tab agents|ctrl\\+p|Build auto|commands'
@property
def exit_key(self) -> str:
return '/exit'
@property
def delegate_agent_key(self) -> str:
return 'opencode-cli'
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
@property
def input_prompt(self) -> Optional[str]:
return '❯'
@property
def input_placeholder(self) -> Optional[str]:
return ''
@property
def input_rule_pattern(self) -> Optional[str]:
# Kept for lib.sh's readiness corroboration; the input box itself is a
# ┃ block (input_block_prefix), which the extractor reads first.
return '─{10,}'
@property
def input_block_prefix(self) -> Optional[str]:
return '┃'
@property
def input_stop_pattern(self) -> Optional[str]:
# The input area ends at the right-aligned cwd line (a long run of
# blanks after the ┃) or the `Build auto · <model>` status line.
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
# 1. Try opencode CLI "opencode db path"
try:
import subprocess
bin_path = shutil.which("opencode")
if not bin_path:
for candidate_bin in [
f"{ctx.home_dir}/.opencode/bin/opencode",
os.path.expanduser("~/.opencode/bin/opencode"),
]:
if os.path.exists(candidate_bin):
bin_path = candidate_bin
break
if bin_path:
env = dict(os.environ)
if ctx.home_dir:
env["HOME"] = ctx.home_dir
env["HOME_DIR"] = ctx.home_dir
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
cwd = ctx.workspace or ctx.cwd or os.getcwd()
res = subprocess.run(
[bin_path, "db", "path"],
capture_output=True,
text=True,
timeout=2,
env=env,
cwd=cwd if os.path.exists(cwd) else None
)
if res.returncode == 0:
out = res.stdout.strip()
if out and os.path.exists(out):
return out
except Exception:
pass
# 2. Filesystem fallbacks
base_dir = f"{ctx.home_dir}/.local/share/opencode"
candidates = [
f"{base_dir}/opencode.db",
f"{base_dir}/storage.db",
]
for cand in candidates:
if os.path.exists(cand):
return cand
if os.path.isdir(f"{base_dir}/project"):
for root, _, files in os.walk(f"{base_dir}/project"):
for f in files:
if f.endswith(".db"):
return os.path.join(root, f)
return candidates[0]
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
db = self._resolve_db(ctx)
return db or f"{ctx.home_dir}/.local/share/opencode/opencode.db"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
db = self._resolve_db(ctx)
if not db or not os.path.exists(db):
return False
try:
conn = sqlite3.connect(db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(session)")
cols = [col[1] for col in cursor.fetchall()]
if not cols:
conn.close()
return False
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
query_cols = []
if cwd_col:
query_cols.append(cwd_col)
if time_col:
query_cols.append(time_col)
query = "SELECT " + (", ".join(query_cols) if query_cols else "id") + " FROM session WHERE id=?"
r = cursor.execute(query, (uuid,)).fetchone()
conn.close()
if not r:
return False
found_cwd = None
started_at = None
idx = 0
if cwd_col:
found_cwd = r[idx]
idx += 1
if time_col:
started_at = r[idx]
idx += 1
if ctx.epoch and started_at is not None:
try:
val = float(started_at)
if time_col == "time_created" or val > 1e11:
val = val / 1000.0
if val < float(ctx.epoch):
return False
except (ValueError, TypeError):
pass
if found_cwd and ctx.cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
return False
return True
except Exception:
return False
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
purged = []
db = self._resolve_db(ctx)
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cursor.fetchall()}
if "message" in tables:
conn.execute("DELETE FROM message WHERE session_id=?", (uuid,))
if "messages" in tables:
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
if "part" in tables:
conn.execute("DELETE FROM part WHERE session_id=?", (uuid,))
if "parts" in tables:
conn.execute("DELETE FROM parts WHERE session_id=?", (uuid,))
if "session" in tables:
conn.execute("DELETE FROM session WHERE id=?", (uuid,))
if "sessions" in tables:
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
conn.commit()
conn.close()
purged.append(f"sqlite rows for session: {uuid}")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --auto --agent build"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --session {session_uuid} --auto --agent build"
return f"{binary} --auto --agent build"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS):
return True
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
auth_file = f"{home}/.local/share/opencode/auth.json"
return os.path.exists(auth_file)
def discover(self, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx)
if not db or not os.path.exists(db):
return []
try:
conn = sqlite3.connect(db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(session)")
cols = [col[1] for col in cursor.fetchall()]
if not cols:
conn.close()
return []
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
query = "SELECT id FROM session"
params = []
where_clauses = []
if cwd_col and ctx.workspace:
where_clauses.append(f"{cwd_col} = ?")
params.append(ctx.workspace)
if time_col and ctx.epoch:
if time_col == "time_created":
where_clauses.append(f"{time_col} >= ?")
params.append(int(float(ctx.epoch) * 1000))
else:
where_clauses.append(f"{time_col} >= ?")
params.append(ctx.epoch)
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
if time_col:
query += f" ORDER BY {time_col} DESC"
query += " LIMIT 20"
rows = cursor.execute(query, params).fetchall()
conn.close()
candidates = []
for (cand,) in rows:
if cand and self.verify_artifact(cand, ctx):
candidates.append(cand)
return candidates
except Exception:
return []