Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/opencode.py
T
GodopuandClaude Opus 5.5 cb5f439cce fix(opencode): a purge takes its child sessions' 1.x rows, the background service alone holds no session, XDG_DATA_HOME is followed (Issue #60, review)
- Review 614b6263 (codex, P2): `opencode session delete` removes the child
  sessions with their parent, but their 1.x rows stayed and the parent's
  residual was empty. The child sessions (session_v2 and a 1.x session
  table, any depth) are taken before the delete; the 1.x tables (message,
  part, todo, session_share, session) are cleaned for all of them, and the
  residual also reports a child still on record (a failed delete).
- The in-use check counted any process holding the shared opencode.db; the
  per-user 2.x background service (`serve --service`) holds it for every
  session, so every opencode purge was refused while it ran (it does on
  this Mac). It no longer counts; any other holder and a command line
  naming the session still do.
- The data dir follows the caller's XDG_DATA_HOME when ctx is the caller's
  own home (else <home>/.local/share), for debug paths, the fallback and the
  residual scan (review 614b6263 / f4cedbae N4).
- resident_children = ('opencode',): the --standalone server stays the TUI's
  idle child; what runs under it still counts (review 1abe0a90).
- Tests: child sessions on a migrated database, a failed delete, the service
  holder, XDG, resident children, drift C pinning the parent over a newer
  child session (review f4cedbae N2), and the real-binary test no longer
  depends on PATH (N1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 15:46:04 +09:00

313 lines
13 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"))
_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.*·'
@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 = sqlite3.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 = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
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 = sqlite3.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")))
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 = sqlite3.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 []