Compare commits

...
8 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 6a4e385a78 test(opencode): the narrow 2.x capture ends at its last drawn line (Issue #60, review: git diff --check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:38:58 +00:00
GodopuandClaude Opus 5.5 d17cd80bd9 fix(opencode): the auth check is the binary, and create takes the adapter's verdict (Issue #60, Linux item 3)
opencode 2.x needs no login: a fresh HOME runs a free default model and a
local provider has no key, while 2.x keeps no auth.json. auth_ok is now
"opencode on PATH or under <HOME>/.opencode/bin". `python -m lib_py.agents
auth-ok <agent>` exits with an adapter's verdict, and create's opencode
branch uses it (same 7 lines) instead of its own auth.json/key test and
`opencode --version`, which wrote a line to the real opencode log.
test_a4's opencode block now pins the binary rule (decision D1, required
by the brief's item 3 and by both reviewers). Other agents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:38:58 +00:00
GodopuandClaude Opus 5.5 7803d47026 fix(opencode): a -wal left without its -shm is read, not ignored (Issue #60, Linux item 5, review)
_ro_connect read every database without both sidecars as immutable, which
skips a -wal's committed rows: a recovered DB+WAL with no -shm showed no
sessions (planner-reviewer-codex-01). That state - no writer runs without
a -shm - is now read from a private DB+WAL copy, where SQLite may rebuild
the index, loaded into memory; nothing beside the source is touched. The
query matrix gains the walonly state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:36:41 +00:00
GodopuandClaude Opus 5.5 463817ee34 fix(stop): an unconfirmed /exit no longer claims a kill-chain fallback (Issue #60, Linux item 2)
An agent that exits on /exit never turns `working`, so send_keys_safe
returns 4 although the stop then ends cleanly. The line now says the
delivery is unconfirmed and that the stop waits for the session to exit;
the kill steps keep their own lines, printed only when they run. Exit
judgement and the kill chain are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:35:27 +00:00
GodopuandClaude Opus 5.5 614ab1420f fix(opencode): debug paths runs once per binary and home in a process (Issue #60, Linux item 6)
Every run appends a line to opencode's log, and one discover resolved the
database once per candidate. A successful answer is kept per (binary, HOME,
XDG_DATA_HOME) - the values the run sees; a failed run is not remembered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:33:01 +00:00
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
GodopuandClaude Opus 5.5 14d7a46181 fix(opencode): the 2.x home placeholder is not typed input, cut example included (Issue #60, Linux item 1)
An adapter can now name its placeholder as a pattern the box's first line
matches in full (input_placeholder_pattern); input_placeholder stays the
literal lib.sh greps for. opencode: `Ask anything… "<example>"`, the closing
quote optional because a box under ~65 columns cuts the example. Fixtures:
the 140-column herdr capture and a 60-column one with the cut example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:29:54 +00:00
GodopuandClaude Opus 5.5 4d49abb371 fix(tests): u1e hands the wrapper its own python - PATH's python3 may lack paho (Issue #60, Linux item 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:23:35 +00:00
11 changed files with 515 additions and 31 deletions

No files matched your search

+9
View File
@@ -117,6 +117,15 @@ def main():
print(adapter.resume_spec(binary, uuid, materialized=mat)) print(adapter.resume_spec(binary, uuid, materialized=mat))
sys.exit(0) sys.exit(0)
elif cmd == 'auth-ok':
# auth-ok <agent>: exit 0 when the adapter's auth_ok() holds, else 1.
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
adapter = get_adapter(agent_name)
if not adapter:
print(f"ERROR: unknown agent {agent_name!r}", file=sys.stderr)
sys.exit(1)
sys.exit(0 if adapter.auth_ok() else 1)
elif cmd == 'exit-key': elif cmd == 'exit-key':
agent_name = sys.argv[2] if len(sys.argv) > 2 else '' agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
adapter = get_adapter(agent_name) adapter = get_adapter(agent_name)
@@ -1,4 +1,4 @@
import os, sys, sqlite3, shutil, subprocess import os, sys, sqlite3, shutil, subprocess, tempfile
from urllib.parse import quote from urllib.parse import quote
from typing import Optional, Any, List 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.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
@@ -10,12 +10,34 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"), ("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
("session", "id"), ("sessions", "id")) ("session", "id"), ("sessions", "id"))
_PROVIDER_ENV_VARS = ( def _ro_connect(db: str):
'ANTHROPIC_API_KEY', """A read that neither creates nor removes anything beside db (Issue #60).
'OPENAI_API_KEY', Both WAL sidecars (a live writer keeps them): read through them. No -wal:
'GEMINI_API_KEY', nothing to apply, so the file is read as immutable. A -wal without its
'GROQ_API_KEY' -shm (left behind - no writer runs without one) still holds committed
) rows: they are read from a private copy, where SQLite may rebuild the
index, loaded into memory."""
path = os.path.abspath(db)
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
if wal and shm:
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
if not wal:
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
mem = sqlite3.connect(":memory:")
with tempfile.TemporaryDirectory(prefix="mam-opencode-") as tmp:
copy = os.path.join(tmp, "opencode.db")
shutil.copyfile(path, copy)
shutil.copyfile(path + "-wal", copy + "-wal")
src = sqlite3.connect(copy)
try:
src.backup(mem)
finally:
src.close()
return mem
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
_DB_PATHS = {}
class OpenCodeAgentAdapter(BaseAgentAdapter): class OpenCodeAgentAdapter(BaseAgentAdapter):
@property @property
@@ -50,6 +72,12 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
def input_placeholder(self) -> Optional[str]: def input_placeholder(self) -> Optional[str]:
return '' 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 @property
def input_rule_pattern(self) -> Optional[str]: def input_rule_pattern(self) -> Optional[str]:
# Kept for lib.sh's readiness corroboration; the input box itself is a # Kept for lib.sh's readiness corroboration; the input box itself is a
@@ -111,13 +139,23 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]: def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
# opencode 2.x prints its paths with `debug paths` (the 1.x `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. # is gone: it prints the help and exits 0). Fallback: the default.
# Each run writes a line to opencode's log, so a process asks once
# per (binary, HOME, XDG_DATA_HOME) the run would see (Issue #60).
bin_path = self._bin(ctx)
if ctx.home_dir:
key = (bin_path, ctx.home_dir, self._data_home(ctx))
else:
key = (bin_path, os.environ.get("HOME"), os.environ.get("XDG_DATA_HOME"))
if bin_path and key in _DB_PATHS:
return _DB_PATHS[key]
try: try:
res = self._run(ctx, ["debug", "paths"], timeout=5) res = self._run(ctx, ["debug", "paths"], timeout=5)
if res is not None and res.returncode == 0: if res is not None and res.returncode == 0:
for line in res.stdout.splitlines(): for line in res.stdout.splitlines():
parts = line.split(None, 1) parts = line.split(None, 1)
if len(parts) == 2 and parts[0] == "db": if len(parts) == 2 and parts[0] == "db":
return parts[1].strip() _DB_PATHS[key] = parts[1].strip()
return _DB_PATHS[key]
except Exception: except Exception:
pass pass
return os.path.join(self._data_home(ctx), "opencode", "opencode.db") return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
@@ -133,7 +171,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
try: try:
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table # opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
# survives a migration but is no longer written). # survives a migration but is no longer written).
conn = sqlite3.connect(db) conn = _ro_connect(db)
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone() r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
conn.close() conn.close()
if not r: if not r:
@@ -196,7 +234,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table.""" """Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
found, todo = [], [uuid] found, todo = [], [uuid]
try: try:
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True) conn = _ro_connect(db)
except Exception: except Exception:
return found return found
try: try:
@@ -218,7 +256,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
def _has_session(self, db: str, uuid: str) -> bool: def _has_session(self, db: str, uuid: str) -> bool:
try: try:
conn = sqlite3.connect(db) conn = _ro_connect(db)
try: try:
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
finally: finally:
@@ -233,7 +271,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid) 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 []): 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"), rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
id_tables=(("session", "id"), ("session_v2", "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] found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
return found return found
@@ -277,11 +315,10 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
return f"{binary} --auto --standalone" return f"{binary} --auto --standalone"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool: def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS): # opencode 2.x needs no login: a fresh HOME runs a free default model
return True # 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("~") 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 bool(shutil.which("opencode")) or os.path.exists(f"{home}/.opencode/bin/opencode")
return os.path.exists(auth_file)
def discover(self, ctx: DiscoveryContext) -> list: def discover(self, ctx: DiscoveryContext) -> list:
db = self._resolve_db(ctx) db = self._resolve_db(ctx)
@@ -300,7 +337,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
params.append(int(float(ctx.epoch) * 1000)) params.append(int(float(ctx.epoch) * 1000))
query += " ORDER BY time_created DESC LIMIT 20" query += " ORDER BY time_created DESC LIMIT 20"
conn = sqlite3.connect(db) conn = _ro_connect(db)
rows = conn.execute(query, params).fetchall() rows = conn.execute(query, params).fetchall()
conn.close() conn.close()
candidates = [] candidates = []
+11 -3
View File
@@ -108,14 +108,15 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
return sorted(found) return sorted(found)
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list: def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this """"<db> (<table>.<col>: <n> rows)" for every table holding rows of this
conversation in one of `columns`, or in the id column of an `id_tables` conversation in one of `columns`, or in the id column of an `id_tables`
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.""" (table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
`connect(db)` opens the database (default: a mode=ro URI)."""
if not _plausible_id(uuid) or not db or not os.path.isfile(db): if not _plausible_id(uuid) or not db or not os.path.isfile(db):
return [] return []
try: try:
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True) conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
except Exception: except Exception:
return [] return []
found = [] found = []
@@ -208,6 +209,13 @@ class BaseAgentAdapter:
def input_placeholder(self) -> Optional[str]: def input_placeholder(self) -> Optional[str]:
return None return None
@property
def input_placeholder_pattern(self) -> Optional[str]:
"""Regex the input box's first line matches in full while it shows
only a placeholder whose text varies (#60); input_placeholder stays
the literal lib.sh greps for."""
return None
@property @property
def input_rule_pattern(self) -> Optional[str]: def input_rule_pattern(self) -> Optional[str]:
return None return None
+4 -2
View File
@@ -17,7 +17,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
prompt line is input: a multi-line draft is never cut to its first line. prompt line is input: a multi-line draft is never cut to its first line.
Then `input_frame_chars` are stripped, and the prompt and the placeholder Then `input_frame_chars` are stripped, and the prompt and the placeholder
(whole-line) are stripped from the box's first non-blank line only. There (whole-line: `input_placeholder` exactly or `input_placeholder_pattern` in
full) are stripped from the box's first non-blank line only. There
is no whole-pane fallback: a pane with none of these shapes has no input is no whole-pane fallback: a pane with none of these shapes has no input
region and yields a non-empty "unknown" result rather than a guess. region and yields a non-empty "unknown" result rather than a guess.
Raises ValueError for agents without input area facts (like hermes' peers). Raises ValueError for agents without input area facts (like hermes' peers).
@@ -79,7 +80,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
if first is not None: if first is not None:
head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1) head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1)
placeholder = adapter.input_placeholder placeholder = adapter.input_placeholder
if placeholder and head.strip() == placeholder: pattern = adapter.input_placeholder_pattern
if (placeholder and head.strip() == placeholder) or (pattern and re.fullmatch(pattern, head.strip())):
head = '' head = ''
region_lines[first] = head region_lines[first] = head
@@ -143,9 +143,9 @@ elif [ "$AGENT" = "grok" ]; then
exit 1 exit 1
fi fi
elif [ "$AGENT" = "opencode" ]; then elif [ "$AGENT" = "opencode" ]; then
if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then # opencode needs no login (2.x: a free default model, keyless local
true # providers); the adapter's auth_ok is the one verdict.
elif ! opencode --version >/dev/null 2>&1; then if ! "$(_delegate_py_bin)" -m lib_py.agents auth-ok opencode >/dev/null 2>&1; then
echo "ERROR: opencode is not functional or configured." >&2 echo "ERROR: opencode is not functional or configured." >&2
exit 1 exit 1
fi fi
@@ -239,7 +239,7 @@ graceful_stop() {
if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then
herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true
else else
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain" send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: delivery of '$exitkey' unconfirmed (rc=$?) — waiting for the session to exit"
fi fi
_wait_session_gone "$SESSION_NAME" 5 || true _wait_session_gone "$SESSION_NAME" 5 || true
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
+40
View File
@@ -0,0 +1,40 @@
# captured 2026-10-18 from an idle opencode 2.0.32 home screen (no message sent), input box empty: herdr 0.9.3 `herdr agent read <session> --source visible`, 140x40 (cwd line masked)
▄
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
┃
┃ Ask anything… "Fix a TODO in the codebase"
┃
┃ Build auto · Nemotron 3.5 Super VL Free OpenCode
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
/…/<repo>/<worktree>:<branch> shift+tab agents ctrl+p commands
2.0.32
+23
View File
@@ -0,0 +1,23 @@
# captured 2026-10-18 from an idle opencode 2.0.32 home screen (no message sent), input box empty, width 60: tmux 60x30 `capture-pane -p`; the example is cut without its closing quote (cwd line masked)
▄
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
┃
┃ Ask anything… "What is the tech stack of this proje
┃
┃ Build · Nemotron 3.5 Super VL Free OpenCode
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
/…/<worktree>:<branch> shift+tab agents ctrl+p commands
+11 -3
View File
@@ -311,16 +311,24 @@ def test_adapter_auth_ok(tmp_path, monkeypatch):
# Hermes always True # Hermes always True
assert get_adapter('hermes').auth_ok() is True assert get_adapter('hermes').auth_ok() is True
# OpenCode auth check (provider env var or auth.json file) # OpenCode auth check: the binary alone (2.x needs no login, Issue #60);
# keys and auth.json do not count
opencode = get_adapter('opencode') opencode = get_adapter('opencode')
path = os.environ["PATH"]
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
assert opencode.auth_ok() is False assert opencode.auth_ok() is False
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
assert opencode.auth_ok() is True
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json" opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json"
opencode_auth.parent.mkdir(parents=True, exist_ok=True) opencode_auth.parent.mkdir(parents=True, exist_ok=True)
opencode_auth.write_text("{}") opencode_auth.write_text("{}")
assert opencode.auth_ok() is False
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
opencode_bin = tmp_path / ".opencode" / "bin" / "opencode"
opencode_bin.parent.mkdir(parents=True)
opencode_bin.write_text("#!/bin/sh\n")
opencode_bin.chmod(0o755)
assert opencode.auth_ok() is True assert opencode.auth_ok() is True
monkeypatch.setenv("PATH", path)
# Codex auth check (env var, auth.json file, or CLI) # Codex auth check (env var, auth.json file, or CLI)
codex = get_adapter('codex') codex = get_adapter('codex')
+4 -2
View File
@@ -165,9 +165,11 @@ def test_u1e_a_dry_run_record_is_not_an_open_job(tmp_path):
admission must not wait for it.""" admission must not wait for it."""
reg = tmp_path / "jobs" reg = tmp_path / "jobs"
# WORKSPACE_ROOT: the wrapper sources lib.sh, which otherwise writes the # WORKSPACE_ROOT: the wrapper sources lib.sh, which otherwise writes the
# checkout's own .mam/shim/herdr (Issue #17 follow-up; W1 runs this node) # checkout's own .mam/shim/herdr (Issue #17 follow-up; W1 runs this node).
# DELEGATE_JOB_PYTHON: from tmp_path the wrapper finds no .venv and would
# take PATH's python3, which may lack paho (Issue #60, Linux).
env = dict(os.environ, HOME=str(tmp_path), MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"), env = dict(os.environ, HOME=str(tmp_path), MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"),
WORKSPACE_ROOT=str(tmp_path)) WORKSPACE_ROOT=str(tmp_path), DELEGATE_JOB_PYTHON=sys.executable)
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:s", res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:s",
"--dry-run", "--registry-dir", str(reg), "--prompt", "preview only"], "--dry-run", "--registry-dir", str(reg), "--prompt", "preview only"],
capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=120) capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=120)
+355
View File
@@ -0,0 +1,355 @@
"""Issue #60, Linux check: the defects the opencode 2.x check on Linux found.
1. The 2.x home screen's placeholder (`Ask anything… "<example>"`, cut
without its closing quote in a narrow box) is not typed input.
5. The adapter's queries neither create nor remove anything beside
opencode's database (a read-write open checkpoints the WAL and deletes
the sidecars on close; a plain mode=ro open creates missing ones).
6. `debug paths` (which writes a line to opencode's log) runs once per
(binary, HOME, XDG_DATA_HOME) in a process.
2. A graceful stop whose `/exit` worked says so: the agent exits instead of
turning `working`, so the delivery is unconfirmed, and no kill step ran.
3. opencode's auth check is the binary alone (2.x needs no login), and create
takes the adapter's verdict instead of running `opencode --version`.
"""
from __future__ import annotations
import os
import re
import json
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
PANES = REPO_ROOT / "tests" / "fixtures" / "panes"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
from lib_py import stall_watch # noqa: E402
from lib_py.agents.base import DiscoveryContext # noqa: E402
from lib_py.agents.input_region import extract_input_region # noqa: E402
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.adapters import opencode as opencode_mod # noqa: E402
import _fake_opencode # noqa: E402
# 140 columns (herdr capture) and 60 columns, where the example loses its
# closing quote.
HOME_SCREENS = ("opencode_idle_2x_home.txt", "opencode_idle_2x_home_narrow.txt")
PLACEHOLDER_LINE = re.compile(r'^(\s*┃\s*)Ask anything… ".*$', re.M)
@pytest.fixture(autouse=True)
def _no_cached_db_paths(monkeypatch):
monkeypatch.setattr(opencode_mod, "_DB_PATHS", {}, raising=False)
def _pane(name: str) -> str:
return "".join(l for l in (PANES / name).read_text(encoding="utf-8").splitlines(True) if not l.startswith("# "))
# ---------------------------------------------------------------------------
# 1. the 2.x home screen's placeholder
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", HOME_SCREENS)
def test_the_2x_home_screen_reads_as_an_empty_box(name):
assert extract_input_region(_pane(name), "opencode") == ""
def test_the_narrow_capture_has_a_cut_example():
# The case the pattern must keep: no closing quote on screen.
(line,) = [l for l in _pane(HOME_SCREENS[1]).splitlines() if "Ask anything" in l]
assert '"' in line and not line.rstrip().endswith('"')
def test_the_narrow_screen_stops_at_its_status_line_and_reads_ready():
text = _pane(HOME_SCREENS[1])
# Under ~60 columns the status line drops `auto`: `Build · <model>`.
assert re.search(r"^\s*┃\s*Build · ", text, re.M) and "Build auto" not in text
assert re.search(get_adapter("opencode").ready_tokens, text)
@pytest.mark.parametrize("name", HOME_SCREENS)
@pytest.mark.parametrize("typed", [
"please continue",
'Ask anything… "x" and fix it',
"Ask anything about the build",
'Ask anything… "a"b',
])
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)
assert n == 1
assert extract_input_region(text, "opencode") == typed
def test_the_1x_capture_still_reads_empty():
assert extract_input_region(_pane("opencode_idle.txt"), "opencode") == ""
@pytest.mark.parametrize("name", HOME_SCREENS)
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
src = stall_watch.HerdrSource("t", "opencode")
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
assert src.input_empty() is True
def test_other_adapters_have_no_placeholder_pattern():
for agent in ("claude", "agy", "grok", "codex", "hermes"):
assert get_adapter(agent).input_placeholder_pattern is None, agent
# ---------------------------------------------------------------------------
# 5. the queries leave the files beside the database alone
# ---------------------------------------------------------------------------
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
QUERIES = {
"verify_artifact": (lambda a, ctx, db: a.verify_artifact("ses_main", ctx), True),
"discover": (lambda a, ctx, db: a.discover(ctx), ["ses_main"]),
"_has_session": (lambda a, ctx, db: a._has_session(db, "ses_main"), True),
"_descendants": (lambda a, ctx, db: a._descendants(db, "ses_main"), ["ses_child"]),
"residual_artifacts": (lambda a, ctx, db: any("session_v2.id: 1 rows" in r
for r in a.residual_artifacts("ses_main", ctx)), True),
}
def _wal_writer(db: Path, ws: Path) -> sqlite3.Connection:
"""An open WAL connection holding the schema and two sessions: until it
closes, the rows live in the -wal only."""
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=wal")
for stmt in V2_DDL:
conn.execute(stmt)
conn.execute("INSERT INTO session_v2 VALUES ('ses_main', NULL, ?, 2000000)", (str(ws),))
conn.execute("INSERT INTO session_v2 VALUES ('ses_child', 'ses_main', ?, 3000000)", (str(ws),))
conn.execute("INSERT INTO session_message VALUES ('msg-1', 'ses_main')")
conn.commit()
return conn
@pytest.fixture
def wal_home(tmp_path, monkeypatch):
"""(adapter, ctx, db, ws, make(state)) - make builds the database in one
of four states: `clean` (closed: no sidecars), `leftover` (both sidecars
left behind by a writer that is gone, the rows only in the -wal),
`walonly` (the same without its -shm: the committed rows still count),
`writer` (a live writer whose rows are only in the -wal)."""
home, ws = tmp_path / "home", tmp_path / "ws"
ws.mkdir()
db = home / ".local" / "share" / "opencode" / "opencode.db"
db.parent.mkdir(parents=True)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: None) # the default path, no subprocess
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
opened = []
def make(state):
if state == "clean":
_wal_writer(db, ws).close()
elif state in ("leftover", "walonly"):
src = tmp_path / "src" / "opencode.db"
src.parent.mkdir()
conn = _wal_writer(src, ws)
for suffix in ("", "-wal", "-shm")[:3 if state == "leftover" else 2]:
shutil.copyfile(str(src) + suffix, str(db) + suffix)
conn.close()
else:
opened.append(_wal_writer(db, ws))
return sorted(os.listdir(db.parent))
yield adapter, ctx, str(db), ws, make
for conn in opened:
conn.close()
@pytest.mark.parametrize("query", sorted(QUERIES))
@pytest.mark.parametrize("state,listing", [
("clean", ["opencode.db"]),
("leftover", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
("walonly", ["opencode.db", "opencode.db-wal"]),
("writer", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
])
def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, listing, query):
adapter, ctx, db, ws, make = wal_home
assert make(state) == listing
run, expected = QUERIES[query]
assert run(adapter, ctx, db) == expected
assert sorted(os.listdir(os.path.dirname(db))) == listing
# ---------------------------------------------------------------------------
# 6. `debug paths` once per (binary, HOME, XDG_DATA_HOME)
# ---------------------------------------------------------------------------
def test_debug_paths_runs_once_per_home(tmp_path, monkeypatch):
log = tmp_path / "calls.log"
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode-real")
wrapper = tmp_path / "fakebin" / "opencode"
wrapper.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{fake}" "$@"\n')
wrapper.chmod(0o755)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(wrapper))
homes = [tmp_path / "home1", tmp_path / "home2"]
ctxs = [DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(h)) for h in homes]
def calls():
return log.read_text().splitlines() if log.exists() else []
want = [str(h / ".local" / "share" / "opencode" / "opencode.db") for h in homes]
assert [adapter._resolve_db(ctxs[0]) for _ in range(2)] == [want[0]] * 2
assert calls() == ["debug paths"]
assert adapter._resolve_db(ctxs[1]) == want[1]
assert adapter._resolve_db(ctxs[0]) == want[0]
assert calls() == ["debug paths"] * 2
def test_a_failed_debug_paths_is_not_remembered(tmp_path, monkeypatch):
log = tmp_path / "calls.log"
failing = tmp_path / "fakebin" / "opencode"
failing.parent.mkdir()
failing.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexit 3\n')
failing.chmod(0o755)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(tmp_path / "home"))
default = str(tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db")
assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2
assert log.read_text().splitlines() == ["debug paths"] * 2
# ---------------------------------------------------------------------------
# 2. the stop's words match what happened
# ---------------------------------------------------------------------------
SESSION = "i60-stop-opencode"
# A herdr in front of the conftest mock that, like a real opencode 2.x pane
# (Linux check 3-3), ends the agent on `/exit`: the prompt's wait for
# `working` then fails because the agent is gone.
EXITING_HERDR = """#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
if "prompt" in args and "/exit" in args:
with open(@STATE@) as f:
state = json.load(f)
state["agents"].pop(@NAME@, None)
state.setdefault("calls", []).append("herdr " + " ".join(args))
with open(@STATE@, "w") as f:
json.dump(state, f)
sys.stderr.write(json.dumps({"error": {"code": "agent_exited", "message": "agent exited"}}) + "\\n")
sys.exit(1)
os.execv(@MOCK@, [@MOCK@] + args)
"""
@pytest.mark.parametrize("exits", [True, False])
def test_the_graceful_stop_reports_what_happened(mam_sandbox, mock_herdr, mock_agents, monkeypatch, exits):
state = json.loads(Path(mock_herdr).read_text())
state["agents"][SESSION] = {"status": "running", "agent": "opencode", "agent_status": "idle",
"cwd": str(mam_sandbox), "pid": 12345, "pane_id": "w1:p1",
"command": "opencode", "buffer": "Ask anything"}
Path(mock_herdr).write_text(json.dumps(state, indent=2))
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
row = ("d['herdr_sessions'] = [{'name': '%s', 'agent': 'opencode', 'status': 'running', "
"'pane': {'cwd': '%s', 'pid': 12345, 'cmd': 'opencode'}}]" % (SESSION, mam_sandbox))
res = subprocess.run(["bash", "-c", f"source {lib} && atomic_dump_yaml {mam_sandbox / '.mam' / 'agent-sessions.yaml'}"],
input=row, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
if exits:
wrap = mam_sandbox / "exitbin" / "herdr"
wrap.parent.mkdir()
wrap.write_text(EXITING_HERDR.replace("@STATE@", repr(str(mock_herdr))).replace("@NAME@", repr(SESSION))
.replace("@MOCK@", repr(shutil.which("herdr"))))
wrap.chmod(0o755)
monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}")
stop = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(stop), "--session", SESSION, "--agent", "opencode"],
capture_output=True, text=True, timeout=180)
out = res.stdout + res.stderr
assert res.returncode == 0, out
calls = json.loads(Path(mock_herdr).read_text()).get("calls", [])
assert any("prompt" in c and "/exit" in c for c in calls), calls
assert "falling back to kill chain" not in out
if exits:
assert "unconfirmed" in out and "graceful: exited cleanly" in out, out
assert "kill-session" not in out and not any("kill-session" in c for c in calls), out
else:
# The kill step runs, so it is named.
assert "still alive → kill-session" in out, out
# ---------------------------------------------------------------------------
# 3. opencode's auth check is the binary
# ---------------------------------------------------------------------------
def _executable(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\n")
path.chmod(0o755)
return path
@pytest.fixture
def bare_home(tmp_path, monkeypatch):
"""An empty HOME_DIR and a PATH with no opencode."""
monkeypatch.setenv("HOME_DIR", str(tmp_path / "home"))
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"):
monkeypatch.delenv(var, raising=False)
return tmp_path
def test_keys_and_auth_json_without_the_binary_are_not_enough(bare_home, monkeypatch):
opencode = get_adapter("opencode")
assert opencode.auth_ok() is False
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
assert opencode.auth_ok() is False
auth = bare_home / "home" / ".local" / "share" / "opencode" / "auth.json"
auth.parent.mkdir(parents=True)
auth.write_text("{}")
assert 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":
_executable(bare_home / "bin" / "opencode")
monkeypatch.setenv("PATH", str(bare_home / "bin"))
else:
_executable(bare_home / "home" / ".opencode" / "bin" / "opencode")
assert get_adapter("opencode").auth_ok() is True
def test_the_auth_ok_command_exits_with_the_verdict(bare_home):
def rc(*args):
env = dict(os.environ, PYTHONPATH=str(SKILLS))
return subprocess.run([sys.executable, "-m", "lib_py.agents", "auth-ok", *args],
capture_output=True, text=True, env=env).returncode
assert rc("opencode") == 1
_executable(bare_home / "home" / ".opencode" / "bin" / "opencode")
assert rc("opencode") == 0
assert rc("no-such-agent") == 1
def test_create_decides_opencode_auth_without_running_it(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
# Every opencode call create makes is logged; --version was the old check.
log = mam_sandbox / "opencode-calls.log"
wrap = mam_sandbox / "logbin" / "opencode"
wrap.parent.mkdir()
wrap.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{shutil.which("opencode")}" "$@"\n')
wrap.chmod(0o755)
monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}")
for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"):
monkeypatch.delenv(var, raising=False)
create = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run(["bash", str(create), "--workspace", str(mam_sandbox), "--agent", "opencode",
"--role", "creator", "--no-onboard"],
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
assert res.returncode == 0, res.stdout + res.stderr
calls = log.read_text().splitlines() if log.exists() else []
assert "--version" not in calls, calls