Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/opencode.py
T
GodopuandClaude Opus 5.5 ac5f1154c7 fix(opencode): queries open the database read-only without touching its sidecars (Issue #60, Linux item 5)
A read-write open checkpointed the WAL and deleted -wal/-shm on close; a
plain mode=ro open created missing ones and left them. _ro_connect uses the
sidecars only when both exist (a live writer keeps them) and reads the file
as immutable otherwise. verify_artifact, _has_session, discover,
_descendants and residual_artifacts (db_rows_naming takes a connect) use it;
the purge's own writes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:31:44 +00:00

328 lines
14 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, subprocess
from urllib.parse import quote
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
from lib_py.verify_session import workspace_key
# Tables a 1.x opencode wrote and a migration to 2.x keeps but no longer
# writes (`opencode session delete` leaves them), and their session column.
_LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part", "session_id"),
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
("session", "id"), ("sessions", "id"))
def _ro_connect(db: str):
"""A read that neither creates nor removes anything beside db (Issue #60):
the WAL sidecars are used only when both already exist (a live writer
keeps them); otherwise the file is read as immutable."""
path = os.path.abspath(db)
both = os.path.exists(path + "-wal") and os.path.exists(path + "-shm")
return sqlite3.connect(f"file:{quote(path)}?mode=ro" + ("" if both else "&immutable=1"), uri=True)
_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_placeholder_pattern(self) -> Optional[str]:
# 2.x home screen: `Ask anything… "<example>"`, the example picked per
# launch and cut without its closing quote in a narrow box (Issue #60).
return r'Ask anything(?:…|\.\.\.)(?: "[^"]*"?)?'
@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.*·'
@property
def resident_children(self) -> tuple:
# --standalone keeps its server, `opencode serve --stdio --port 0`, as
# the TUI's child while idle (measured on 2.0.32, Issue #60).
return ('opencode',)
def _bin(self, ctx: DiscoveryContext) -> Optional[str]:
bin_path = shutil.which("opencode")
if bin_path:
return 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):
return candidate_bin
return None
def _data_home(self, ctx: DiscoveryContext) -> str:
"""The XDG data dir opencode uses for ctx's home: the caller's
XDG_DATA_HOME when ctx is the caller's own home, else <home>/.local/share."""
ambient = os.environ.get("XDG_DATA_HOME")
own = os.path.realpath(ctx.home_dir or "") == os.path.realpath(os.path.expanduser("~"))
if ambient and (own or not ctx.home_dir):
return ambient
return os.path.join(ctx.home_dir, ".local/share")
def _run(self, ctx: DiscoveryContext, args: list, timeout: int):
"""Run the opencode CLI against ctx's home (its data dir follows HOME and
XDG_DATA_HOME); None when there is no binary."""
bin_path = self._bin(ctx)
if not bin_path:
return None
env = dict(os.environ)
if ctx.home_dir:
env["HOME"] = ctx.home_dir
env["HOME_DIR"] = ctx.home_dir
env["XDG_DATA_HOME"] = self._data_home(ctx)
cwd = ctx.workspace or ctx.cwd or os.getcwd()
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
env=env, cwd=cwd if os.path.exists(cwd) else None)
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
# opencode 2.x prints its paths with `debug paths` (the 1.x `db path`
# is gone: it prints the help and exits 0). Fallback: the default.
try:
res = self._run(ctx, ["debug", "paths"], timeout=5)
if res is not None and res.returncode == 0:
for line in res.stdout.splitlines():
parts = line.split(None, 1)
if len(parts) == 2 and parts[0] == "db":
return parts[1].strip()
except Exception:
pass
return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
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:
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
# survives a migration but is no longer written).
conn = _ro_connect(db)
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
return False
found_cwd, started_at = r
if ctx.epoch and started_at is not None:
try:
if float(started_at) / 1000.0 < 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 not db or not os.path.exists(db):
return purged
# The child sessions are taken before opencode deletes them with
# their parent: their 1.x rows are this purge's to remove too.
ids = [uuid] + self._descendants(db, uuid)
if self._has_session(db, uuid):
# opencode deletes a 2.x session with its child sessions and every
# 2.x row it keeps for them; --standalone so no background service
# is started for it.
try:
res = self._run(ctx, ["session", "delete", "--standalone", "--", uuid], timeout=60)
if res is None:
sys.stderr.write(f"WARN: purge opencode: no opencode binary to delete session {uuid}\n")
elif res.returncode == 0:
purged.append(f"opencode session (with its child sessions): {uuid}")
else:
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} exited {res.returncode}: "
f"{(res.stderr or res.stdout).strip()}\n")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} failed: {e}\n")
# The rows a 1.x opencode left (a migration keeps those tables).
try:
conn = sqlite3.connect(db)
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
legacy = [(t, c) for t, c in _LEGACY_TABLES if t in tables]
for t, c in legacy:
for i in ids:
conn.execute(f'DELETE FROM "{t}" WHERE "{c}"=?', (i,))
conn.commit()
conn.close()
if legacy:
kids = f" and {len(ids) - 1} child session(s)" if len(ids) > 1 else ""
purged.append(f"sqlite rows for session: {uuid}{kids}")
except Exception as e:
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged
def _descendants(self, db: str, uuid: str) -> list:
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
found, todo = [], [uuid]
try:
conn = _ro_connect(db)
except Exception:
return found
try:
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
sources = [t for t in ("session_v2", "session") if t in tables
and "parent_id" in {r[1] for r in conn.execute(f'PRAGMA table_info("{t}")')}]
while todo:
cur = todo.pop()
for t in sources:
for (cid,) in conn.execute(f'SELECT id FROM "{t}" WHERE parent_id=?', (cur,)):
if cid and cid != uuid and cid not in found:
found.append(cid)
todo.append(cid)
except Exception:
pass
finally:
conn.close()
return found
def _has_session(self, db: str, uuid: str) -> bool:
try:
conn = _ro_connect(db)
try:
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
finally:
conn.close()
except Exception:
return False
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: whatever still names the session or a child session still
# on record (a failed `session delete`) is reported.
db = self._resolve_db(ctx)
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
id_tables=(("session", "id"), ("session_v2", "id")), connect=_ro_connect)
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
return found
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
"""A process naming the session, or an opencode process holding the
shared database. The per-user background service (`serve --service`)
holds it for every session and runs none of MAM's (MAM spawns
--standalone), so it alone does not make a session in use."""
if conversation_in_use(uuid, []):
return True
db = self._resolve_db(ctx)
if not db or not os.path.exists(db) or not shutil.which("lsof"):
return False
try:
pids = subprocess.run(["lsof", "-t", db], capture_output=True, text=True, timeout=10).stdout.split()
except Exception:
return True
for pid in pids:
try:
cmd = subprocess.run(["ps", "-o", "command=", "-p", pid], capture_output=True, text=True,
timeout=10).stdout
except Exception:
return True
if cmd.strip() and "serve --service" not in cmd:
return True
return False
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
return super().in_use_hints(uuid, ctx) + [
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
# opencode 2.x: the default agent is build (`--agent` is gone from the
# TUI), and --standalone keeps the server a child of the pane's TUI
# instead of the per-user background service.
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --auto --standalone"
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 --standalone"
return f"{binary} --auto --standalone"
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:
# Top-level sessions only: a subagent's session (parent_id set)
# shares the directory and is newer than the one the TUI shows.
query = "SELECT id FROM session_v2 WHERE parent_id IS NULL"
params = []
if ctx.workspace:
query += " AND directory = ?"
params.append(ctx.workspace)
if ctx.epoch:
query += " AND time_created >= ?"
params.append(int(float(ctx.epoch) * 1000))
query += " ORDER BY time_created DESC LIMIT 20"
conn = _ro_connect(db)
rows = conn.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 []