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:
2026-06-28 14:28:04 +02:00
parent 0c75d94812
commit c58f73626e
24 changed files with 1173 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{
"name": "@agent-helm/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx --env-file-if-exists=../../.env watch src/index.ts",
"start": "tsx --env-file-if-exists=../../.env src/index.ts",
"build": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@agent-helm/shared": "workspace:*",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/headless": "^5.5.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.12",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View 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)`);
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@agent-helm/shared": ["../shared/src/index.ts"]
}
},
"include": ["src"]
}