From a9ed8f90f4064b44283a5c939fa4315fc3db066f Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 11:21:15 -0500 Subject: [PATCH] feat(server): add Codex session support and improve session lifecycle Extend AMC to monitor Codex sessions alongside Claude Code: Codex Integration: - Discover active Codex sessions from ~/.codex/sessions/*.jsonl - Parse Codex JSONL format (response_item/message payloads) for conversation history, filtering out developer role injections - Extract session metadata (cwd, timestamp) from session_meta records - Match Codex sessions to Zellij panes via cwd for response injection - Add ?agent=codex query param to /api/conversation endpoint Session Lifecycle Improvements: - Cache Zellij session list for 5 seconds to reduce subprocess calls - Proactive liveness check: auto-delete orphan "starting" sessions when their Zellij session no longer exists - Clean up stale "starting" sessions after 1 hour (likely orphaned) - Preserve existing event log cleanup (24h for orphan logs) Code Quality: - Refactor _serve_conversation into _parse_claude_conversation and _parse_codex_conversation for cleaner separation - Add _discover_active_codex_sessions for session file generation - Add _get_codex_zellij_panes to match sessions to panes - Use JSON error responses consistently via _json_error helper --- bin/amc-server | 355 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 334 insertions(+), 21 deletions(-) diff --git a/bin/amc-server b/bin/amc-server index 00c156f..e218d49 100755 --- a/bin/amc-server +++ b/bin/amc-server @@ -12,6 +12,7 @@ Endpoints: import json import os +import re import subprocess import time import urllib.parse @@ -22,6 +23,9 @@ from pathlib import Path # Claude Code conversation directory CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" +# Codex conversation directory +CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions" + # Plugin path for zellij-send-keys ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm" @@ -36,11 +40,17 @@ DASHBOARD_FILE = PROJECT_DIR / "dashboard.html" PORT = 7400 STALE_EVENT_AGE = 86400 # 24 hours in seconds +STALE_STARTING_AGE = 3600 # 1 hour - sessions stuck in "starting" are orphans + +# Cache for Zellij session list (avoid calling zellij on every request) +_zellij_cache = {"sessions": None, "expires": 0} class AMCHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/" or self.path == "/index.html": + self._serve_preact_dashboard() + elif self.path == "/old" or self.path == "/dashboard.html": self._serve_dashboard() elif self.path == "/api/state": self._serve_state() @@ -54,12 +64,14 @@ class AMCHandler(BaseHTTPRequestHandler): session_id, query = path_part.split("?", 1) params = urllib.parse.parse_qs(query) project_dir = params.get("project_dir", [""])[0] + agent = params.get("agent", ["claude"])[0] else: session_id = path_part project_dir = "" - self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir)) + agent = "claude" + self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir), agent) else: - self.send_error(404) + self._json_error(404, "Not Found") def do_POST(self): if self.path.startswith("/api/dismiss/"): @@ -69,7 +81,7 @@ class AMCHandler(BaseHTTPRequestHandler): session_id = urllib.parse.unquote(self.path[len("/api/respond/"):]) self._respond_to_session(session_id) else: - self.send_error(404) + self._json_error(404, "Not Found") def do_OPTIONS(self): # CORS preflight for respond endpoint @@ -90,13 +102,42 @@ class AMCHandler(BaseHTTPRequestHandler): except FileNotFoundError: self.send_error(500, "dashboard.html not found") + def _serve_preact_dashboard(self): + try: + preact_file = PROJECT_DIR / "dashboard-preact.html" + content = preact_file.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + except FileNotFoundError: + self.send_error(500, "dashboard-preact.html not found") + def _serve_state(self): sessions = [] SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + # Discover active Codex sessions and create session files for them + self._discover_active_codex_sessions() + + # Get active Zellij sessions for liveness check + active_zellij_sessions = self._get_active_zellij_sessions() + for f in SESSIONS_DIR.glob("*.json"): try: data = json.loads(f.read_text()) + + # Proactive liveness check: only auto-delete orphan "starting" sessions. + # Other statuses can still be useful as historical/debug context. + zellij_session = data.get("zellij_session", "") + if zellij_session and active_zellij_sessions is not None: + if zellij_session not in active_zellij_sessions: + if data.get("status") == "starting": + # A missing Zellij session while "starting" indicates an orphan. + f.unlink(missing_ok=True) + continue + sessions.append(data) except (json.JSONDecodeError, OSError): continue @@ -145,17 +186,34 @@ class AMCHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(response) - def _serve_conversation(self, session_id, project_dir): - """Serve conversation history from Claude Code JSONL file.""" + def _serve_conversation(self, session_id, project_dir, agent="claude"): + """Serve conversation history from Claude Code or Codex JSONL file.""" safe_id = os.path.basename(session_id) + messages = [] + + if agent == "codex": + messages = self._parse_codex_conversation(safe_id) + else: + messages = self._parse_claude_conversation(safe_id, project_dir) + + response = json.dumps({"session_id": safe_id, "messages": messages}).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def _parse_claude_conversation(self, session_id, project_dir): + """Parse Claude Code JSONL conversation format.""" + messages = [] # Convert project_dir to Claude's encoded format # /Users/foo/projects/bar -> -Users-foo-projects-bar if project_dir: encoded_dir = project_dir.replace("/", "-") - if encoded_dir.startswith("-"): - encoded_dir = encoded_dir # Already starts with - - else: + if not encoded_dir.startswith("-"): encoded_dir = "-" + encoded_dir else: encoded_dir = "" @@ -163,9 +221,8 @@ class AMCHandler(BaseHTTPRequestHandler): # Find the conversation file conv_file = None if encoded_dir: - conv_file = CLAUDE_PROJECTS_DIR / encoded_dir / f"{safe_id}.jsonl" + conv_file = CLAUDE_PROJECTS_DIR / encoded_dir / f"{session_id}.jsonl" - messages = [] if conv_file and conv_file.exists(): try: for line in conv_file.read_text().splitlines(): @@ -207,14 +264,75 @@ class AMCHandler(BaseHTTPRequestHandler): except OSError: pass - response = json.dumps({"session_id": safe_id, "messages": messages}).encode() + return messages - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Content-Length", str(len(response))) - self.end_headers() - self.wfile.write(response) + def _parse_codex_conversation(self, session_id): + """Parse Codex JSONL conversation format.""" + messages = [] + + # Find the Codex session file by searching for files containing the session ID + # Codex files are named: rollout-YYYY-MM-DDTHH-MM-SS-SESSION_ID.jsonl + conv_file = None + if CODEX_SESSIONS_DIR.exists(): + for jsonl_file in CODEX_SESSIONS_DIR.rglob("*.jsonl"): + if session_id in jsonl_file.name: + conv_file = jsonl_file + break + + if conv_file and conv_file.exists(): + try: + for line in conv_file.read_text().splitlines(): + if not line.strip(): + continue + try: + entry = json.loads(line) + + # Codex format: type="response_item", payload.type="message" + if entry.get("type") != "response_item": + continue + + payload = entry.get("payload", {}) + if payload.get("type") != "message": + continue + + role = payload.get("role", "") + content_parts = payload.get("content", []) + + # Skip developer role (system context/permissions) + if role == "developer": + continue + + # Extract text from content array + text_parts = [] + for part in content_parts: + if isinstance(part, dict): + # Codex uses "input_text" for user, "output_text" for assistant + text = part.get("text", "") + if text: + # Skip injected context (AGENTS.md, environment, permissions) + skip_prefixes = ( + "", + "", + "", + "# AGENTS.md instructions", + ) + if any(text.startswith(p) for p in skip_prefixes): + continue + text_parts.append(text) + + if text_parts and role in ("user", "assistant"): + messages.append({ + "role": role, + "content": "\n".join(text_parts), + "timestamp": entry.get("timestamp", "") + }) + + except json.JSONDecodeError: + continue + except OSError: + pass + + return messages def _dismiss_session(self, session_id): """Delete a session file (manual dismiss from dashboard).""" @@ -225,6 +343,7 @@ class AMCHandler(BaseHTTPRequestHandler): response = json.dumps({"ok": True}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(response))) self.end_headers() self.wfile.write(response) @@ -245,8 +364,8 @@ class AMCHandler(BaseHTTPRequestHandler): self._json_error(400, "Invalid JSON body") return - if not text: - self._json_error(400, "Missing 'text' field") + if not text or not text.strip(): + self._json_error(400, "Missing or empty 'text' field") return # Load session @@ -264,7 +383,7 @@ class AMCHandler(BaseHTTPRequestHandler): zellij_pane = session.get("zellij_pane", "") if not zellij_session or not zellij_pane: - self._json_error(400, "Session missing Zellij info") + self._json_error(400, "Session missing Zellij pane info - input not supported for auto-discovered Codex sessions") return # Parse pane ID from "terminal_N" format @@ -412,6 +531,156 @@ class AMCHandler(BaseHTTPRequestHandler): except Exception as e: return {"ok": False, "error": str(e)} + def _discover_active_codex_sessions(self): + """Find active Codex sessions and create/update session files with Zellij pane info.""" + if not CODEX_SESSIONS_DIR.exists(): + return + + # Get Zellij panes running codex with their cwds + codex_panes = self._get_codex_zellij_panes() + + # Only look at sessions modified in the last 10 minutes (active) + now = time.time() + cutoff = now - 600 # 10 minutes + + for jsonl_file in CODEX_SESSIONS_DIR.rglob("*.jsonl"): + try: + # Skip old files + mtime = jsonl_file.stat().st_mtime + if mtime < cutoff: + continue + + # Extract session ID from filename + match = re.search(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', jsonl_file.name) + if not match: + continue + + session_id = match.group(1) + session_file = SESSIONS_DIR / f"{session_id}.json" + + # Parse first line to get session metadata + with jsonl_file.open() as f: + first_line = f.readline().strip() + if not first_line: + continue + + meta = json.loads(first_line) + if meta.get("type") != "session_meta": + continue + + payload = meta.get("payload", {}) + cwd = payload.get("cwd", "") + project = os.path.basename(cwd) if cwd else "Unknown" + + # Try to find matching Zellij pane by cwd + zellij_session = "" + zellij_pane = "" + if cwd and codex_panes: + for pane_cwd, pane_info in codex_panes.items(): + # Match by directory name (end of path) + if cwd.endswith(pane_cwd) or pane_cwd.endswith(os.path.basename(cwd)): + zellij_session = pane_info.get("session", "") + zellij_pane = pane_info.get("pane_id", "") + break + + # Determine status based on file age + file_age_minutes = (now - mtime) / 60 + if file_age_minutes < 2: + status = "active" + else: + status = "done" + + # Read existing session to preserve some fields + existing = {} + if session_file.exists(): + try: + existing = json.loads(session_file.read_text()) + # Don't downgrade active to done if file was just updated + if existing.get("status") == "active" and status == "done": + # Check if we should keep it active + if file_age_minutes < 5: + status = "active" + except (json.JSONDecodeError, OSError): + pass + + # Get last message preview from recent lines + last_message = "" + try: + lines = jsonl_file.read_text().splitlines()[-30:] + for line in reversed(lines): + entry = json.loads(line) + if entry.get("type") == "response_item": + payload_item = entry.get("payload", {}) + if payload_item.get("role") == "assistant": + content = payload_item.get("content", []) + for part in content: + if isinstance(part, dict) and part.get("text"): + text = part["text"] + # Skip system content + if not text.startswith("<") and not text.startswith("#"): + last_message = text[:200] + break + if last_message: + break + except (json.JSONDecodeError, OSError): + pass + + session_ts = payload.get("timestamp", "") + last_event_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() + + session_data = { + "session_id": session_id, + "agent": "codex", + "project": project, + "project_dir": cwd, + "status": status, + "started_at": existing.get("started_at", session_ts), + "last_event_at": last_event_at, + "last_event": "CodexSession", + "last_message_preview": last_message, + "zellij_session": zellij_session or existing.get("zellij_session", ""), + "zellij_pane": zellij_pane or existing.get("zellij_pane", ""), + } + + session_file.write_text(json.dumps(session_data, indent=2)) + + except (OSError, json.JSONDecodeError): + continue + + def _get_codex_zellij_panes(self): + """Get Zellij panes running codex with their cwds.""" + try: + result = subprocess.run( + ["zellij", "action", "dump-layout"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode != 0: + return {} + + # Parse layout to find codex panes + # Format: pane command="codex" cwd="projects/amc" ... + panes = {} + zellij_session = os.environ.get("ZELLIJ_SESSION_NAME", "") + + for line in result.stdout.splitlines(): + if 'command="codex"' in line: + # Extract cwd + cwd_match = re.search(r'cwd="([^"]+)"', line) + if cwd_match: + cwd = cwd_match.group(1) + # We don't have pane ID from dump-layout, but we can use focus + panes[cwd] = { + "session": zellij_session, + "pane_id": "", # Can't get from dump-layout + } + + return panes + + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + return {} + def _json_error(self, code, message): """Send a JSON error response.""" response = json.dumps({"ok": False, "error": message}).encode() @@ -422,11 +691,44 @@ class AMCHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(response) + def _get_active_zellij_sessions(self): + """Query Zellij for active sessions. Returns set of session names, or None on error.""" + now = time.time() + + # Use cached value if fresh (cache for 5 seconds to avoid hammering zellij) + if _zellij_cache["sessions"] is not None and now < _zellij_cache["expires"]: + return _zellij_cache["sessions"] + + try: + result = subprocess.run( + ["zellij", "list-sessions", "--no-formatting"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + # Parse session names (one per line, format: "session_name [created ...]" or just "session_name") + sessions = set() + for line in result.stdout.strip().splitlines(): + if line: + # Session name is the first word + session_name = line.split()[0] if line.split() else "" + if session_name: + sessions.add(session_name) + _zellij_cache["sessions"] = sessions + _zellij_cache["expires"] = now + 5 # Cache for 5 seconds + return sessions + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + pass + + return None # Return None on error (don't clean up if we can't verify) + def _cleanup_stale(self, sessions): - """Remove orphan event logs >24h (no matching session file).""" + """Remove orphan event logs >24h and stale 'starting' sessions >1h.""" active_ids = {s.get("session_id") for s in sessions if s.get("session_id")} now = time.time() + # Clean up orphan event logs EVENTS_DIR.mkdir(parents=True, exist_ok=True) for f in EVENTS_DIR.glob("*.jsonl"): session_id = f.stem @@ -438,6 +740,17 @@ class AMCHandler(BaseHTTPRequestHandler): except OSError: pass + # Clean up orphan "starting" sessions (never became active) + for f in SESSIONS_DIR.glob("*.json"): + try: + age = now - f.stat().st_mtime + if age > STALE_STARTING_AGE: + data = json.loads(f.read_text()) + if data.get("status") == "starting": + f.unlink() + except (json.JSONDecodeError, OSError): + pass + def log_message(self, format, *args): """Suppress default request logging to keep output clean.""" pass