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:
Erik 2026-04-25 20:43:59 +02:00
parent aeddaf9925
commit 79cf88d3f7
35 changed files with 1763 additions and 25 deletions

View file

@ -6,6 +6,8 @@ export const SidebarWindowButtons: React.FC = () => {
return (
<div className="ml-tool-links">
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('playerdash', 'Player Dashboard')}>👥 Dashboard</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}

View file

@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import {
agentAsk,
agentNewSession,
agentSessionHistory,
type AgentHistoryMessage,
} from '../../api/endpoints';
interface Props {
id: string;
zIndex: number;
}
interface ChatMsg {
role: 'user' | 'assistant' | 'error';
text: string;
}
const SESSION_KEY = 'overlord_agent_session_id';
/** UUID is preferred but crypto.randomUUID is only available in secure contexts. */
function newUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// RFC4122-ish fallback
const r = (n: number) => Math.floor(Math.random() * n);
return `${r(0x100000000).toString(16).padStart(8, '0')}-${r(0x10000).toString(16).padStart(4, '0')}-4${r(0x1000).toString(16).padStart(3, '0')}-${(8 + r(4)).toString(16)}${r(0x1000).toString(16).padStart(3, '0')}-${r(0x1000000000000).toString(16).padStart(12, '0')}`;
}
function loadSessionId(): string {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) return stored;
} catch { /* ignore */ }
const fresh = newUuid();
try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ }
return fresh;
}
export const AgentWindow: React.FC<Props> = ({ id, zIndex }) => {
const [sessionId, setSessionId] = useState<string>(() => loadSessionId());
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [hydrating, setHydrating] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
// Rehydrate from server-side session JSONL on mount / session change.
useEffect(() => {
let cancelled = false;
setHydrating(true);
agentSessionHistory(sessionId)
.then(res => {
if (cancelled) return;
const msgs: ChatMsg[] = (res.messages ?? []).map((m: AgentHistoryMessage) => ({
role: m.role,
text: m.text,
}));
setMessages(msgs);
})
.catch(() => {
if (!cancelled) setMessages([]);
})
.finally(() => {
if (!cancelled) setHydrating(false);
});
return () => { cancelled = true; };
}, [sessionId]);
// Auto-scroll to bottom on new messages.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages.length, loading]);
const send = useCallback(async () => {
const text = input.trim();
if (!text || loading) return;
setInput('');
setMessages(prev => [...prev, { role: 'user', text }]);
setLoading(true);
try {
const res = await agentAsk(text, sessionId);
setMessages(prev => [
...prev,
{ role: res.is_error ? 'error' : 'assistant', text: res.result || '(no response)' },
]);
} catch (err) {
setMessages(prev => [
...prev,
{ role: 'error', text: `Request failed: ${String(err)}` },
]);
} finally {
setLoading(false);
}
}, [input, loading, sessionId]);
const newChat = useCallback(async () => {
if (loading) return;
let fresh = '';
try {
const res = await agentNewSession();
fresh = res.session_id;
} catch {
fresh = newUuid();
}
try { localStorage.setItem(SESSION_KEY, fresh); } catch { /* ignore */ }
setSessionId(fresh);
setMessages([]);
setInput('');
}, [loading]);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}, [send]);
return (
<DraggableWindow id={id} title="🤖 Overlord Assistant" zIndex={zIndex} width={520} height={620}>
<div className="ml-agent">
<div className="ml-agent-toolbar">
<button className="ml-agent-btn" onClick={newChat} disabled={loading}>+ New Chat</button>
<span className="ml-agent-session" title={sessionId}>{sessionId.slice(0, 8)}</span>
</div>
<div className="ml-agent-messages" ref={scrollRef}>
{hydrating && messages.length === 0 && (
<div className="ml-agent-empty">Loading conversation</div>
)}
{!hydrating && messages.length === 0 && (
<div className="ml-agent-empty">
Ask anything about the live game state players, kills, inventory,
suitbuilder, recent rares, etc.
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`ml-agent-msg ml-agent-${m.role}`}>
<div className="ml-agent-role">
{m.role === 'user' ? 'You' : m.role === 'assistant' ? 'Overlord' : 'Error'}
</div>
<div className="ml-agent-text">{m.text}</div>
</div>
))}
{loading && (
<div className="ml-agent-msg ml-agent-assistant">
<div className="ml-agent-role">Overlord</div>
<div className="ml-agent-text ml-agent-thinking">Thinking</div>
</div>
)}
</div>
<form
className="ml-agent-form"
onSubmit={e => { e.preventDefault(); void send(); }}
>
<textarea
className="ml-agent-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={loading ? 'Waiting for response…' : 'Type a message — Enter to send, Shift+Enter for newline'}
disabled={loading}
rows={2}
/>
<button
type="submit"
className="ml-agent-send"
disabled={loading || !input.trim()}
>
Send
</button>
</form>
</div>
</DraggableWindow>
);
};

View file

@ -11,6 +11,7 @@ const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
import type { CharacterState } from '../../types';
interface Props {
@ -62,6 +63,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <QuestStatusWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'playerdash':
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
case 'agent':
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
default:
return null;
}