Initial work, pre-preact refactor
This commit is contained in:
62
bin/amc
Executable file
62
bin/amc
Executable file
@@ -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
|
||||||
270
bin/amc-hook
Executable file
270
bin/amc-hook
Executable file
@@ -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()
|
||||||
464
bin/amc-server
Executable file
464
bin/amc-server
Executable file
@@ -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()
|
||||||
1470
dashboard.html
Normal file
1470
dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user