feat(agent): Phase 1 — chat-window AI assistant via Claude Code subprocess
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>
This commit is contained in:
parent
aeddaf9925
commit
79cf88d3f7
35 changed files with 1763 additions and 25 deletions
123
agent/claude_wrapper.py
Normal file
123
agent/claude_wrapper.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Subprocess wrapper around `claude -p` (Claude Code in headless JSON mode).
|
||||
|
||||
Run from cwd=/home/erik/MosswartOverlord so:
|
||||
• Sessions persist at ~/.claude/projects/-home-erik-MosswartOverlord/<uuid>.jsonl
|
||||
• Project-level .mcp.json is auto-loaded
|
||||
• CLAUDE.md in the repo root briefs the agent
|
||||
|
||||
The `--session-id` flag both creates a new session (first call) and resumes
|
||||
an existing one (subsequent calls), so we don't need separate code paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These can be overridden via env vars for non-prod testing.
|
||||
CLAUDE_BIN = os.getenv("CLAUDE_BIN", "/home/erik/.local/bin/claude")
|
||||
CLAUDE_CWD = os.getenv("CLAUDE_CWD", "/home/erik/MosswartOverlord")
|
||||
# Hard cap on how long a single agent turn may take. Claude Code can spin a
|
||||
# while when chaining many tool calls; we don't want to leave a zombie
|
||||
# subprocess if something gets stuck.
|
||||
CLAUDE_TIMEOUT_S = int(os.getenv("CLAUDE_TIMEOUT_S", "120"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeResult:
|
||||
result: str
|
||||
session_id: str
|
||||
duration_ms: int
|
||||
num_turns: int
|
||||
is_error: bool
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
class ClaudeError(RuntimeError):
|
||||
"""Raised when the claude CLI returns a non-zero exit or unparseable output."""
|
||||
|
||||
|
||||
async def ask_claude(message: str, session_id: str) -> ClaudeResult:
|
||||
"""Send `message` to `claude -p` resuming session_id; return parsed result.
|
||||
|
||||
Raises ClaudeError on subprocess failure, JSON parse failure, or timeout.
|
||||
"""
|
||||
if not Path(CLAUDE_BIN).exists():
|
||||
raise ClaudeError(f"claude binary not found at {CLAUDE_BIN}")
|
||||
if not Path(CLAUDE_CWD).is_dir():
|
||||
raise ClaudeError(f"CLAUDE_CWD does not exist: {CLAUDE_CWD}")
|
||||
|
||||
args = [
|
||||
CLAUDE_BIN,
|
||||
"-p",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"claude exec: session=%s msg_len=%d cwd=%s", session_id, len(message), CLAUDE_CWD
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=CLAUDE_CWD,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input=message.encode("utf-8")),
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise ClaudeError(f"claude timed out after {CLAUDE_TIMEOUT_S}s")
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise ClaudeError(
|
||||
f"claude exited {proc.returncode}: {stderr.decode('utf-8', 'replace')[:500]}"
|
||||
)
|
||||
|
||||
raw_text = stdout.decode("utf-8", "replace").strip()
|
||||
if not raw_text:
|
||||
raise ClaudeError("claude produced empty stdout")
|
||||
|
||||
# In --output-format json mode the LAST line is the JSON envelope; some
|
||||
# earlier lines may be progress. Be tolerant.
|
||||
try:
|
||||
envelope = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
# Try the last non-empty line
|
||||
last = next(
|
||||
(line for line in reversed(raw_text.splitlines()) if line.strip()),
|
||||
"",
|
||||
)
|
||||
try:
|
||||
envelope = json.loads(last)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ClaudeError(
|
||||
f"claude stdout was not JSON: {raw_text[:500]}"
|
||||
) from e
|
||||
|
||||
return ClaudeResult(
|
||||
result=envelope.get("result", ""),
|
||||
session_id=envelope.get("session_id", session_id),
|
||||
duration_ms=int(envelope.get("duration_ms", 0)),
|
||||
num_turns=int(envelope.get("num_turns", 0)),
|
||||
is_error=bool(envelope.get("is_error", False)),
|
||||
raw=envelope,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue