feat(maestro): web client (Vite + TS + xterm.js)

NAP-218. Browser UI for the prototype: catalog tree derived from
`metadata.label`, live updates over `WS /api/events`, search across
session name+label, spawn modal (with optional git worktree), and an
attach view that opens the daemon's per-session WS for xterm.js.

Framework choice: vanilla TypeScript. The interactivity surface is
small enough that pulling in a framework wouldn't pay back; xterm.js
does the heavy lifting. Easy to swap later without protocol impact.

Hash-based routing (`#/`, `#/attach/<id>`) keeps the registry's
static surface dumb — no SPA fallback needed; concurrent attaches
work by opening the same hash URL in another tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:57:11 +00:00
co-authored by Claude Opus 4.7
parent b68e39a9e5
commit e68c9b6d73
16 changed files with 2188 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import type {
ApiError,
ConnectInfo,
CreateSessionRequest,
DaemonInfo,
Session,
Uuid,
} from "./types.js";
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T;
}
let detail = "";
try {
const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`;
} catch {
detail = await res.text();
}
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
export async function listDaemons(): Promise<DaemonInfo[]> {
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
}
export async function listSessions(): Promise<Session[]> {
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
}
export async function createSession(req: CreateSessionRequest): Promise<Session> {
return jsonOrError(
await fetch("/api/sessions", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
}),
);
}
export async function killSession(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
}),
);
}
export async function getConnectInfo(id: Uuid): Promise<ConnectInfo> {
return jsonOrError(
await fetch(`/api/sessions/${encodeURIComponent(id)}/connect-info`, {
credentials: "same-origin",
}),
);
}
export function eventsWsUrl(): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/events`;
}