Scaffolda tunn vertikal slice: pnpm-monorepo (shared/daemon/server/web)
- shared: typat WebSocket-protokoll (daemon/server/web som enda sanning) - daemon: node-pty kör agenten (default gemini), strömmar pty över WS, auto-reconnect; fast pty-storlek - server: WS-mux + token-auth + session-registry + headless-xterm scrollback (SerializeAddon) + routing av input till rätt session - web: Vue 3 + Vite + xterm.js, session-lista, mobil-layout och fallback-tangentbord (piltangenter/Enter/Esc/Tab/Ctrl-C) som skickar escape-sekvenser rakt till pty:n -> styr sessionen även utan hooks - workspace: pnpm-workspace.yaml (onlyBuiltDependencies: node-pty, esbuild), tsconfig.base, .npmrc (retry-inställningar), .env.example Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
packages/server/src/index.ts
Normal file
204
packages/server/src/index.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* agent-helm control-plane (körs på Pi5).
|
||||
*
|
||||
* - WebSocket-mux: både daemons och frontends ansluter hit. Rollen avgörs av
|
||||
* det första meddelandet (daemon:hello / web:hello), båda kräver rätt token.
|
||||
* - Session-registry: håller reda på vilken daemon som äger vilken session.
|
||||
* - Scrollback: en headless xterm per session matas med all output, så en
|
||||
* frontend som ansluter (eller en mobil som väcks) får en ögonblicksbild via
|
||||
* SerializeAddon — inte en tom skärm.
|
||||
* - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { Terminal } from "@xterm/headless";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type {
|
||||
DaemonHello,
|
||||
DaemonMessage,
|
||||
ServerToWeb,
|
||||
SessionMeta,
|
||||
WebMessage,
|
||||
} from "@agent-helm/shared";
|
||||
import { parseMessage } from "@agent-helm/shared";
|
||||
|
||||
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error("[server] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface Session {
|
||||
meta: SessionMeta;
|
||||
daemon: WebSocket | null;
|
||||
term: Terminal;
|
||||
serializer: SerializeAddon;
|
||||
subscribers: Set<WebSocket>;
|
||||
}
|
||||
|
||||
type Role =
|
||||
| { kind: "pending" }
|
||||
| { kind: "daemon"; sessionId: string }
|
||||
| { kind: "web" };
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
const webClients = new Set<WebSocket>();
|
||||
const roles = new WeakMap<WebSocket, Role>();
|
||||
|
||||
function sessionList(): SessionMeta[] {
|
||||
return [...sessions.values()].map((s) => s.meta);
|
||||
}
|
||||
|
||||
function sendWeb(ws: WebSocket, msg: ServerToWeb): void {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function broadcastSessions(): void {
|
||||
const msg: ServerToWeb = { type: "server:sessions", sessions: sessionList() };
|
||||
for (const ws of webClients) sendWeb(ws, msg);
|
||||
}
|
||||
|
||||
function closeWithError(ws: WebSocket, message: string): void {
|
||||
sendWeb(ws, { type: "server:error", message });
|
||||
ws.close();
|
||||
}
|
||||
|
||||
function registerDaemon(ws: WebSocket, hello: DaemonHello): void {
|
||||
roles.set(ws, { kind: "daemon", sessionId: hello.sessionId });
|
||||
|
||||
const existing = sessions.get(hello.sessionId);
|
||||
if (existing) {
|
||||
// Daemonen återansluter — behåll scrollback, byt ut socketen.
|
||||
existing.daemon = ws;
|
||||
existing.meta.status = "running";
|
||||
} else {
|
||||
const term = new Terminal({ cols: hello.cols, rows: hello.rows, allowProposedApi: true });
|
||||
const serializer = new SerializeAddon();
|
||||
term.loadAddon(serializer);
|
||||
sessions.set(hello.sessionId, {
|
||||
meta: {
|
||||
sessionId: hello.sessionId,
|
||||
title: hello.title,
|
||||
status: "running",
|
||||
cols: hello.cols,
|
||||
rows: hello.rows,
|
||||
},
|
||||
daemon: ws,
|
||||
term,
|
||||
serializer,
|
||||
subscribers: new Set(),
|
||||
});
|
||||
}
|
||||
console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.title})`);
|
||||
broadcastSessions();
|
||||
}
|
||||
|
||||
function handleHello(ws: WebSocket, text: string): void {
|
||||
const msg = parseMessage<DaemonMessage | WebMessage>(text);
|
||||
if (!msg) return closeWithError(ws, "ogiltigt meddelande");
|
||||
|
||||
if (msg.type === "daemon:hello") {
|
||||
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
|
||||
return registerDaemon(ws, msg);
|
||||
}
|
||||
if (msg.type === "web:hello") {
|
||||
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
|
||||
roles.set(ws, { kind: "web" });
|
||||
webClients.add(ws);
|
||||
sendWeb(ws, { type: "server:welcome" });
|
||||
sendWeb(ws, { type: "server:sessions", sessions: sessionList() });
|
||||
return;
|
||||
}
|
||||
return closeWithError(ws, "förväntade hello");
|
||||
}
|
||||
|
||||
function handleDaemonMessage(sessionId: string, text: string): void {
|
||||
const msg = parseMessage<DaemonMessage>(text);
|
||||
if (!msg) return;
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (msg.type === "daemon:output") {
|
||||
session.term.write(msg.data);
|
||||
const out: ServerToWeb = { type: "server:output", sessionId, data: msg.data };
|
||||
for (const sub of session.subscribers) sendWeb(sub, out);
|
||||
} else if (msg.type === "daemon:exit") {
|
||||
session.meta.status = "exited";
|
||||
session.meta.exitCode = msg.code;
|
||||
for (const sub of session.subscribers) {
|
||||
sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
|
||||
}
|
||||
broadcastSessions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebMessage(ws: WebSocket, text: string): void {
|
||||
const msg = parseMessage<WebMessage>(text);
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === "web:subscribe") {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (!session) return sendWeb(ws, { type: "server:error", message: "okänd session" });
|
||||
session.subscribers.add(ws);
|
||||
sendWeb(ws, {
|
||||
type: "server:snapshot",
|
||||
sessionId: msg.sessionId,
|
||||
data: session.serializer.serialize(),
|
||||
meta: session.meta,
|
||||
});
|
||||
} else if (msg.type === "web:input") {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return;
|
||||
// Strukturerade hook-svar går INTE här — bara terminal-input (text/tangenter).
|
||||
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose(ws: WebSocket): void {
|
||||
const role = roles.get(ws);
|
||||
roles.delete(ws);
|
||||
if (!role) return;
|
||||
|
||||
if (role.kind === "web") {
|
||||
webClients.delete(ws);
|
||||
for (const s of sessions.values()) s.subscribers.delete(ws);
|
||||
} else if (role.kind === "daemon") {
|
||||
const session = sessions.get(role.sessionId);
|
||||
if (session && session.daemon === ws) {
|
||||
session.daemon = null; // behåll session + scrollback ifall daemonen kommer tillbaka
|
||||
console.log(`[server] daemon för ${role.sessionId} kopplade ner`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
roles.set(ws, { kind: "pending" });
|
||||
ws.on("message", (raw) => {
|
||||
const role = roles.get(ws);
|
||||
if (!role) return;
|
||||
const text = raw.toString();
|
||||
if (role.kind === "pending") handleHello(ws, text);
|
||||
else if (role.kind === "daemon") handleDaemonMessage(role.sessionId, text);
|
||||
else handleWebMessage(ws, text);
|
||||
});
|
||||
ws.on("close", () => handleClose(ws));
|
||||
ws.on("error", () => {});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`);
|
||||
});
|
||||
Reference in New Issue
Block a user