Files
multi-agent-mux/.agents/skills/lib_py/agents/adapters/codex.py
T
GodopuandClaude Opus 5.5 622c1f8a9d feat(issue-30): a stall watcher that only notifies
lib_py/stall_watch.py runs beside the wrapper's subscriber for a local herdr
job (started after delivery, stopped when the subscriber ends; MAM_STALL_WATCH=0
turns it off). It follows `herdr agent wait` transitions and publishes only
`progress` events through one publisher; it has no path that closes a job.

- blocked: `stalled:blocked <detection line>` once per episode, never answered.
- idle for MAM_STALL_IDLE_S (600) with no background descendants of the agent
  pid (pane process-info + /proc; adapter resident_children and the heartbeat
  timer excluded) and an empty input box: `stalled:idle` and one nudge.
- input box judged from adapter facts measured on herdr 0.9.1 (codex '›',
  grok frame '│', opencode '┃' block + stop pattern); the whole-pane fallback
  is gone. No facts: withheld + `stall_watch:input_check_unavailable` once;
  never empty for 3 x S: `stall_watch:input_never_empty` once.
- herdr failures are not "no event": warning + `stall_watch:herdr_unavailable:
  <code>` (or agent_not_found), backoff, rc 2 after MAM_STALL_HERDR_FAIL_S
  (120); the wrapper prints the watcher's last lines.
