Skip to content

Instantly share code, notes, and snippets.

@espennilsen
Created February 10, 2026 09:35
Show Gist options
  • Select an option

  • Save espennilsen/6512812b74c8d8cd11a71ed3cfa4bb05 to your computer and use it in GitHub Desktop.

Select an option

Save espennilsen/6512812b74c8d8cd11a71ed3cfa4bb05 to your computer and use it in GitHub Desktop.
/**
* Hannah — Web UI adapter.
*
* Serves a dashboard for monitoring agent activity. Uses Node's built-in
* http module — no framework dependencies.
*
* Routes:
* GET / — Dashboard (inline HTML/CSS/JS)
* GET /api/stats — JSON totals
* GET /api/jobs — JSON recent jobs
* GET /api/jobs/:id — JSON single job + tool calls
* GET /api/daily — JSON daily stats
* GET /api/models — JSON model breakdown
* GET /api/tools — JSON tool breakdown
* GET /api/sections — Registered dashboard sections (for dynamic injection)
* GET /events — SSE stream of real-time events
*
* Extensions can register additional routes via addRoute() and dashboard
* sections via addDashboardSection(). See types.ts for the interfaces.
*
* Also implements Plugin to receive lifecycle events and push them via SSE.
*/
import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import type { Adapter, Plugin, HannahServer, JobRecord, ToolCallRecord, WebRouteHandler, DashboardSection } from "../types.ts";
import type { WebConfig } from "../config.ts";
import { jobsApi } from "../extensions/jobs/jobs-db.ts";
// ── Simple rate limiter ─────────────────────────────────────────
class RateLimiter {
private hits = new Map<string, number[]>();
constructor(private maxRequests: number, private windowMs: number) {}
isAllowed(key: string): boolean {
const now = Date.now();
const timestamps = this.hits.get(key)?.filter(t => now - t < this.windowMs) ?? [];
if (timestamps.length >= this.maxRequests) {
this.hits.set(key, timestamps);
return false;
}
timestamps.push(now);
this.hits.set(key, timestamps);
return true;
}
}
// Dashboard HTML is lazy-loaded on first request to avoid crashing at import time.
let _dashboardHtml: string | null = null;
function getDashboardHtml(): string {
if (!_dashboardHtml) {
_dashboardHtml = fs.readFileSync(
path.resolve(import.meta.dirname, "dashboard.html"),
"utf-8",
);
}
return _dashboardHtml;
}
// Feature pages (tasks, projects, vault, calendar) are now served by their
// respective modules via addWebRoute(). Only the core dashboard stays here.
export class WebAdapter implements Adapter, Plugin {
readonly name = "web";
private config: WebConfig;
private httpServer: http.Server | null = null;
private server!: HannahServer;
private sseClients = new Set<http.ServerResponse>();
private startedAt = new Date();
private customRoutes = new Map<string, WebRouteHandler>();
private dashboardSections: DashboardSection[] = [];
private promptLimiter = new RateLimiter(10, 60_000); // 10 requests per minute
constructor(config: WebConfig) {
this.config = config;
}
// ── Web extension API ───────────────────────────────────────
/**
* Register a custom API route. Key format: "METHOD /path".
* The handler receives (req, res, url).
*/
addRoute(method: string, routePath: string, handler: WebRouteHandler): void {
this.customRoutes.set(`${method.toUpperCase()} ${routePath}`, handler);
}
/**
* Register a dashboard section to be rendered in the web UI.
* Sections are served via GET /api/sections and loaded dynamically.
*/
addDashboardSection(section: DashboardSection): void {
this.dashboardSections.push(section);
this.dashboardSections.sort((a, b) => a.order - b.order);
}
/**
* Deliver a heartbeat message via SSE to all connected dashboard clients.
*/
async deliverHeartbeat(message: string): Promise<void> {
this.broadcast({ type: "heartbeat", message, time: new Date().toISOString() });
}
// ── Adapter interface ───────────────────────────────────────
async start(server: HannahServer): Promise<void> {
this.server = server;
// Warn if listening beyond localhost without auth
if (this.config.host !== "127.0.0.1" && this.config.host !== "localhost" && !this.config.auth) {
console.warn(
`[web] ⚠️ Listening on ${this.config.host}:${this.config.port} WITHOUT authentication. ` +
`Set web.auth: true and HANNAH_WEB_ADMIN to secure the dashboard.`
);
}
this.httpServer = http.createServer((req, res) => this.handleRequest(req, res));
this.httpServer.listen(this.config.port, this.config.host);
}
async stop(): Promise<void> {
for (const client of this.sseClients) {
try { client.end(); } catch {}
}
this.sseClients.clear();
return new Promise((resolve) => {
if (this.httpServer) {
this.httpServer.close(() => resolve());
} else {
resolve();
}
});
}
// ── Plugin interface ────────────────────────────────────────
onJobCreated(job: JobRecord): void {
this.broadcast({ type: "job_created", job: this.summarizeJob(job) });
}
onJobDone(job: JobRecord): void {
this.broadcast({ type: "job_done", job: this.summarizeJob(job) });
}
onJobFailed(job: JobRecord): void {
this.broadcast({ type: "job_failed", job: this.summarizeJob(job) });
}
onToolCall(toolCall: ToolCallRecord): void {
this.broadcast({ type: "tool_call", toolCall });
}
onStreamEvent(event: any): void {
switch (event.type) {
case "agent_start":
this.broadcast({ type: "stream_agent_start", time: new Date().toISOString() });
break;
case "agent_end":
this.broadcast({ type: "stream_agent_end", time: new Date().toISOString() });
break;
case "message_update":
if (event.assistantMessageEvent?.type === "text_delta") {
this.broadcast({
type: "stream_text_delta",
delta: event.assistantMessageEvent.delta,
});
}
break;
case "tool_execution_start":
this.broadcast({
type: "stream_tool_start",
toolName: event.toolName,
args: event.args,
});
break;
case "tool_execution_end":
this.broadcast({
type: "stream_tool_end",
toolName: event.toolName,
isError: event.isError,
});
break;
case "turn_start":
this.broadcast({ type: "stream_turn_start" });
break;
case "turn_end":
this.broadcast({ type: "stream_turn_end" });
break;
}
}
// ── HTTP handler ────────────────────────────────────────────
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const pathname = url.pathname;
// CORS for local dev
res.setHeader("Access-Control-Allow-Origin", "*");
// HTTP Basic Auth — only enforced when auth is enabled and HANNAH_WEB_ADMIN is set
if (this.config.auth && this.config.adminPassword) {
const auth = req.headers.authorization ?? "";
let valid = false;
if (auth.startsWith("Basic ")) {
try {
const decoded = Buffer.from(auth.slice(6), "base64").toString("utf-8");
// Accept any username, just check password
const password = decoded.includes(":") ? decoded.slice(decoded.indexOf(":") + 1) : decoded;
valid = password === this.config.adminPassword;
} catch {}
}
if (!valid) {
res.writeHead(401, {
"Content-Type": "text/plain",
"WWW-Authenticate": 'Basic realm="Hannah Dashboard"',
});
res.end("Unauthorized");
return;
}
}
try {
// Check custom routes first — feature modules register via addRoute()
const routeKey = `${req.method?.toUpperCase()} ${pathname}`;
const customHandler = this.customRoutes.get(routeKey);
if (customHandler) {
customHandler(req, res, url);
return;
}
if (pathname === "/" || pathname === "/index.html") {
this.serveDashboard(res);
} else if (pathname === "/events") {
this.serveSSE(req, res);
} else if (pathname === "/api/stats") {
this.json(res, jobsApi.getTotals());
} else if (pathname === "/api/jobs") {
const limit = parseInt(url.searchParams.get("limit") ?? "50");
const channel = url.searchParams.get("channel") ?? undefined;
this.json(res, jobsApi.getRecentJobs(limit, channel));
} else if (pathname.startsWith("/api/jobs/")) {
const id = pathname.slice("/api/jobs/".length);
const job = jobsApi.getJob(id);
if (!job) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Job not found" }));
return;
}
this.json(res, { job, toolCalls: jobsApi.getJobToolCalls(id) });
} else if (pathname === "/api/daily") {
this.json(res, jobsApi.getDailyStats());
} else if (pathname === "/api/models") {
this.json(res, jobsApi.getModelBreakdown());
} else if (pathname === "/api/tools") {
this.json(res, jobsApi.getToolBreakdown());
} else if (pathname === "/api/prompt" && req.method === "POST") {
this.handlePrompt(req, res);
return;
} else if (pathname === "/api/prompt" && req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.writeHead(204);
res.end();
return;
} else if (pathname === "/api/sections") {
this.json(res, this.dashboardSections);
} else if (pathname === "/api/commands") {
this.json(res, this.server.getCommands());
} else if (pathname === "/api/config") {
this.json(res, {
...this.server.config,
startedAt: this.startedAt.toISOString(),
uptime: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
sseClients: this.sseClients.size,
});
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
}
} catch (err: any) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
}
// ── Prompt handler ───────────────────────────────────────────
private handlePrompt(req: http.IncomingMessage, res: http.ServerResponse): void {
// Rate limiting — 10 requests per minute per IP
const clientIp = req.socket.remoteAddress ?? "unknown";
if (!this.promptLimiter.isAllowed(clientIp)) {
res.writeHead(429, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Too many requests. Max 10 per minute." }));
return;
}
// Token auth — only enforced when auth is enabled
if (this.config.auth) {
if (!this.config.token) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Prompt endpoint disabled. Set HANNAH_WEB_PASSWORD to enable." }));
return;
}
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (token !== this.config.token) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid or missing token" }));
return;
}
}
const MAX_BODY_SIZE = 1_048_576; // 1 MB
let body = "";
let oversized = false;
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
if (body.length > MAX_BODY_SIZE) {
oversized = true;
req.destroy();
}
});
req.on("end", async () => {
if (oversized) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request body too large (max 1MB)" }));
return;
}
try {
const { prompt } = JSON.parse(body);
if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing prompt" }));
return;
}
// Acknowledge immediately — client can watch SSE for job_created
// to get the real job ID, or poll /api/jobs for recent jobs.
res.writeHead(202, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "accepted" }));
// Run in background
this.server.runJob({
channel: "web",
chatId: "web-dashboard",
prompt: prompt.trim(),
}).catch((err) => {
console.error("[web] prompt job failed:", err);
});
} catch (err: any) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
});
}
// ── SSE ─────────────────────────────────────────────────────
private serveSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// Send initial connected event
res.write(`data: ${JSON.stringify({ type: "connected", time: new Date().toISOString() })}\n\n`);
this.sseClients.add(res);
req.on("close", () => {
this.sseClients.delete(res);
});
}
private broadcast(data: any): void {
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const client of this.sseClients) {
try { client.write(payload); } catch {}
}
}
// ── Helpers ──────────────────────────────────────────────────
private json(res: http.ServerResponse, data: any): void {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
private summarizeJob(job: JobRecord): any {
return {
id: job.id,
channel: job.channel,
status: job.status,
model: job.model,
prompt: job.prompt.slice(0, 200),
total_tokens: job.total_tokens,
cost_total: job.cost_total,
tool_call_count: job.tool_call_count,
duration_ms: job.duration_ms,
error_message: job.error_message,
created_at: job.created_at,
finished_at: job.finished_at,
};
}
// ── Dashboard HTML ──────────────────────────────────────────
private serveDashboard(res: http.ServerResponse): void {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(getDashboardHtml());
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment