From b2a5712202383d52419b75f271b3f5f429ab7f4e Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 09:21:56 -0500 Subject: [PATCH] Initial work, pre-preact refactor --- bin/amc | 62 ++ bin/amc-hook | 270 +++++++++ bin/amc-server | 464 +++++++++++++++ dashboard.html | 1470 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 2266 insertions(+) create mode 100755 bin/amc create mode 100755 bin/amc-hook create mode 100755 bin/amc-server create mode 100644 dashboard.html diff --git a/bin/amc b/bin/amc new file mode 100755 index 0000000..8d7f36a --- /dev/null +++ b/bin/amc @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# AMC — Agent Mission Control launcher +# Usage: amc [start|stop|status] + +set -euo pipefail + +# Runtime data +DATA_DIR="$HOME/.local/share/amc" +PID_FILE="$DATA_DIR/server.pid" +PORT=7400 + +# Find server relative to this script (handles symlinks) +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +SERVER="$SCRIPT_DIR/amc-server" + +cmd="${1:-start}" + +is_running() { + [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +case "$cmd" in + start) + if is_running; then + echo "AMC already running (pid $(cat "$PID_FILE"))" + else + mkdir -p "$DATA_DIR/sessions" "$DATA_DIR/events" + nohup "$SERVER" > "$DATA_DIR/server.log" 2>&1 & + # Wait briefly for server to start and write PID + sleep 0.3 + if is_running; then + echo "AMC started (pid $(cat "$PID_FILE"))" + else + echo "AMC started (pid $!)" + fi + fi + open "http://127.0.0.1:$PORT" + ;; + stop) + if is_running; then + pid="$(cat "$PID_FILE")" + kill "$pid" 2>/dev/null || true + rm -f "$PID_FILE" + echo "AMC stopped (pid $pid)" + else + echo "AMC not running" + rm -f "$PID_FILE" + fi + ;; + status) + if is_running; then + echo "AMC running (pid $(cat "$PID_FILE"), port $PORT)" + else + echo "AMC not running" + rm -f "$PID_FILE" + fi + ;; + *) + echo "Usage: amc [start|stop|status]" + exit 1 + ;; +esac diff --git a/bin/amc-hook b/bin/amc-hook new file mode 100755 index 0000000..df7545e --- /dev/null +++ b/bin/amc-hook @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""AMC hook — writes Claude Code session state to disk. + +Called by Claude Code hooks: + SessionStart, UserPromptSubmit, Stop, SessionEnd + PreToolUse(AskUserQuestion), PostToolUse(AskUserQuestion) + +Reads hook JSON from stdin, writes session state + appends event log. + +MUST be fail-open: never exit nonzero, never block, never crash Claude. +""" + +import json +import os +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +DATA_DIR = Path.home() / ".local" / "share" / "amc" +SESSIONS_DIR = DATA_DIR / "sessions" +EVENTS_DIR = DATA_DIR / "events" + +STATUS_MAP = { + "SessionStart": "starting", + "UserPromptSubmit": "active", + "Stop": "done", +} + +MAX_PREVIEW_LEN = 200 +MAX_QUESTION_LEN = 500 + + +def _detect_prose_question(message): + """Detect if message ends with a question. Returns question text or None.""" + if not message: + return None + + # Strip trailing whitespace and check for question mark + text = message.rstrip() + if not text.endswith("?"): + return None + + # Extract the question - find the last paragraph or sentence with "?" + # Split by double newlines (paragraphs) first + paragraphs = text.split("\n\n") + last_para = paragraphs[-1].strip() + + # If the last paragraph has a question mark, use it + if "?" in last_para: + # Truncate if too long + if len(last_para) > MAX_QUESTION_LEN: + last_para = last_para[-MAX_QUESTION_LEN:] + # Try to start at a sentence boundary + first_period = last_para.find(". ") + if first_period > 0: + last_para = last_para[first_period + 2:] + return last_para + + return None + + +def _extract_questions(hook): + """Extract question text from AskUserQuestion tool_input.""" + tool_input = hook.get("tool_input", {}) + if isinstance(tool_input, str): + try: + tool_input = json.loads(tool_input) + except (json.JSONDecodeError, TypeError): + return [] + + # Guard against non-dict tool_input (null, list, etc.) + if not isinstance(tool_input, dict): + return [] + + questions = tool_input.get("questions", []) + result = [] + for q in questions: + entry = { + "question": q.get("question", ""), + "header": q.get("header", ""), + "options": [], + } + for opt in q.get("options", []): + entry["options"].append({ + "label": opt.get("label", ""), + "description": opt.get("description", ""), + }) + result.append(entry) + return result + + +def _atomic_write(path, data): + """Write JSON data atomically via temp file + os.replace().""" + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + closed = False + try: + os.write(fd, json.dumps(data, indent=2).encode()) + os.close(fd) + closed = True + os.replace(tmp, path) + except Exception: + if not closed: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _read_session(session_file): + """Read existing session state, or return empty dict.""" + if session_file.exists(): + try: + return json.loads(session_file.read_text()) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def main(): + try: + raw = sys.stdin.read() + if not raw.strip(): + return + + hook = json.loads(raw) + event = hook.get("hook_event_name", "") + session_id = hook.get("session_id", "") + if not session_id or not event: + return + + SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + EVENTS_DIR.mkdir(parents=True, exist_ok=True) + + now = datetime.now(timezone.utc).isoformat() + + # Sanitize session_id to prevent path traversal + session_id = os.path.basename(session_id) + if not session_id: + return + + session_file = SESSIONS_DIR / f"{session_id}.json" + + # SessionEnd: delete session file (session is gone) + if event == "SessionEnd": + _append_event(session_id, {"event": event, "at": now}) + try: + session_file.unlink(missing_ok=True) + except OSError: + pass + return + + # PreToolUse(AskUserQuestion): mark needs_attention + store questions + if event == "PreToolUse": + tool_name = hook.get("tool_name", "") + if tool_name == "AskUserQuestion": + existing = _read_session(session_file) + if not existing: + return + existing["status"] = "needs_attention" + existing["last_event"] = f"PreToolUse({tool_name})" + existing["last_event_at"] = now + existing["pending_questions"] = _extract_questions(hook) + _atomic_write(session_file, existing) + _append_event(session_id, { + "event": f"PreToolUse({tool_name})", + "at": now, + "status": "needs_attention", + }) + return + + # PostToolUse(AskUserQuestion): question answered, back to active + if event == "PostToolUse": + tool_name = hook.get("tool_name", "") + if tool_name == "AskUserQuestion": + existing = _read_session(session_file) + if not existing: + return + existing["status"] = "active" + existing["last_event"] = f"PostToolUse({tool_name})" + existing["last_event_at"] = now + existing.pop("pending_questions", None) + _atomic_write(session_file, existing) + _append_event(session_id, { + "event": f"PostToolUse({tool_name})", + "at": now, + "status": "active", + }) + return + + # Guard: don't resurrect a session after SessionEnd deleted it. + if event != "SessionStart" and not session_file.exists(): + return + + # Build session state for SessionStart, UserPromptSubmit, Stop + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", hook.get("cwd", "")) + project = os.path.basename(project_dir) if project_dir else "unknown" + + existing = _read_session(session_file) + + # Get the full message for question detection + full_message = hook.get("last_assistant_message", "") or "" + + # Truncate message preview + preview = full_message + if len(preview) > MAX_PREVIEW_LEN: + preview = preview[:MAX_PREVIEW_LEN] + "..." + + # Determine status - check for prose questions on Stop + status = STATUS_MAP.get(event, existing.get("status", "unknown")) + prose_question = None + if event == "Stop": + prose_question = _detect_prose_question(full_message) + if prose_question: + status = "needs_attention" + + state = { + "session_id": session_id, + "agent": "claude", + "project": project, + "project_dir": project_dir, + "status": status, + "started_at": existing.get("started_at", now), + "last_event_at": now, + "last_event": event, + "last_message_preview": preview, + "zellij_session": os.environ.get("ZELLIJ_SESSION_NAME", ""), + "zellij_pane": os.environ.get("ZELLIJ_PANE_ID", ""), + } + + # Store prose question if detected + if prose_question: + state["pending_questions"] = [{ + "question": prose_question, + "header": "Question", + "options": [], + }] + + _atomic_write(session_file, state) + event_name = event + if event == "Stop" and prose_question: + event_name = "Stop(question)" + _append_event(session_id, { + "event": event_name, + "at": now, + "status": status, + }) + + except Exception: + # Fail open: never let a hook error affect Claude + pass + + +def _append_event(session_id, event_data): + """Append a single JSON line to the session's event log.""" + event_file = EVENTS_DIR / f"{session_id}.jsonl" + try: + with open(event_file, "a") as f: + f.write(json.dumps(event_data) + "\n") + except OSError: + pass + + +if __name__ == "__main__": + main() diff --git a/bin/amc-server b/bin/amc-server new file mode 100755 index 0000000..00c156f --- /dev/null +++ b/bin/amc-server @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""AMC server — serves the dashboard and session state API. + +Endpoints: + GET / → dashboard.html + GET /api/state → aggregated session state JSON + GET /api/events/ID → event timeline for one session + GET /api/conversation/ID → conversation history for a session + POST /api/dismiss/ID → dismiss (delete) a completed session + POST /api/respond/ID → inject response into session's Zellij pane +""" + +import json +import os +import subprocess +import time +import urllib.parse +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + +# Claude Code conversation directory +CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" + +# Plugin path for zellij-send-keys +ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm" + +# Runtime data lives in XDG data dir +DATA_DIR = Path.home() / ".local" / "share" / "amc" +SESSIONS_DIR = DATA_DIR / "sessions" +EVENTS_DIR = DATA_DIR / "events" + +# Source files live in project directory (relative to this script) +PROJECT_DIR = Path(__file__).resolve().parent.parent +DASHBOARD_FILE = PROJECT_DIR / "dashboard.html" + +PORT = 7400 +STALE_EVENT_AGE = 86400 # 24 hours in seconds + + +class AMCHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/" or self.path == "/index.html": + self._serve_dashboard() + elif self.path == "/api/state": + self._serve_state() + elif self.path.startswith("/api/events/"): + session_id = urllib.parse.unquote(self.path[len("/api/events/"):]) + self._serve_events(session_id) + elif self.path.startswith("/api/conversation/"): + # Parse session_id and query params + path_part = self.path[len("/api/conversation/"):] + if "?" in path_part: + session_id, query = path_part.split("?", 1) + params = urllib.parse.parse_qs(query) + project_dir = params.get("project_dir", [""])[0] + else: + session_id = path_part + project_dir = "" + self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir)) + else: + self.send_error(404) + + def do_POST(self): + if self.path.startswith("/api/dismiss/"): + session_id = urllib.parse.unquote(self.path[len("/api/dismiss/"):]) + self._dismiss_session(session_id) + elif self.path.startswith("/api/respond/"): + session_id = urllib.parse.unquote(self.path[len("/api/respond/"):]) + self._respond_to_session(session_id) + else: + self.send_error(404) + + def do_OPTIONS(self): + # CORS preflight for respond endpoint + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def _serve_dashboard(self): + try: + content = DASHBOARD_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.html not found") + + def _serve_state(self): + sessions = [] + SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + for f in SESSIONS_DIR.glob("*.json"): + try: + data = json.loads(f.read_text()) + sessions.append(data) + except (json.JSONDecodeError, OSError): + continue + + # Sort by last_event_at descending + sessions.sort(key=lambda s: s.get("last_event_at", ""), reverse=True) + + # Clean orphan event logs (sessions persist until manually dismissed or SessionEnd) + self._cleanup_stale(sessions) + + response = json.dumps({ + "sessions": sessions, + "server_time": datetime.now(timezone.utc).isoformat(), + }).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 _serve_events(self, session_id): + # Sanitize session_id to prevent path traversal + safe_id = os.path.basename(session_id) + event_file = EVENTS_DIR / f"{safe_id}.jsonl" + + events = [] + if event_file.exists(): + try: + for line in event_file.read_text().splitlines(): + if line.strip(): + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except OSError: + pass + + response = json.dumps({"session_id": safe_id, "events": events}).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 _serve_conversation(self, session_id, project_dir): + """Serve conversation history from Claude Code JSONL file.""" + safe_id = os.path.basename(session_id) + + # 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: + encoded_dir = "-" + encoded_dir + else: + encoded_dir = "" + + # Find the conversation file + conv_file = None + if encoded_dir: + conv_file = CLAUDE_PROJECTS_DIR / encoded_dir / f"{safe_id}.jsonl" + + messages = [] + 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) + msg_type = entry.get("type") + + if msg_type == "user": + content = entry.get("message", {}).get("content", "") + # Only include actual human messages (strings), not tool results (arrays) + if content and isinstance(content, str): + messages.append({ + "role": "user", + "content": content, + "timestamp": entry.get("timestamp", "") + }) + + elif msg_type == "assistant": + # Assistant messages have structured content + raw_content = entry.get("message", {}).get("content", []) + text_parts = [] + for part in raw_content: + if isinstance(part, dict): + if part.get("type") == "text": + text_parts.append(part.get("text", "")) + elif isinstance(part, str): + text_parts.append(part) + if text_parts: + messages.append({ + "role": "assistant", + "content": "\n".join(text_parts), + "timestamp": entry.get("timestamp", "") + }) + + except json.JSONDecodeError: + continue + except OSError: + pass + + 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 _dismiss_session(self, session_id): + """Delete a session file (manual dismiss from dashboard).""" + safe_id = os.path.basename(session_id) + session_file = SESSIONS_DIR / f"{safe_id}.json" + session_file.unlink(missing_ok=True) + + response = json.dumps({"ok": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def _respond_to_session(self, session_id): + """Inject a response into the session's Zellij pane.""" + safe_id = os.path.basename(session_id) + session_file = SESSIONS_DIR / f"{safe_id}.json" + + # Read request body + try: + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + text = body.get("text", "") + is_freeform = body.get("freeform", False) + option_count = body.get("optionCount", 0) + except (json.JSONDecodeError, ValueError): + self._json_error(400, "Invalid JSON body") + return + + if not text: + self._json_error(400, "Missing 'text' field") + return + + # Load session + if not session_file.exists(): + self._json_error(404, "Session not found") + return + + try: + session = json.loads(session_file.read_text()) + except (json.JSONDecodeError, OSError): + self._json_error(500, "Failed to read session") + return + + zellij_session = session.get("zellij_session", "") + zellij_pane = session.get("zellij_pane", "") + + if not zellij_session or not zellij_pane: + self._json_error(400, "Session missing Zellij info") + return + + # Parse pane ID from "terminal_N" format + pane_id = self._parse_pane_id(zellij_pane) + if pane_id is None: + self._json_error(400, f"Invalid pane format: {zellij_pane}") + return + + # For freeform responses, we need two-step injection: + # 1. Send "Other" option number (optionCount + 1) WITHOUT Enter + # 2. Wait for Claude Code to switch to text input mode + # 3. Send the actual text WITH Enter + if is_freeform and option_count > 0: + other_num = str(option_count + 1) + result = self._inject_to_pane(zellij_session, pane_id, other_num, send_enter=False) + if not result["ok"]: + response = json.dumps({"ok": False, "error": result["error"]}).encode() + self.send_response(500) + 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) + return + # Delay for Claude Code to switch to text input mode + time.sleep(0.3) + + # Inject the actual text (with Enter) + result = self._inject_to_pane(zellij_session, pane_id, text, send_enter=True) + + if result["ok"]: + response = json.dumps({"ok": True}).encode() + self.send_response(200) + else: + response = json.dumps({"ok": False, "error": result["error"]}).encode() + self.send_response(500) + + 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_pane_id(self, zellij_pane): + """Extract numeric pane ID from various formats.""" + if not zellij_pane: + return None + + # Try direct integer (e.g., "10") + try: + return int(zellij_pane) + except ValueError: + pass + + # Try "terminal_N" format + parts = zellij_pane.split("_") + if len(parts) == 2 and parts[0] in ("terminal", "plugin"): + try: + return int(parts[1]) + except ValueError: + pass + + return None + + def _inject_to_pane(self, zellij_session, pane_id, text, send_enter=True): + """Inject text into a pane using zellij actions.""" + env = os.environ.copy() + env["ZELLIJ_SESSION_NAME"] = zellij_session + + # Try plugin first (no focus change), fall back to write-chars (changes focus) + if ZELLIJ_PLUGIN.exists(): + result = self._try_plugin_inject(env, pane_id, text, send_enter) + if result["ok"]: + return result + # Plugin failed, fall back to write-chars + + return self._try_write_chars_inject(env, text, send_enter) + + def _try_plugin_inject(self, env, pane_id, text, send_enter=True): + """Try injecting via zellij-send-keys plugin (no focus change).""" + payload = json.dumps({ + "pane_id": pane_id, + "text": text, + "send_enter": send_enter, + }) + + try: + result = subprocess.run( + [ + "zellij", "action", "pipe", + "--plugin", f"file:{ZELLIJ_PLUGIN}", + "--name", "send_keys", + "--floating-plugin", "false", + "--", payload, + ], + env=env, + capture_output=True, + text=True, + timeout=3, + ) + + if result.returncode == 0: + return {"ok": True} + return {"ok": False, "error": result.stderr or "plugin failed"} + + except subprocess.TimeoutExpired: + return {"ok": False, "error": "plugin timed out"} + except Exception as e: + return {"ok": False, "error": str(e)} + + def _try_write_chars_inject(self, env, text, send_enter=True): + """Inject via write-chars (writes to focused pane, simpler but changes focus).""" + try: + # Write the text + result = subprocess.run( + ["zellij", "action", "write-chars", text], + env=env, + capture_output=True, + text=True, + timeout=2, + ) + + if result.returncode != 0: + return {"ok": False, "error": result.stderr or "write-chars failed"} + + # Send Enter if requested + if send_enter: + result = subprocess.run( + ["zellij", "action", "write", "13"], # 13 = Enter + env=env, + capture_output=True, + text=True, + timeout=2, + ) + + if result.returncode != 0: + return {"ok": False, "error": result.stderr or "write Enter failed"} + + return {"ok": True} + + except subprocess.TimeoutExpired: + return {"ok": False, "error": "write-chars timed out"} + except FileNotFoundError: + return {"ok": False, "error": "zellij not found in PATH"} + except Exception as e: + return {"ok": False, "error": str(e)} + + def _json_error(self, code, message): + """Send a JSON error response.""" + response = json.dumps({"ok": False, "error": message}).encode() + self.send_response(code) + 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 _cleanup_stale(self, sessions): + """Remove orphan event logs >24h (no matching session file).""" + active_ids = {s.get("session_id") for s in sessions if s.get("session_id")} + now = time.time() + + EVENTS_DIR.mkdir(parents=True, exist_ok=True) + for f in EVENTS_DIR.glob("*.jsonl"): + session_id = f.stem + if session_id not in active_ids: + try: + age = now - f.stat().st_mtime + if age > STALE_EVENT_AGE: + f.unlink() + except OSError: + pass + + def log_message(self, format, *args): + """Suppress default request logging to keep output clean.""" + pass + + +def main(): + server = HTTPServer(("127.0.0.1", PORT), AMCHandler) + print(f"AMC server listening on http://127.0.0.1:{PORT}") + + # Write PID file + pid_file = DATA_DIR / "server.pid" + pid_file.write_text(str(os.getpid())) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + pid_file.unlink(missing_ok=True) + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..a5fa9fc --- /dev/null +++ b/dashboard.html @@ -0,0 +1,1470 @@ + + + + + +AMC + + + + +
+ AGENT MISSION CONTROL +
+
+ +
+
+
No active sessions
+
Start a Claude Code session to see it here
+
+
+ + + + + + +