- remote and human jobs are skipped with one log line.
- agent_status_reliable is an adapter field; false turns idle judgement off.
- the loop runner relays stalled:/stall_watch: notices once each.
- scoped_env drops a pane-inherited workspace scope for name lookups (#34 rule).

The module sits at the top of lib_py: delegation and loop may not import the
agent adapters (their sibling-import rules). Idle pane captures of the five
CLIs are fixtures; the mock herdr answers process-info in the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:21 +00:00

234 lines
9.3 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, json, glob, sqlite3, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'codex'
@property
def own_key(self) -> str:
return 'codex_session_id_own'
@property
def ready_tokens(self) -> str:
return 'Codex|Ask Codex to do anything'
@property
def exit_key(self) -> str:
return 'Ctrl+C'
@property
def delegate_agent_key(self) -> str:
return 'codex-cli'
@property
def startup_dialogs(self) -> list:
# #24: the trust prompt lists "1. Yes, continue" preselected, so Enter
# accepts. Trust here also loads this project's local config, hooks and
# exec policy, which is why --no-auto-trust exists.
#
# The 0.155.1 update notice (observed 2026-09-22) blocks the session the
# same way; "2" is Skip, the choice that changes nothing.
return [
('Do you trust the contents of this directory|1\\. Yes, continue',
['Enter'], 'trust'),
('1\\. Update now|Skip until next version', ['2'], 'notice'),
]
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
@property
def input_prompt(self) -> Optional[str]:
# herdr 0.9.1 capture: one `› Ask Codex to do anything` line, no rules.
return '›'
@property
def input_placeholder(self) -> Optional[str]:
return 'Ask Codex to do anything'
@property
def resident_children(self) -> tuple:
# Kept alive while idle (measured 2026-09-25).
return ('codex-code-mode-host',)
def _codex_home(self, ctx: DiscoveryContext) -> str:
return os.environ.get('CODEX_HOME') or f"{ctx.home_dir}/.codex"
def _state_db(self, ctx: DiscoveryContext) -> Optional[str]:
matches = glob.glob(f"{self._codex_home(ctx)}/state_*.sqlite")
if not matches:
return None
return max(matches, key=os.path.getmtime)
def _find_rollout_via_db(self, uuid: str, ctx: DiscoveryContext):
"""Returns (rollout_path, cwd) from the state DB, or (None, None)."""
db = self._state_db(ctx)
if not db or not os.path.exists(db):
return None, None
try:
conn = sqlite3.connect(db)
row = conn.execute("SELECT rollout_path, cwd FROM threads WHERE id=?", (uuid,)).fetchone()
conn.close()
if row and row[0] and os.path.exists(row[0]):
return row[0], row[1]
except Exception:
pass
return None, None
def _find_rollout(self, uuid: str, ctx: DiscoveryContext) -> Optional[str]:
path, _ = self._find_rollout_via_db(uuid, ctx)
if path:
return path
matches = glob.glob(f"{self._codex_home(ctx)}/sessions/*/*/*/rollout-*-{uuid}.jsonl")
return matches[0] if matches else None
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return self._find_rollout(uuid, ctx) or f"{self._codex_home(ctx)}/sessions/rollout-{uuid}.jsonl"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
db_path, db_cwd = self._find_rollout_via_db(uuid, ctx)
path = db_path or self._find_rollout(uuid, ctx)
if not path or not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
# Fail closed on invalid metadata or mismatched id / cwd (R4)
try:
with open(path) as f:
first = f.readline().strip()
meta = json.loads(first)
if not isinstance(meta, dict):
return False
payload = meta.get('payload')
if not isinstance(payload, dict):
return False
file_id = payload.get('id')
file_cwd = payload.get('cwd')
if not file_id or file_id != uuid:
return False
if not file_cwd:
return False
if db_cwd and workspace_key(db_cwd) != workspace_key(file_cwd):
return False
target_cwd = ctx.cwd or ctx.workspace
if not target_cwd:
return False
if workspace_key(file_cwd) != workspace_key(target_cwd):
return False
return True
except Exception:
return False
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
purged = []
path = self._find_rollout(uuid, ctx)
try:
res = subprocess.run(['codex', 'delete', uuid, '--force'],
capture_output=True, text=True, timeout=5)
if res.returncode == 0:
if path:
purged.append(path)
return purged
except Exception:
pass
if path and os.path.exists(path):
os.remove(path)
purged.append(path)
db = self._state_db(ctx)
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
conn.execute("DELETE FROM threads WHERE id=?", (uuid,))
conn.commit()
conn.close()
purged.append(f"state db row for thread: {uuid}")
except Exception:
pass
return purged
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
home = os.environ.get('HOME_DIR') or os.environ.get('HOME') or os.path.expanduser('~')
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
return os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))
def discover(self, ctx: DiscoveryContext) -> List[str]:
target_ws = ctx.workspace or ctx.cwd
target_key = workspace_key(target_ws) if target_ws else ""
db = self._state_db(ctx)
candidates = []
seen = set()
if db and os.path.exists(db):
try:
conn = sqlite3.connect(db)
cur = conn.cursor()
cur.execute("PRAGMA table_info(threads)")
cols = {r[1] for r in cur.fetchall()}
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
order_clause = f"ORDER BY {order_col} DESC" if order_col else ""
# Query database with workspace scope before bounding (R3)
if "cwd" in cols and target_ws:
direct_cwds = list({target_ws, os.path.realpath(target_ws)})
placeholders = ",".join("?" * len(direct_cwds))
cur.execute(f"SELECT id, cwd FROM threads WHERE cwd IN ({placeholders}) {order_clause} LIMIT 50", direct_cwds)
for uuid, cwd in cur.fetchall():
if uuid and uuid not in seen and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
# Page through batches if needed to catch symlink aliases / canonical paths
if len(candidates) < 50:
cur.execute(f"SELECT id, cwd FROM threads {order_clause}")
while True:
batch = cur.fetchmany(100)
if not batch:
break
for uuid, cwd in batch:
if uuid in seen:
continue
if target_key and cwd and workspace_key(cwd) != target_key:
continue
if uuid and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
if len(candidates) >= 50:
break
if len(candidates) >= 50:
break
conn.close()
except Exception:
pass
# R2: If database yielded usable candidates, return them; otherwise fall through to transcript discovery
if candidates:
return candidates
root = self._codex_home(ctx)
paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)
for path in paths:
base = os.path.basename(path)
if base.endswith(".jsonl") and len(base) >= 42:
uuid = base[:-6][-36:]
if uuid and uuid not in seen and self.verify_artifact(uuid, ctx):
candidates.append(uuid)
seen.add(uuid)
if len(candidates) >= 50:
break
return candidates