Adds an in-dashboard AI assistant that answers questions about live game
state. Designed reactively (no background loops) — every user message in
the chat window or via /api/agent/ask runs one `claude -p` invocation.
Architecture:
- New host-side FastAPI service (agent/) on 127.0.0.1:8767, OUTSIDE the
dereth-tracker Docker container because `claude` and ~/.claude
credentials live on the host.
- nginx routes /api/agent/* to the host service.
- The same browser session cookie the tracker issues authenticates
agent requests (shared SECRET_KEY).
- The agent shells out to `claude -p --session-id <uuid>` with
cwd=/home/erik/MosswartOverlord. Sessions persist as JSONL on disk
via Claude Code's built-in machinery.
- An MCP stdio server (agent/mcp_overlord.py) exposes tools to Claude:
get_live_players, get_recent_rares, query_telemetry_db (read-only,
parsed by sqlglot to reject DML/DDL), get_player_state, get_inventory,
get_inventory_search, get_combat_stats, get_equipment_cantrips,
get_quest_status, get_server_health, suitbuilder_search.
- Read-only PG role (overlord_agent_ro) is the second line of defense
on the SQL tool — even a parser bypass can't mutate.
Frontend:
- AgentWindow.tsx — draggable chat window with localStorage-pinned
session UUID, "New Chat" button, on-mount rehydration from
/agent/sessions/{id}/history (parses Claude Code's JSONL).
- Wired into WindowRenderer + Sidebar (🤖 Assistant button).
Operational:
- systemd unit (overlord-agent.service) + install.sh.
- agent/README.md documents env vars, deploy flow, smoke tests.
- nginx/overlord.conf gets a new /api/agent/ location with 180s timeout.
- CLAUDE.md gets an "Overlord Assistant Mode" section briefing the
agent on which tools to use and how to behave.
NOT YET DEPLOYED — server still needs:
1. Apply agent/sql/0001_overlord_agent_ro.sql + ALTER ROLE password
2. Add AGENT_DB_DSN to /home/erik/MosswartOverlord/.env
3. bash agent/install.sh (creates venv, installs unit, starts service)
4. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf to nginx + reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
213 lines
7 KiB
Python
213 lines
7 KiB
Python
"""Overlord Agent host-side FastAPI service.
|
|
|
|
Runs OUTSIDE Docker (host-side) on port 8767.
|
|
|
|
Endpoints:
|
|
GET /agent/health — liveness check
|
|
POST /agent/sessions/new — returns a fresh session UUID
|
|
POST /agent/ask — runs claude -p with given session
|
|
GET /agent/sessions/{session_id}/history
|
|
— replays a session's JSONL on disk
|
|
|
|
Auth: every endpoint except /health requires the same browser session
|
|
cookie that dereth-tracker issues.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import auth
|
|
from .claude_wrapper import CLAUDE_CWD, ClaudeError, ask_claude
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("agent")
|
|
|
|
app = FastAPI(title="Overlord Agent", version="0.1.0")
|
|
|
|
|
|
# ─── Models ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
session_id: str = Field(
|
|
..., description="Stable per-conversation UUID stored in browser localStorage"
|
|
)
|
|
message: str = Field(..., min_length=1, max_length=10_000)
|
|
|
|
|
|
class AskResponse(BaseModel):
|
|
result: str
|
|
session_id: str
|
|
duration_ms: int
|
|
num_turns: int
|
|
is_error: bool
|
|
|
|
|
|
class NewSessionResponse(BaseModel):
|
|
session_id: str
|
|
|
|
|
|
# ─── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _encode_cwd(cwd: str) -> str:
|
|
"""Match Claude Code's on-disk encoding for cwd → directory name.
|
|
|
|
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
|
where non-alphanumerics in the cwd are replaced with hyphens.
|
|
Example: /home/erik/MosswartOverlord → -home-erik-MosswartOverlord
|
|
"""
|
|
return "".join(c if c.isalnum() else "-" for c in cwd)
|
|
|
|
|
|
def _sessions_dir() -> Path:
|
|
return Path.home() / ".claude" / "projects" / _encode_cwd(CLAUDE_CWD)
|
|
|
|
|
|
# ─── Endpoints ───────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/agent/health")
|
|
async def health() -> dict:
|
|
"""Liveness probe — no auth, used by deployment scripts."""
|
|
return {
|
|
"status": "ok",
|
|
"claude_cwd": CLAUDE_CWD,
|
|
"sessions_dir_exists": _sessions_dir().exists(),
|
|
}
|
|
|
|
|
|
@app.post("/agent/sessions/new", response_model=NewSessionResponse)
|
|
async def new_session(_user: dict = Depends(auth.require_user)) -> NewSessionResponse:
|
|
"""Generate a fresh session UUID. Doesn't touch disk — claude creates the
|
|
JSONL file when the first message lands."""
|
|
return NewSessionResponse(session_id=str(uuid.uuid4()))
|
|
|
|
|
|
@app.post("/agent/ask", response_model=AskResponse)
|
|
async def agent_ask(
|
|
req: AskRequest, user: dict = Depends(auth.require_user)
|
|
) -> AskResponse:
|
|
"""Forward a message to claude -p resuming the given session."""
|
|
started = time.monotonic()
|
|
try:
|
|
result = await ask_claude(req.message, req.session_id)
|
|
except ClaudeError as e:
|
|
logger.warning(
|
|
"claude failed user=%s session=%s err=%s", user["username"], req.session_id, e
|
|
)
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
|
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
logger.info(
|
|
"ask user=%s session=%s turns=%d duration_ms=%d (subprocess=%dms)",
|
|
user["username"],
|
|
result.session_id,
|
|
result.num_turns,
|
|
elapsed_ms,
|
|
result.duration_ms,
|
|
)
|
|
|
|
return AskResponse(
|
|
result=result.result,
|
|
session_id=result.session_id,
|
|
duration_ms=result.duration_ms,
|
|
num_turns=result.num_turns,
|
|
is_error=result.is_error,
|
|
)
|
|
|
|
|
|
@app.get("/agent/sessions/{session_id}/history")
|
|
async def session_history(
|
|
session_id: str, _user: dict = Depends(auth.require_user)
|
|
) -> JSONResponse:
|
|
"""Replay a session's JSONL from ~/.claude/projects/.../<id>.jsonl.
|
|
|
|
Returns a flat array of {role, text, timestamp} for the chat window.
|
|
Returns an empty array if the session file doesn't exist yet.
|
|
"""
|
|
# UUID sanity check to prevent path traversal — claude Code uses uuid4
|
|
try:
|
|
uuid.UUID(session_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="invalid session_id")
|
|
|
|
path = _sessions_dir() / f"{session_id}.jsonl"
|
|
if not path.is_file():
|
|
return JSONResponse({"messages": []})
|
|
|
|
messages: list[dict[str, Any]] = []
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
# Claude Code records turns with type=user / type=assistant.
|
|
# Tool-use traffic is verbose; skip it for the chat UI.
|
|
msg_type = obj.get("type")
|
|
if msg_type not in ("user", "assistant"):
|
|
continue
|
|
msg = obj.get("message") or {}
|
|
content = msg.get("content")
|
|
# `content` may be a string or list[{type,text}].
|
|
if isinstance(content, str):
|
|
text = content
|
|
elif isinstance(content, list):
|
|
text = "".join(
|
|
part.get("text", "")
|
|
for part in content
|
|
if isinstance(part, dict) and part.get("type") == "text"
|
|
)
|
|
else:
|
|
text = ""
|
|
if not text:
|
|
continue
|
|
messages.append(
|
|
{
|
|
"role": msg_type,
|
|
"text": text,
|
|
"timestamp": obj.get("timestamp"),
|
|
}
|
|
)
|
|
except OSError as e:
|
|
logger.warning("failed to read session %s: %s", session_id, e)
|
|
raise HTTPException(status_code=500, detail="failed to read session")
|
|
|
|
return JSONResponse({"messages": messages})
|
|
|
|
|
|
# ─── Entrypoint ──────────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> None:
|
|
"""Run via `python -m agent.service` for local testing."""
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"agent.service:app",
|
|
host="127.0.0.1",
|
|
port=8767,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|