From a311c2330371579e872cea22b054a7ada0aa0b1f Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 18 Oct 2026 14:58:29 +0900 Subject: [PATCH] fix(opencode): support opencode 2.x only - session_v2, debug paths, session delete, --standalone (Issue #60) Measured on opencode 2.0.32 (macOS): - the TUI rejects --agent ("Unrecognized flag"), so create and resume could not start opencode at all; the default agent is build. MAM now spawns `opencode --auto --standalone` (resume adds --session ): the server runs as the TUI's child and ends with it, and the per-user background service is neither used nor started; - sessions live in session_v2 (a fresh 2.x database has no `session` table; a migrated one keeps the 1.x tables but no longer writes them). discover, verify_artifact, reconcile drift C and status read session_v2, and discover / drift C skip child (subagent) sessions; - `db path` is gone (it prints the help and exits 0): the db comes from the `db` line of `opencode debug paths`, else the default path; - `opencode session delete` removes a session with its child sessions from the 2.x tables (and leaves the 1.x ones): purge runs it with --standalone for a session in session_v2, still deletes the 1.x message/part/session rows, and residual also reports a session_v2 row left by a failed delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .../skills/lib_py/agents/adapters/opencode.py | 204 ++++++++---------- .../scripts/create_session.sh | 2 +- .../scripts/reconcile.sh | 9 +- .../scripts/resume_session.sh | 4 +- .../scripts/update_yaml_resumed.sh | 4 +- .../multi-agent-mux-status/scripts/status.sh | 4 +- 6 files changed, 104 insertions(+), 123 deletions(-) diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index f1dd484..3d89c9b 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -1,4 +1,4 @@ -import os, sys, sqlite3, shutil +import os, sys, sqlite3, shutil, subprocess from typing import Optional, Any, List from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming from lib_py.verify_session import workspace_key @@ -59,56 +59,46 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): # blanks after the ┃) or the `Build auto · ` status line. return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·' + 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 _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"] = os.path.join(ctx.home_dir, ".local/share") + 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]: - # 1. Try opencode CLI "opencode db path" + # 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: - 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 + 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 - - # 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] + return f"{ctx.home_dir}/.local/share/opencode/opencode.db" def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: db = self._resolve_db(ctx) @@ -119,44 +109,18 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): 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) - 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() + r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (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 - + found_cwd, started_at = r 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): + if float(started_at) / 1000.0 < float(ctx.epoch): return False except (ValueError, TypeError): pass @@ -169,7 +133,23 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: purged = [] db = self._resolve_db(ctx) + if db and os.path.exists(db) and self._has_session(db, uuid): + # opencode deletes a 2.x session with its child sessions and every + # 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") if db and os.path.exists(db): + # Rows a 1.x opencode left in the tables a migration keeps. try: conn = sqlite3.connect(db) cursor = conn.cursor() @@ -189,25 +169,39 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): conn.execute("DELETE FROM sessions WHERE id=?", (uuid,)) conn.commit() conn.close() - purged.append(f"sqlite rows for session: {uuid}") + if tables & {"message", "messages", "part", "parts", "session", "sessions"}: + 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 _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: the purge leaves event-sourced rows (event, todo, ...); - # deleting those is not known to be safe, so they are reported. + # Issue #31: whatever still names the session (event-sourced rows a + # 1.x opencode left, a failed `session delete`) is reported. found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid) return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"), - id_tables=(("session", "id"),)) + id_tables=(("session", "id"), ("session_v2", "id"))) + # 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 --agent build" + 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 --agent build" - return f"{binary} --auto --agent build" + 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): @@ -221,38 +215,20 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): 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" + # 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 = [] - where_clauses = [] - if cwd_col and ctx.workspace: - where_clauses.append(f"{cwd_col} = ?") + if ctx.workspace: + query += " AND directory = ?" 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 ctx.epoch: + query += " AND time_created >= ?" + params.append(int(float(ctx.epoch) * 1000)) + query += " ORDER BY time_created DESC LIMIT 20" - 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 = sqlite3.connect(db) + rows = conn.execute(query, params).fetchall() conn.close() candidates = [] for (cand,) in rows: diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 6f5c960..aa3525f 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -213,7 +213,7 @@ if [ -z "$CMD_FULL" ]; then agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;; grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;; - opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;; + opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;; codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;; esac fi diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 7ede101..31a2c29 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -952,15 +952,20 @@ for s in d.get('herdr_sessions', []): try: conn = sqlite3.connect(odb) cursor = conn.cursor() - cursor.execute("PRAGMA table_info(session)") + # opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session` + # table is no longer written). + cursor.execute("PRAGMA table_info(session_v2)") cols = [col[1] for col in cursor.fetchall()] if cols: 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" + query = "SELECT id FROM session_v2" params = [] where_clauses = [] + if "parent_id" in cols: + # a subagent's session shares the directory and is newer + where_clauses.append("parent_id IS NULL") if cwd_col and cwd: where_clauses.append(f"{cwd_col} = ?") params.append(cwd) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 02d9be9..152ad82 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -178,7 +178,7 @@ if [ "$FRESH_SPAWN" = "1" ]; then case "$AGENT" in agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;; - opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;; + opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;; codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;; esac fi @@ -190,7 +190,7 @@ else agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;; grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;; - opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;; + opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --standalone" ;; codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;; *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; esac diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index d30ad3b..57c5739 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -238,10 +238,10 @@ elif agent == 'grok': elif agent == 'opencode': target['pane']['cmd'] = 'opencode' if uuid: - target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build' + target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --standalone' target['opencode_session_id_own'] = uuid else: - target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --agent build' + target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --standalone' cp = os.environ.get('CHILD_PID', '0') if cp.isdigit() and int(cp) > 0: target['child_pid'] = int(cp) diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 249f906..ca66b6a 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -124,7 +124,7 @@ def resume_on_disk(s): try: import sqlite3 conn = sqlite3.connect(odb) - r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone() + r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() return 'yes' if r else 'MISSING' except Exception: @@ -334,7 +334,7 @@ def resume_on_disk(s): try: import sqlite3 conn = sqlite3.connect(odb) - r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone() + r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() return 'yes' if r else 'MISSING' except Exception: