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
This commit is contained in:
355
bin/amc-server
355
bin/amc-server
@@ -12,6 +12,7 @@ Endpoints:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -22,6 +23,9 @@ from pathlib import Path
|
|||||||
# Claude Code conversation directory
|
# Claude Code conversation directory
|
||||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
||||||
|
|
||||||
|
# Codex conversation directory
|
||||||
|
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
||||||
|
|
||||||
# Plugin path for zellij-send-keys
|
# Plugin path for zellij-send-keys
|
||||||
ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm"
|
ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm"
|
||||||
|
|
||||||
@@ -36,11 +40,17 @@ DASHBOARD_FILE = PROJECT_DIR / "dashboard.html"
|
|||||||
|
|
||||||
PORT = 7400
|
PORT = 7400
|
||||||
STALE_EVENT_AGE = 86400 # 24 hours in seconds
|
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):
|
class AMCHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/" or self.path == "/index.html":
|
if self.path == "/" or self.path == "/index.html":
|
||||||
|
self._serve_preact_dashboard()
|
||||||
|
elif self.path == "/old" or self.path == "/dashboard.html":
|
||||||
self._serve_dashboard()
|
self._serve_dashboard()
|
||||||
elif self.path == "/api/state":
|
elif self.path == "/api/state":
|
||||||
self._serve_state()
|
self._serve_state()
|
||||||
@@ -54,12 +64,14 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
session_id, query = path_part.split("?", 1)
|
session_id, query = path_part.split("?", 1)
|
||||||
params = urllib.parse.parse_qs(query)
|
params = urllib.parse.parse_qs(query)
|
||||||
project_dir = params.get("project_dir", [""])[0]
|
project_dir = params.get("project_dir", [""])[0]
|
||||||
|
agent = params.get("agent", ["claude"])[0]
|
||||||
else:
|
else:
|
||||||
session_id = path_part
|
session_id = path_part
|
||||||
project_dir = ""
|
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:
|
else:
|
||||||
self.send_error(404)
|
self._json_error(404, "Not Found")
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if self.path.startswith("/api/dismiss/"):
|
if self.path.startswith("/api/dismiss/"):
|
||||||
@@ -69,7 +81,7 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
session_id = urllib.parse.unquote(self.path[len("/api/respond/"):])
|
session_id = urllib.parse.unquote(self.path[len("/api/respond/"):])
|
||||||
self._respond_to_session(session_id)
|
self._respond_to_session(session_id)
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self._json_error(404, "Not Found")
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
def do_OPTIONS(self):
|
||||||
# CORS preflight for respond endpoint
|
# CORS preflight for respond endpoint
|
||||||
@@ -90,13 +102,42 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.send_error(500, "dashboard.html not found")
|
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):
|
def _serve_state(self):
|
||||||
sessions = []
|
sessions = []
|
||||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
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"):
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(f.read_text())
|
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)
|
sessions.append(data)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
continue
|
continue
|
||||||
@@ -145,17 +186,34 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(response)
|
self.wfile.write(response)
|
||||||
|
|
||||||
def _serve_conversation(self, session_id, project_dir):
|
def _serve_conversation(self, session_id, project_dir, agent="claude"):
|
||||||
"""Serve conversation history from Claude Code JSONL file."""
|
"""Serve conversation history from Claude Code or Codex JSONL file."""
|
||||||
safe_id = os.path.basename(session_id)
|
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
|
# Convert project_dir to Claude's encoded format
|
||||||
# /Users/foo/projects/bar -> -Users-foo-projects-bar
|
# /Users/foo/projects/bar -> -Users-foo-projects-bar
|
||||||
if project_dir:
|
if project_dir:
|
||||||
encoded_dir = project_dir.replace("/", "-")
|
encoded_dir = project_dir.replace("/", "-")
|
||||||
if encoded_dir.startswith("-"):
|
if not encoded_dir.startswith("-"):
|
||||||
encoded_dir = encoded_dir # Already starts with -
|
|
||||||
else:
|
|
||||||
encoded_dir = "-" + encoded_dir
|
encoded_dir = "-" + encoded_dir
|
||||||
else:
|
else:
|
||||||
encoded_dir = ""
|
encoded_dir = ""
|
||||||
@@ -163,9 +221,8 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
# Find the conversation file
|
# Find the conversation file
|
||||||
conv_file = None
|
conv_file = None
|
||||||
if encoded_dir:
|
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():
|
if conv_file and conv_file.exists():
|
||||||
try:
|
try:
|
||||||
for line in conv_file.read_text().splitlines():
|
for line in conv_file.read_text().splitlines():
|
||||||
@@ -207,14 +264,75 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
response = json.dumps({"session_id": safe_id, "messages": messages}).encode()
|
return messages
|
||||||
|
|
||||||
self.send_response(200)
|
def _parse_codex_conversation(self, session_id):
|
||||||
self.send_header("Content-Type", "application/json")
|
"""Parse Codex JSONL conversation format."""
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
messages = []
|
||||||
self.send_header("Content-Length", str(len(response)))
|
|
||||||
self.end_headers()
|
# Find the Codex session file by searching for files containing the session ID
|
||||||
self.wfile.write(response)
|
# 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 = (
|
||||||
|
"<INSTRUCTIONS>",
|
||||||
|
"<environment_context>",
|
||||||
|
"<permissions instructions>",
|
||||||
|
"# 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):
|
def _dismiss_session(self, session_id):
|
||||||
"""Delete a session file (manual dismiss from dashboard)."""
|
"""Delete a session file (manual dismiss from dashboard)."""
|
||||||
@@ -225,6 +343,7 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
response = json.dumps({"ok": True}).encode()
|
response = json.dumps({"ok": True}).encode()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Content-Length", str(len(response)))
|
self.send_header("Content-Length", str(len(response)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(response)
|
self.wfile.write(response)
|
||||||
@@ -245,8 +364,8 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
self._json_error(400, "Invalid JSON body")
|
self._json_error(400, "Invalid JSON body")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not text:
|
if not text or not text.strip():
|
||||||
self._json_error(400, "Missing 'text' field")
|
self._json_error(400, "Missing or empty 'text' field")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load session
|
# Load session
|
||||||
@@ -264,7 +383,7 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
zellij_pane = session.get("zellij_pane", "")
|
zellij_pane = session.get("zellij_pane", "")
|
||||||
|
|
||||||
if not zellij_session or not 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
|
return
|
||||||
|
|
||||||
# Parse pane ID from "terminal_N" format
|
# Parse pane ID from "terminal_N" format
|
||||||
@@ -412,6 +531,156 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(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):
|
def _json_error(self, code, message):
|
||||||
"""Send a JSON error response."""
|
"""Send a JSON error response."""
|
||||||
response = json.dumps({"ok": False, "error": message}).encode()
|
response = json.dumps({"ok": False, "error": message}).encode()
|
||||||
@@ -422,11 +691,44 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(response)
|
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):
|
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")}
|
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
# Clean up orphan event logs
|
||||||
EVENTS_DIR.mkdir(parents=True, exist_ok=True)
|
EVENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
for f in EVENTS_DIR.glob("*.jsonl"):
|
for f in EVENTS_DIR.glob("*.jsonl"):
|
||||||
session_id = f.stem
|
session_id = f.stem
|
||||||
@@ -438,6 +740,17 @@ class AMCHandler(BaseHTTPRequestHandler):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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):
|
def log_message(self, format, *args):
|
||||||
"""Suppress default request logging to keep output clean."""
|
"""Suppress default request logging to keep output clean."""
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user