From 7341186ef9cd000bcf4b2171591b91759f9f539c Mon Sep 17 00:00:00 2001 From: Godopu Date: Sat, 29 Aug 2026 14:58:57 +0900 Subject: [PATCH] feat(opencode): implement OpenCodeAgentAdapter and core identity bindings --- .../skills/lib_py/agents/adapters/opencode.py | 244 ++++++++++++++++++ .agents/skills/lib_py/agents/registry.py | 2 + .agents/skills/lib_py/atomic_yaml.py | 2 +- .agents/skills/lib_py/verify_session.py | 2 +- .agents/skills/lib_py/workspace_uuid.py | 5 +- 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 .agents/skills/lib_py/agents/adapters/opencode.py diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py new file mode 100644 index 0000000..9e5d01b --- /dev/null +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -0,0 +1,244 @@ +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' + + @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]: + return '─{10,}' + + 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 [] diff --git a/.agents/skills/lib_py/agents/registry.py b/.agents/skills/lib_py/agents/registry.py index 05538c0..a04748d 100644 --- a/.agents/skills/lib_py/agents/registry.py +++ b/.agents/skills/lib_py/agents/registry.py @@ -6,12 +6,14 @@ from lib_py.agents.adapters.claude import ClaudeAgentAdapter from lib_py.agents.adapters.agy import AgyAgentAdapter from lib_py.agents.adapters.hermes import HermesAgentAdapter from lib_py.agents.adapters.grok import GrokAgentAdapter +from lib_py.agents.adapters.opencode import OpenCodeAgentAdapter _ADAPTERS: Dict[str, BaseAgentAdapter] = { 'claude': ClaudeAgentAdapter(), 'agy': AgyAgentAdapter(), 'hermes': HermesAgentAdapter(), 'grok': GrokAgentAdapter(), + 'opencode': OpenCodeAgentAdapter(), } def get_adapter(agent_name: str) -> Optional[BaseAgentAdapter]: diff --git a/.agents/skills/lib_py/atomic_yaml.py b/.agents/skills/lib_py/atomic_yaml.py index f771e15..a80f50e 100644 --- a/.agents/skills/lib_py/atomic_yaml.py +++ b/.agents/skills/lib_py/atomic_yaml.py @@ -129,7 +129,7 @@ def atomic_dump_yaml_main(): if name in old_roles and s.get('role') != old_roles[name]: raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}") - running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'grok_session_id_own'] + running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'grok_session_id_own', 'opencode_session_id_own'] id_to_session = {} for s in d.get('herdr_sessions', []): if s.get('status') == 'running': diff --git a/.agents/skills/lib_py/verify_session.py b/.agents/skills/lib_py/verify_session.py index c191622..5f46f20 100644 --- a/.agents/skills/lib_py/verify_session.py +++ b/.agents/skills/lib_py/verify_session.py @@ -66,7 +66,7 @@ def mam_orchestrator_uuids(): def mam_row_own_uuid(row): if not isinstance(row, dict): return None - for k in ["claude_session_id_own", "agy_conversation_id_own", "hermes_conversation_id_own", "grok_session_id_own"]: + for k in ["claude_session_id_own", "agy_conversation_id_own", "hermes_conversation_id_own", "grok_session_id_own", "opencode_session_id_own"]: v = row.get(k) if v: return v diff --git a/.agents/skills/lib_py/workspace_uuid.py b/.agents/skills/lib_py/workspace_uuid.py index 82ec95f..6874429 100644 --- a/.agents/skills/lib_py/workspace_uuid.py +++ b/.agents/skills/lib_py/workspace_uuid.py @@ -8,7 +8,8 @@ OWN_KEY = { 'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own', 'hermes': 'hermes_conversation_id_own', - 'grok': 'grok_session_id_own' + 'grok': 'grok_session_id_own', + 'opencode': 'opencode_session_id_own' } from lib_py.paths import resolve_home @@ -30,7 +31,7 @@ def find_workspace_uuid_main(): if s_item.get('status') == 'running': if target and s_item.get('name') == target: continue - for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'grok_session_id_own']: + for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'grok_session_id_own', 'opencode_session_id_own']: val = s_item.get(k) if val: running_ids.add(val)