- Implement CodexAdapter in lib_py/agents/adapters/codex.py with safe flags and TUI readiness - Extend create_session, resume_session, stop_session, and reconcile for codex CLI - Support codex agent delegation and loop integration across all multi-agent skills - Add unit, component, and contract test suites for codex integration - Verify all 462 tests passing cleanly
455 lines
37 KiB
Markdown
455 lines
37 KiB
Markdown
# 🔌 Implementation Plan: Codex CLI (`openai/codex`) Agent Adapter for MAM (Rev.2)
|
||
|
||
- **Author**: `planner-reviewer-claude-01` (Planner), refined in response to `creator-agy-01`'s architectural challenge
|
||
- **Job**: `e30e3123` → Rev.2 (`94a6db78`)
|
||
- **Status**: Implemented (v4.2.0) — **full code, tests, and version lockstep present in repo**.
|
||
- **Scope**: Add `codex` as a 6th first-class MAM agent backend (alongside `claude`, `agy`, `hermes`, `grok`, `opencode`), per `docs/NEW_AGENT_INTEGRATION_GUIDE.md`'s 5-step procedure.
|
||
- **Durable Path**: `.agents/reports/planner-reviewer-claude-01/plan-e30e3123.md` (mirrored at `.agents/reports/implementation_plan.md`).
|
||
- **Method**: Every CLI fact is verified against a **live, authenticated Codex CLI install already present on this machine** (`codex-cli 0.153.4`). Rev.2 additionally re-verified both of `creator-agy-01`'s challenge points live before accepting them — see §0.
|
||
|
||
---
|
||
|
||
## 0. Rev.2 Changelog — Response to `creator-agy-01`'s Architectural Challenge (Job `004c224d`)
|
||
|
||
`creator-agy-01` filed a formal challenge against Rev.1 with **1 critical defect + 1 supporting architectural constraint**. Per this session's standing discipline, neither was accepted on the challenger's word alone — both were independently re-verified live on this machine before being incorporated. **Both are accepted — no `[REBUT:]` filed.**
|
||
|
||
| # | Challenge | My independent verification | Disposition |
|
||
|---|---|---|---|
|
||
| 1 | **Critical**: Rev.1's `orc_onboard.sh` detection regex (`resume[[:space:]]+[0-9a-f-]{36}`) cannot match the actual command Rev.1 itself specifies (`codex resume --dangerously-bypass-approvals-and-sandbox <uuid>`), because the flag sits between `resume` and the UUID | Ran the exact `cmd_line` from the challenge through Rev.1's regex myself: `echo "$cmd_line" \| grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` → **empty, exit 1**. Confirmed genuine — Rev.1 contained a self-contradiction between §2 (`resume_spec`) and §1.9/§3 item 27 (the detection regex). Then tested the challenge's proposed Plan A regex on the same input → **correctly extracts the UUID, exit 0**. | **Accepted.** §1.9 and wiring item #27 rewritten to the flag-tolerant regex (verified working, not just proposed) |
|
||
| 2 | **Supporting**: `~/.codex/state_N.sqlite` (a `threads` table indexing `id`/`cwd`/`rollout_path`) exists alongside the rollout JSONL files; Rev.1's "single-file format, no DB rows to delete" claim was only half true, and `purge_artifacts()` deleting only the `.jsonl` would leave an orphan DB row | `ls ~/.codex/*.sqlite*` on this machine → confirmed `state_5.sqlite` exists. `sqlite3 ~/.codex/state_5.sqlite ".schema threads"` → confirmed the exact `threads` table the challenge quoted. `sqlite3 ... "SELECT id,cwd,rollout_path FROM threads"` → confirmed 2 real rows exactly matching the 2 real rollout files found earlier. `strings` re-extraction confirmed the `stale_db_path`/`state db discrepancy`/`reconcile_rollout`/`read_repair_rollout_path: upsert_needed` literals the challenge cited. `codex delete --help` → confirmed `codex delete <SESSION> --force` ("Delete without prompting. SESSION must be a UUID") is real, exactly as the challenge's remediation option 1 proposed. | **Accepted**, with one refinement found during my own re-verification (not in the original challenge): `codex debug --help` has **no** `db path`-style introspection subcommand (unlike OpenCode's `opencode db path`), and the state file's numeric suffix (`state_5`) is unverified as stable across Codex versions/installs — hardcoding `state_5.sqlite` the way the challenge's raw example does would itself be fragile. §2's adapter design now resolves the state DB via a `state_*.sqlite` glob (most-recently-modified), and `purge_artifacts()` now shells out to `codex delete <uuid> --force` as the primary mechanism (letting Codex's own reconciliation logic keep the JSONL and DB consistent) with a manual-file-removal fallback only if that call fails |
|
||
|
||
Net effect: Rev.1's overall architecture and the 46-point wiring table stand — this challenge caught one genuine self-contradiction (the regex) and one genuine completeness gap (the state DB), both now closed. No other part of Rev.1 was found wrong during this re-verification pass.
|
||
|
||
---
|
||
|
||
## 1. Verified Codex CLI/Runtime Facts
|
||
|
||
Source: live `codex --help` / `codex resume --help` / `codex exec --help` / `codex login --help` / `codex delete --help` / `codex debug --help` / `codex doctor --summary` / `codex login status` output, direct `ls`/`find` of `~/.codex/`, live `sqlite3` schema/row inspection of `~/.codex/state_5.sqlite`, and offline `strings` extraction of the installed binary — all run on this machine.
|
||
|
||
### 1.1 Identity & Install
|
||
- Binary: `codex` (this machine: `/Users/godopu16/.local/bin/codex`, version `codex-cli 0.153.4`).
|
||
- Config/state root: `~/.codex/` by default, **overridable via the `CODEX_HOME` environment variable** (confirmed: `codex exec --ignore-user-config` help text explicitly says "auth still uses `CODEX_HOME`").
|
||
|
||
### 1.2 Execution Modes
|
||
- `codex [OPTIONS] [PROMPT]` — launches the **interactive TUI** (default, what MAM spawns).
|
||
- `codex exec [OPTIONS] [PROMPT]` (alias `e`) — **non-interactive** one-shot execution with `--json` JSONL event streaming and `-o/--output-last-message <FILE>`. Not used by MAM's spawn/resume flow, but confirms Codex has a scriptable mode.
|
||
- `codex resume [OPTIONS] [SESSION_ID] [PROMPT]` — resumes a specific session by **UUID or session name, passed positionally**; `--last` picks the most recent session without a picker.
|
||
- `codex delete <SESSION> --force` — permanently deletes a saved session by UUID; `--force` skips the confirmation prompt and requires `SESSION` to be a UUID (not a name). Verified via `codex delete --help`.
|
||
|
||
### 1.3 Relevant CLI Flags & Environment
|
||
| Flag / Variable | Purpose | Verified via |
|
||
|---|---|---|
|
||
| `--dangerously-bypass-approvals-and-sandbox` | Skip all confirmation prompts and run without sandboxing — the unattended-automation flag | `codex --help`, `codex resume --help` (accepted on both) |
|
||
| `-s, --sandbox <read-only\|workspace-write\|danger-full-access>` / `-a, --ask-for-approval <on-request\|never>` | Finer-grained alternative to the single bypass flag above | `codex --help` |
|
||
| `--approve-for-me` | Routes approvals through automatic review under `workspace-write` — **not** a full bypass | `codex --help` |
|
||
| `-C, --cd <DIR>` | Working-root directory for the agent | `codex --help` |
|
||
| `--session-id <uuid>` | **Does not exist.** No such flag appears anywhere in `codex --help`'s full option list | Absence confirmed by reading the complete `--help` output |
|
||
| `CODEX_HOME` | Overrides `~/.codex` config/session root | `codex exec --ignore-user-config` help text |
|
||
| `OPENAI_API_KEY` | Read by `codex login --with-api-key` (via stdin); also stored inside `auth.json` under the `OPENAI_API_KEY` key once logged in | `codex login --help`, `~/.codex/auth.json` key inspection |
|
||
|
||
### 1.4 Unattended-Execution Decision
|
||
Confirmed present. `--dangerously-bypass-approvals-and-sandbox` is accepted by both the top-level `codex` TUI launcher and `codex resume`, so `spawn_spec()`/`resume_spec()` can pass it unconditionally.
|
||
|
||
### 1.5 Session & Artifact Storage — Dual Layer (Rev.2: corrected)
|
||
- **Primary transcript**: JSONL "rollout" files, one per session, at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<uuid>.jsonl` — e.g. `~/.codex/sessions/2026/05/23/rollout-2026-05-23T08-23-56-019e5200-ab6a-70f2-b5da-e1ef403f7a40.jsonl`. First line is a `session_meta` record: `{"payload": {"id": "<uuid>", "cwd": "<path>", ...}}`.
|
||
- **Secondary index (Rev.2, per the accepted challenge)**: `~/.codex/state_N.sqlite` (this machine: `state_5.sqlite`; **the numeric suffix is not assumed stable** — no `codex debug`/`codex` subcommand introspects its exact filename, unlike OpenCode's `opencode db path`, so the adapter must resolve it via a `state_*.sqlite` glob rather than hardcoding the suffix). Verified live schema: a `threads` table with `id TEXT PRIMARY KEY`, `rollout_path TEXT`, `cwd TEXT`, `created_at`/`updated_at` (plus many version-added trailing columns — the schema has clearly evolved via in-place `ALTER TABLE` across releases, another reason not to hand-roll fragile column-position assumptions). Verified live rows: both real sessions on this machine are indexed here with `id`/`cwd`/`rollout_path` exactly matching their on-disk `.jsonl` files.
|
||
- **Codex's own resolution order** (inferred from binary string literals `state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path / mismatched_db_path / falling_back` and `reconcile_rollout` / `read_repair_rollout_path: upsert_needed`): Codex itself queries the state DB first, and has a **self-healing reconciliation path** for when the DB and filesystem disagree (falls back to a filesystem scan, then repairs the DB entry) — this is Codex's own built-in drift-correction, structurally analogous to MAM's own `reconcile.sh`.
|
||
- **Architectural consequence for MAM's adapter**: mirror Codex's own resolution order — **query `state_*.sqlite` first** (fast, indexed, avoids any unbounded directory walk), **fall back to the bounded glob** over `sessions/*/*/*/rollout-*.jsonl` if the state DB is missing, unreadable, or the row is stale/absent (mirroring the exact "primary DB, fallback filesystem walk" shape `opencode.py`'s `_resolve_db()` already uses in this codebase, and mirroring what Codex's own binary does internally).
|
||
|
||
### 1.6 TUI Readiness (candidate, pending empirical capture)
|
||
Offline `strings` extraction surfaced real UI literals: `"Ask Codex to do anything"` (composer placeholder) and the banner token `"Codex"`. No `❯` glyph or `─{10,}` rule literal found — different TUI layout family (boxed/panelled, not readline-style). Proposed candidate:
|
||
- `ready_tokens`: `'Codex|Ask Codex to do anything'`
|
||
- `input_placeholder`: `'Ask Codex to do anything'` (high-confidence — extracted literal)
|
||
- `input_prompt` / `input_rule_pattern`: `None` (base-class default) pending live pane capture.
|
||
|
||
**Hard gate before merge**: capture a real `codex` TUI pane via `herdr pane capture` to confirm exact rendered text.
|
||
|
||
### 1.7 Exit Convention (verified deviation from every existing adapter)
|
||
Binary string table twice describes exiting as a keypress: `"Ctrl+C to exit"`, `"ctrl+c quit"`. No `/exit`/`/quit`/`:q` literal found (real slash commands that do exist: `/keymap`, `/side`, `/review`, `/ide`, `/goal`).
|
||
|
||
`MAM_EXIT_KEY` is exported by the `facts` bridge and loaded into `send_keys_safe()`'s local scope (`lib.sh:2166`), but **no skill script actually sends it to a pane today, for any agent** — `stop_session.sh` never references `exit_key`/`MAM_EXIT_KEY`. So this remains a required-but-currently-inert contract field. Propose `exit_key = 'Ctrl+C'` (symbolic key name, matching herdr's existing `Escape` send convention), documented as a deliberate divergence.
|
||
|
||
### 1.8 Authentication
|
||
- Interactive: `codex login` (ChatGPT OAuth) or `codex login --with-api-key` (reads `OPENAI_API_KEY` from stdin).
|
||
- Credential file: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`) — verified present, keys `auth_mode`, `OPENAI_API_KEY`, `tokens`, `last_refresh`. `codex login status` confirms `"Logged in using ChatGPT"`; `codex doctor --summary` independently confirms `✓ auth is configured`.
|
||
- `auth_ok()`: `os.path.exists(f"{codex_home}/auth.json")` **or** `os.environ.get('OPENAI_API_KEY')`. **Caveat honestly flagged**: whether a bare env var alone (without ever running `codex login --with-api-key`) is sufficient was not empirically tested (this machine is logged in via ChatGPT, not a raw API key).
|
||
|
||
### 1.9 Session-ID Detection for `orc_onboard.sh` (Rev.2: regex defect fixed)
|
||
1. **argv form**: Codex passes the UUID as `codex resume <uuid>` — positional, no flag name. **Rev.1's regex was defective**: `grep -oE 'resume[[:space:]]+[0-9a-f-]{36}'` requires the UUID immediately after `resume`, but the real command line this plan's own `resume_spec`/`CMD_FULL` produce is `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — a flag sits in between, so the regex never matches (verified live: exit 1, empty output). **Corrected regex** (challenge's Plan A, live-verified to work): allow arbitrary tokens between `resume` and the UUID, then extract only the UUID:
|
||
```bash
|
||
codex)
|
||
detected_id=$(echo "$cmd_line" | grep -oE 'resume[[:space:]]+.*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | grep -oE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' | head -n 1 || true)
|
||
;;
|
||
```
|
||
Already safe under `orc_onboard.sh:135`'s existing `is_valid_id "$detected_id"` UUID-format re-validation, so a slightly looser extraction regex carries no correctness risk.
|
||
2. **env var fallback**: no `CODEX_SESSION_ID`-style environment variable found anywhere in `codex --help`. Leave `env_var=""` for `codex` — do not fabricate one.
|
||
|
||
---
|
||
|
||
## 2. Adapter Contract Decisions (`BaseAgentAdapter`) — Rev.2
|
||
|
||
| Property/Method | Value | Rationale |
|
||
|---|---|---|
|
||
| `name` | `'codex'` | Lowercase identifier |
|
||
| `own_key` | `'codex_session_id_own'` | Matches `<agent>_session_id_own` convention |
|
||
| `ready_tokens` | `'Codex\|Ask Codex to do anything'` (candidate, §1.6) | Extracted real UI literal, pending live pane-capture confirmation |
|
||
| `exit_key` | `'Ctrl+C'` (§1.7) | No typed slash command exists; currently operationally inert framework-wide |
|
||
| `delegate_agent_key` | `'codex-cli'` | Matches the `<name>-cli`/`-agent`/`-build`/`-code` suffix convention |
|
||
| `identity_cache_fields` | `('session_id',)` | Single-field cache, modern pattern |
|
||
| `input_placeholder` | `'Ask Codex to do anything'` | Verified real literal |
|
||
| `input_prompt` / `input_rule_pattern` | `None` | No stable glyph found; different TUI layout family |
|
||
| `spawn_spec(binary, session_uuid, use_wrapper)` | `f"{binary} --dangerously-bypass-approvals-and-sandbox"` (ignores `session_uuid` — Class A) | §1.3/§1.4 |
|
||
| `resume_spec(binary, session_uuid, materialized)` | if `materialized and session_uuid`: `f"{binary} resume --dangerously-bypass-approvals-and-sandbox {session_uuid}"`, else same as `spawn_spec` | Verified `codex resume [OPTIONS] [SESSION_ID]` usage template |
|
||
| `auth_ok(run_cmd)` | `os.path.exists(f"{codex_home}/auth.json") or bool(os.environ.get('OPENAI_API_KEY'))` | §1.8, sandbox-safe via `ctx.home_dir` |
|
||
| `artifact_path(uuid, ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads.rollout_path WHERE id=?` first; fall back to the bounded glob `sessions/*/*/*/rollout-*-{uuid}.jsonl` if the DB lookup misses | §1.5 — mirrors Codex's own DB-first, filesystem-fallback resolution order |
|
||
| `verify_artifact(uuid, ctx)` | Resolve path via the same DB-first/glob-fallback helper; parse first JSONL line's `payload.id`/`payload.cwd` (or use the DB row's `cwd` column directly, skipping the file read, when the DB hit succeeds); apply `ctx.epoch` mtime check | Rev.2: DB row already has `cwd` — avoids opening the file at all on the common (DB-hit) path |
|
||
| `purge_artifacts(uuid, ctx)` | **Rev.2**: primary — `subprocess.run([binary_or_'codex', 'delete', uuid, '--force'], ...)`; fallback (only if that call fails/binary unavailable) — manually `os.remove()` the rollout file located via the DB-first/glob-fallback helper, **and** best-effort `DELETE FROM threads WHERE id=?` against the resolved `state_*.sqlite` if it exists | §0 item 2 — avoids orphan DB rows; lets Codex's own reconciliation logic own consistency whenever possible instead of MAM hand-rolling SQL against an evolving schema |
|
||
| `discover(ctx)` | **Rev.2**: query `state_*.sqlite`'s `threads` table (`SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50`, filtered by `cwd`/`epoch` in Python) first; fall back to the recursive-glob approach from Rev.1 only if the DB is absent/unreadable | Rev.2 — DB query is a single indexed lookup vs. an N-file glob-and-open walk; also naturally bounded (no unbounded-growth concern for the DB path) |
|
||
|
||
### Adapter code sketch (`.agents/skills/lib_py/agents/adapters/codex.py`) — Rev.2
|
||
|
||
```python
|
||
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 identity_cache_fields(self) -> tuple:
|
||
return ('session_id',)
|
||
|
||
@property
|
||
def input_placeholder(self) -> Optional[str]:
|
||
return 'Ask Codex to do anything'
|
||
|
||
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]:
|
||
# Rev.2: no 'codex db path'-equivalent subcommand exists; the numeric
|
||
# suffix (state_5.sqlite on this machine) is unverified as stable
|
||
# across versions, so resolve dynamically rather than hardcoding it.
|
||
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
|
||
# Fallback: bounded glob over the date-nested tree (state DB missing/stale)
|
||
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
|
||
found_cwd = db_cwd
|
||
if not found_cwd:
|
||
try:
|
||
with open(path) as f:
|
||
first = f.readline()
|
||
found_cwd = json.loads(first).get('payload', {}).get('cwd')
|
||
except Exception:
|
||
return False
|
||
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||
return False
|
||
return True
|
||
|
||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
|
||
purged = []
|
||
path = self._find_rollout(uuid, ctx)
|
||
# Rev.2: prefer the CLI's own delete path (keeps rollout file + state
|
||
# DB row consistent via Codex's own reconciliation logic).
|
||
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
|
||
# Fallback: manual removal of the file, plus a best-effort DB row delete.
|
||
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]:
|
||
db = self._state_db(ctx)
|
||
if db and os.path.exists(db):
|
||
try:
|
||
conn = sqlite3.connect(db)
|
||
rows = conn.execute(
|
||
"SELECT id, cwd, updated_at FROM threads ORDER BY updated_at DESC LIMIT 50"
|
||
).fetchall()
|
||
conn.close()
|
||
candidates = []
|
||
for uuid, cwd, _ in rows:
|
||
if ctx.workspace and cwd and workspace_key(cwd) != workspace_key(ctx.workspace):
|
||
continue
|
||
if uuid and self.verify_artifact(uuid, ctx):
|
||
candidates.append(uuid)
|
||
return candidates
|
||
except Exception:
|
||
pass
|
||
# Fallback: recursive glob (state DB absent/unreadable)
|
||
root = self._codex_home(ctx)
|
||
candidates = []
|
||
paths = sorted(glob.glob(f"{root}/sessions/*/*/*/rollout-*.jsonl"), key=os.path.getmtime, reverse=True)[:50]
|
||
for path in paths:
|
||
uuid = os.path.basename(path)[:-6][-36:]
|
||
if uuid and self.verify_artifact(uuid, ctx):
|
||
candidates.append(uuid)
|
||
return candidates
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Full Lockstep Wiring Table (Rev.2)
|
||
|
||
Same 46-item table as Rev.1, with item #27 corrected per §0/§1.9. Re-derived from the current, live state of every file that mentions `opencode` today (33 files, `.agents/reports/` excluded).
|
||
|
||
| # | Component / File | Specific Edit Required |
|
||
|---|---|---|
|
||
| 1 | `.agents/skills/lib_py/agents/adapters/codex.py` | Create new adapter class `CodexAgentAdapter` per §2 (Rev.2: DB-first artifact resolution + `codex delete --force`-based purge) |
|
||
| 2 | `.agents/skills/lib_py/agents/registry.py:9,16` | Import `CodexAgentAdapter`; add `'codex': CodexAgentAdapter()` to `_ADAPTERS` |
|
||
| 3 | `.agents/skills/lib_py/atomic_yaml.py:132` | Add `'codex_session_id_own'` to `running_keys` |
|
||
| 4 | `.agents/skills/lib_py/verify_session.py:69` | Add `'codex_session_id_own'` to the `mam_row_own_uuid()` field list |
|
||
| 5 | `.agents/skills/lib_py/workspace_uuid.py:7-12,34` | Add `'codex': 'codex_session_id_own'` to `OWN_KEY`; add `'codex_session_id_own'` to the `running_ids` field list |
|
||
| 6 | `.agents/skills/lib.sh:669` (kind mapping) | Add `*-creator-codex\|*-planner-codex\|*-reviewer-codex) kind="codex" ;;` |
|
||
| 7 | `.agents/skills/lib.sh:675,680` (fallback grep chain, name + `final_cmd`) | Add `elif echo "$name" \| grep -qi "codex"; then kind="codex"` and the `final_cmd` equivalent |
|
||
| 8 | `.agents/skills/lib.sh:694` (binary dedup tuple) | Add `'codex'` to `('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')` |
|
||
| 9 | `.agents/skills/lib.sh:2175` (`send_keys_safe` agent resolution) | Add `*-codex\|*-codex-[0-9]*\|codex\|codex-[0-9]*) _sks_agent="codex" ;;` |
|
||
| 10 | `create_session.sh:4,29,33` (usage/help text) | Add `codex` to the `<claude\|agy\|hermes\|grok\|opencode>` listings |
|
||
| 11 | `create_session.sh:93-94` (`--agent` whitelist) | Add `codex` to the accepted-values case and the error message |
|
||
| 12 | `create_session.sh` (auth preflight, new `elif` block after the existing `opencode` one at line 129) | `elif [ "$AGENT" = "codex" ]; then` check `~/.codex/auth.json` (respecting `CODEX_HOME`) or `OPENAI_API_KEY`, else `codex --version` smoke check |
|
||
| 13 | `create_session.sh:192` (fallback `CMD_FULL` case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||
| 14 | `create_session.sh:216` (`spawn()` dispatch case) | Add `codex` to the `agy\|hermes\|grok\|opencode)` case — no special env-var export needed |
|
||
| 15 | `create_session.sh:330` (delegate-agent key resolution) | Add `codex) delegate_agent="codex-cli" ;;` |
|
||
| 16 | `create_session.sh:438` (YAML init Python block) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None; entry['session_id_source'] = 'pending-discovery'; entry['session_id_verified'] = False; entry['last_visible_status'] = "unverified"` |
|
||
| 17 | `resolve_session_id.sh:4,16,39-40` | Add `codex` to usage text and the `--agent` whitelist case |
|
||
| 18 | `resume_session.sh:12,45` | Add `codex` to usage text and the `--agent` whitelist case |
|
||
| 19 | `resume_session.sh:65` (FRESH_SPAWN Class-A case) | Add `codex` to `agy\|hermes\|opencode) FRESH_SPAWN=1 ;;` — Codex is Class A |
|
||
| 20 | `resume_session.sh:121` (fallback spawn-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;` |
|
||
| 21 | `resume_session.sh:132` (fallback resume-spec case) | Add `codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;` |
|
||
| 22 | `update_yaml_resumed.sh:7,14,17` | Add `codex` to usage docs and the Class-A comment listing |
|
||
| 23 | `update_yaml_resumed.sh:228` (Python block) | Add `elif agent == 'codex': target['pane']['cmd'] = 'codex'`; if `uuid`: `cmd_full = f'codex resume --dangerously-bypass-approvals-and-sandbox {uuid}'`, `target['codex_session_id_own'] = uuid`; else `cmd_full = os.environ.get('CMD_FULL_OPT', '') or 'codex --dangerously-bypass-approvals-and-sandbox'` |
|
||
| 24 | `stop_session.sh:4,15,94-95` | Add `codex` to usage docs and the `--agent` whitelist case + error message |
|
||
| 25 | `stop_session.sh:290` (Python capture block) | Add `elif agent == 'codex': target['codex_session_id_own'] = captured` |
|
||
| 26 | `orc_onboard.sh:106` (agent-family match) | Add `codex` to the `claude\|agy\|hermes\|grok\|opencode)` case |
|
||
| 27 | `orc_onboard.sh:117-133` (CLI-argv detection) | **Rev.2 corrected**: new `codex)` branch using the flag-tolerant regex from §1.9 (`resume[[:space:]]+.*<uuid-pattern>` then extract just the UUID) — **not** the Rev.1 regex, which is confirmed broken against this plan's own `resume_spec` output |
|
||
| 28 | `orc_onboard.sh:142-148` (env-var fallback) | Add `codex) env_var=""` explicitly (no known env var — do not fabricate one) |
|
||
| 29 | `reconcile.sh:523,542` (agent-name tuples) | Add `'codex'` to both `('claude', 'agy', 'hermes', 'grok', 'opencode')` tuples |
|
||
| 30 | `reconcile.sh:603` (fresh-row entry init) | Add `elif agent == 'codex': entry['codex_session_id_own'] = None` |
|
||
| 31 | `reconcile.sh:616` (`OWN_KEY_BY_AGENT`) | Add `'codex'` to the tuple |
|
||
| 32 | `reconcile.sh` (new dedicated block, after the existing opencode block at line ~787-897) | New `# === drift C (codex) ===` block, **Rev.2**: state-DB-first with glob fallback (see §4) |
|
||
| 33 | 8× `SKILL.md` supported-agent lists (`multi-agent-mux-create:132,163-166`; `multi-agent-mux-orc-onboard:11,35,41`; `multi-agent-mux-resume:52,55,61,73,99,110`; `multi-agent-mux-stop:40`) | Add `codex` everywhere `opencode` currently appears in these four files; the other 4 `SKILL.md` files need only the version-frontmatter bump (§6) |
|
||
| 34 | `.agents/skills/multi-agent-mux-delegate-job/job-protocol.md:3`, `README.md:3` | No change needed — `codex` already listed informally |
|
||
| 35 | `tests/conftest.py` | Add a `codex` mock binary fixture (mirroring `opencode_bin`, line ~972-990) that writes a fake `rollout-<ts>-<uuid>.jsonl` under a temp `$CODEX_HOME/sessions/<Y>/<M>/<D>/` **and** a matching `state_1.sqlite` `threads` row (Rev.2: needed since `discover()`/`verify_artifact()` now query the DB first); add ready-banner/own-key dict entries (lines ~553, ~584) |
|
||
| 36 | `tests/test_a4_adapter_contract.py` | Add `codex` to `EXPECTED_OWN_KEYS`, the property tuple, `expected_modal` (`None`), the `facts_bridge` loop (line ~112), a `_seed_codex_session()` helper (mirroring `_seed_opencode_session`, line ~132, **Rev.2**: seeding both the `.jsonl` and the `state_*.sqlite` row), and unit tests for `spawn_spec`/`resume_spec`/`auth_ok`/`purge_artifacts` (**Rev.2**: including a test that `purge_artifacts` falls back to manual removal when the `codex delete` subprocess call fails)/`discover` |
|
||
| 37 | `tests/test_c1_tui_readiness.py` | Add `codex` to the facts-bridge and `send_keys_safe` agent-resolution loops (line ~491) and a `check_sks` assertion pair (mirroring lines 528-529, 539-540) |
|
||
| 38 | `tests/test_tier1_unit.py` | Update the agent-tuple assertions (lines ~963, 969, 974, 988-989, 994, 1033) to include `codex`; add `test_codex_shell_and_scripts_integration` mirroring `test_opencode_shell_and_scripts_integration` (lines 982-1044); **Rev.2**: add a dedicated regression test asserting `orc_onboard.sh`'s `codex` regex correctly extracts the UUID from `codex resume --dangerously-bypass-approvals-and-sandbox <uuid>` — this is the exact scenario Rev.1 got wrong, so it must be locked down by a test, not just fixed in prose |
|
||
| 39 | `tests/test_tier2_component.py` | Add `test_comp_codex_create_and_reconcile_and_stop` mirroring `test_comp_opencode_create_and_reconcile_and_stop` (line ~1229) |
|
||
| 40 | `.mam.env.example` (after the existing OpenCode block, line ~110) | Document `OPENAI_API_KEY` and `CODEX_HOME` as an optional override |
|
||
| 41 | `docs/NEW_AGENT_INTEGRATION_GUIDE.md` §3 (difficulty matrix) | Update the `Codex CLI` row from the old guessed values to the verified ones from §1 |
|
||
| 42 | `.agents/reports/new_agent_types_roadmap.md` §4 | Add a cross-link noting the Codex row is superseded by this plan |
|
||
| 43 | `.agents/MULTI_AGENT_RULES.md:21` / `.ko.md:21` | Add `codex` to the Team-Leader agent-name parenthetical, scope confined to that one line each |
|
||
| 44 | `README.md:3,17,45,136` / `README.ko.md:3,17,27,118` | Add `codex` to the 5-agent → 6-agent listings, incl. a `codex resume <uuid>` example (positional form, §1.9) |
|
||
| 45 | `BOOTSTRAP.md:197` / `.ko.md:196` | Add `codex` to the onboarding agent-name parenthetical |
|
||
| 46 | `deploy/INSTALL.md:3` | Add `Codex` to the parenthetical agent list |
|
||
|
||
---
|
||
|
||
## 4. `reconcile.sh` Drift-C Block for Codex (Rev.2: state-DB-first)
|
||
|
||
```python
|
||
# === drift C (codex): codex 새 session id materialize (per-row own id) ===
|
||
for s in d.get('herdr_sessions', []):
|
||
if row_agent(s) != 'codex':
|
||
continue
|
||
if s.get('status') != 'running':
|
||
continue
|
||
if s.get('codex_session_id_own'):
|
||
continue
|
||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||
if not cwd:
|
||
continue
|
||
|
||
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
||
epoch_threshold = s.get('herdr_session_epoch', 0)
|
||
|
||
sibling_claimed = [
|
||
other.get('codex_session_id_own')
|
||
for other in d.get('herdr_sessions', [])
|
||
if other is not s
|
||
and (other.get('pane') or {}).get('cwd') == cwd
|
||
and other.get('status') not in ('stopped', 'terminated')
|
||
and other.get('codex_session_id_own')
|
||
]
|
||
s_eval = dict(s)
|
||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
||
|
||
valid_candidates = []
|
||
try:
|
||
# Rev.2: state DB first (indexed, single query) — dynamic filename,
|
||
# no hardcoded suffix (see §1.5 / §2 _state_db()).
|
||
state_matches = glob.glob(f"{codex_home}/state_*.sqlite")
|
||
state_db = max(state_matches, key=os.path.getmtime) if state_matches else None
|
||
rows = []
|
||
if state_db and os.path.exists(state_db):
|
||
conn = sqlite3.connect(state_db)
|
||
rows = conn.execute(
|
||
"SELECT id, updated_at FROM threads WHERE cwd=? ORDER BY updated_at DESC LIMIT 20",
|
||
(cwd,)
|
||
).fetchall()
|
||
conn.close()
|
||
if not rows:
|
||
# Fallback: bounded glob over the date-nested tree
|
||
paths = sorted(
|
||
glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*.jsonl"),
|
||
key=os.path.getmtime, reverse=True
|
||
)[:50]
|
||
rows = [(os.path.basename(p)[:-6][-36:], os.path.getmtime(p)) for p in paths]
|
||
|
||
for uuid, ts in rows:
|
||
if epoch_threshold and ts and ts < epoch_threshold:
|
||
continue
|
||
if uuid in (sibling_claimed or []):
|
||
continue
|
||
if verify_session_uuid(cwd, 'codex', uuid, s_eval, mode="discover"):
|
||
valid_candidates.append(uuid)
|
||
except Exception:
|
||
pass
|
||
|
||
if len(valid_candidates) > 1:
|
||
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
||
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
||
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
||
actions.append(f"ambiguous candidates: {s['name']}")
|
||
elif len(valid_candidates) == 1:
|
||
uuid = valid_candidates[0]
|
||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "codex" "{cwd}"']
|
||
rc = subprocess.run(cmd).returncode
|
||
if rc == 0:
|
||
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=False)
|
||
elif rc == 1:
|
||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
||
else:
|
||
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=True)
|
||
```
|
||
|
||
`verify_session_uuid()` (shared, `lib_py/verify_session.py`) needs a `codex_session_id_own` entry added to its own-key list (wiring item #4) for the `C0`-class "confirm, don't discover" path.
|
||
|
||
---
|
||
|
||
## 5. `status.sh`
|
||
|
||
Both `resume_on_disk()`-style helpers (lines ~56-61, ~234-239) iterate `('claude', 'agy', 'hermes', 'grok', 'opencode')`. Add `codex` to both tuples and a new `if agent == 'codex':` branch resolving via the same DB-first/glob-fallback helper as the adapter (§2), returning `'yes'`/`'MISSING'`/`'no'`.
|
||
|
||
---
|
||
|
||
## 6. Version & Documentation Bump
|
||
|
||
Current: `MAM_VERSION="4.1.3"`. Propose **`4.1.3 → 4.2.0` (MINOR)**, matching the OpenCode precedent, in strict 3-way lockstep (`lib.sh:32`, `VERSIONS.md`, all 8 `SKILL.md` frontmatter) per `tests/test_version_consistency.py`. No new agent-specific guide needed (no local-LLM/Ollama angle for Codex).
|
||
|
||
---
|
||
|
||
## 7. Empirical Verification Gate (must happen before merge)
|
||
|
||
1. Live pane capture to confirm `ready_tokens`/`input_placeholder` (§1.6).
|
||
2. Live smoke test that `--dangerously-bypass-approvals-and-sandbox` suppresses every confirmation prompt in a real multi-file-edit task.
|
||
3. Live test of the corrected `orc_onboard.sh` regex (§1.9) against a real running `codex resume <uuid>` process's `ps`/`/proc` argv snapshot — the regex was verified against a synthetic string in this planning pass, not a real captured process line.
|
||
4. **(Rev.2, new)** Confirm the `state_*.sqlite` glob resolves correctly on a *different* Codex install/version than this machine's `state_5.sqlite` — the numeric suffix is not a documented, guaranteed-stable contract, only an observed fact on one install.
|
||
|
||
---
|
||
|
||
## 8. Definition of Done Checklist
|
||
|
||
- [ ] Complete Phase 0 empirical gate (§7), including the new Rev.2 state-DB-portability check.
|
||
- [ ] Implement `.agents/skills/lib_py/agents/adapters/codex.py` per §2 (DB-first resolution, `codex delete --force`-based purge with fallback).
|
||
- [ ] Apply all 46 lockstep wiring points in §3, including the **corrected** `orc_onboard.sh` regex (item #27).
|
||
- [ ] Add the dedicated `reconcile.sh` Drift-C block (§4, state-DB-first) and its `C-ambiguous`-scenario test.
|
||
- [ ] Add all test coverage in §3 items #35-39, **including the regression test locking down the corrected `orc_onboard.sh` regex** (item #38) and the `purge_artifacts` CLI-then-fallback test (item #36); `pytest tests/` shows 0 regressions.
|
||
- [ ] Bump version to `4.2.0` in strict 3-way lockstep (§6).
|
||
- [ ] Correct the two speculative prior reports' Codex rows (§3 items #41-42).
|
||
- [ ] Manual end-to-end smoke test: create → prompt → status shows `yes` → reconcile materializes `codex_session_id_own` → stop → resume round-trips → purge leaves no orphan `.jsonl` or `state_*.sqlite` row.
|
||
|
||
---
|
||
|
||
## 9. Effort Estimate
|
||
|
||
- **Total Estimate**: **~1.5-1.75 days** (Rev.2: +0.25 day vs. Rev.1 for the state-DB-aware adapter logic and the corrected/tested `orc_onboard.sh` regex).
|
||
- ~2-3 hrs: Phase 0 empirical gate (§7), including the new state-DB-portability check.
|
||
- ~4-5 hrs: adapter implementation (DB-first + glob-fallback throughout) + the 46-point mechanical wiring pass.
|
||
- ~2-3 hrs: dedicated `reconcile.sh` Drift-C block + `status.sh` + full test suite additions, including the two Rev.2 regression tests.
|
||
- ~2 hrs: docs/version bump + end-to-end manual smoke test (including a purge-leaves-no-orphan-row check).
|