diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index bb8ff9b..b85c7be 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -76,7 +76,8 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): def input_placeholder_pattern(self) -> Optional[str]: # 2.x home screen: `Ask anything… ""`, the example picked per # launch and cut without its closing quote in a narrow box (Issue #60). - return r'Ask anything(?:…|\.\.\.)(?: "[^"]*"?)?' + # The example is required: a bare `Ask anything…` is a typed draft. + return r'Ask anything(?:…|\.\.\.) "[^"]*"?' @property def input_rule_pattern(self) -> Optional[str]: @@ -318,7 +319,8 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): # opencode 2.x needs no login: a fresh HOME runs a free default model # and local providers have no key, so the binary is the whole check. home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~") - return bool(shutil.which("opencode")) or os.path.exists(f"{home}/.opencode/bin/opencode") + local = f"{home}/.opencode/bin/opencode" + return bool(shutil.which("opencode")) or (os.path.isfile(local) and os.access(local, os.X_OK)) def discover(self, ctx: DiscoveryContext) -> list: db = self._resolve_db(ctx) diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 31a2c29..8a967a0 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -950,7 +950,9 @@ for s in d.get('herdr_sessions', []): valid_candidates = [] try: - conn = sqlite3.connect(odb) + # read-only: nothing is created or removed beside the agent's db (Issue #60) + from lib_py.agents.adapters.opencode import _ro_connect + conn = _ro_connect(odb) cursor = conn.cursor() # opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session` # table is no longer written). diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index e3aea31..9614c35 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -112,8 +112,9 @@ def resume_on_disk(s): if not odb or not os.path.exists(odb): return 'MISSING' try: - import sqlite3 - conn = sqlite3.connect(odb) + # read-only: nothing is created or removed beside the agent's db (Issue #60) + from lib_py.agents.adapters.opencode import _ro_connect + conn = _ro_connect(odb) r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() return 'yes' if r else 'MISSING' @@ -312,8 +313,9 @@ def resume_on_disk(s): if not odb or not os.path.exists(odb): return 'MISSING' try: - import sqlite3 - conn = sqlite3.connect(odb) + # read-only: nothing is created or removed beside the agent's db (Issue #60) + from lib_py.agents.adapters.opencode import _ro_connect + conn = _ro_connect(odb) r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x conn.close() return 'yes' if r else 'MISSING' diff --git a/tests/test_issue60_linux_fixes.py b/tests/test_issue60_linux_fixes.py index 0b47d9b..d476a58 100644 --- a/tests/test_issue60_linux_fixes.py +++ b/tests/test_issue60_linux_fixes.py @@ -81,6 +81,8 @@ def test_the_narrow_screen_stops_at_its_status_line_and_reads_ready(): 'Ask anything… "x" and fix it', "Ask anything about the build", 'Ask anything… "a"b', + "Ask anything…", + "Ask anything...", ]) def test_typed_text_is_read_as_typed(name, typed): text, n = PLACEHOLDER_LINE.subn(lambda m: m.group(1) + typed, _pane(name), count=1) @@ -153,6 +155,11 @@ def wal_home(tmp_path, monkeypatch): def make(state): if state == "clean": _wal_writer(db, ws).close() + # macOS SQLite keeps both sidecars after the last close (persistent + # WAL); the close checkpointed them, so the Linux state is the same rows. + for suffix in ("-wal", "-shm"): + if os.path.exists(str(db) + suffix): + os.remove(str(db) + suffix) elif state in ("leftover", "walonly"): src = tmp_path / "src" / "opencode.db" src.parent.mkdir() @@ -184,6 +191,16 @@ def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, list assert sorted(os.listdir(os.path.dirname(db))) == listing +@pytest.mark.parametrize("script,count", [ + ("multi-agent-mux-status/scripts/status.sh", 2), + ("multi-agent-mux-monitor/scripts/reconcile.sh", 1), +]) +def test_status_and_reconcile_open_the_database_read_only(script, count): + text = (SKILLS / script).read_text() + assert text.count("_ro_connect(odb)") == count + assert "sqlite3.connect(odb)" not in text + + # --------------------------------------------------------------------------- # 6. `debug paths` once per (binary, HOME, XDG_DATA_HOME) # --------------------------------------------------------------------------- @@ -314,6 +331,16 @@ def test_keys_and_auth_json_without_the_binary_are_not_enough(bare_home, monkeyp assert opencode.auth_ok() is False +def test_a_home_install_that_cannot_run_is_not_enough(bare_home): + local = bare_home / "home" / ".opencode" / "bin" / "opencode" + _executable(local).chmod(0o644) + assert get_adapter("opencode").auth_ok() is False + local.unlink() + local.mkdir() + local.chmod(0o755) + assert get_adapter("opencode").auth_ok() is False + + @pytest.mark.parametrize("where", ["path", "home"]) def test_the_binary_alone_is_enough(bare_home, monkeypatch, where): if where == "path":