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,101 @@
/**
* agent-helm daemon ("appen").
*
* Spawnar en agent (default: gemini) i en pty, strömmar pty-output utåt till
* control-plane-servern över WebSocket, och skriver tillbaka input som kommer
* från servern till pty:ns stdin. Återansluter automatiskt om servern går ner.
*
* Persistens (att daemonen själv överlever) är ett yttre lager: kör den i tmux
* eller som en Docker-container med restart-policy. Det hanteras inte här.
*/
import { spawn, type IPty } from "node-pty";
import { WebSocket } from "ws";
import { randomUUID } from "node:crypto";
import os from "node:os";
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
import { parseMessage } from "@agent-helm/shared";
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787";
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`;
// Fast pty-storlek: undviker resize-churn när olika klienter (mobil/desktop)
// tittar samtidigt. Frontenden skalar/scrollar istället för att reflowa.
const COLS = 100;
const ROWS = 30;
if (!TOKEN) {
console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
process.exit(1);
}
const sessionId = randomUUID();
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function send(msg: DaemonMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(): void {
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
ws = new WebSocket(SERVER_URL);
ws.on("open", () => {
console.log("[daemon] ansluten — registrerar session");
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
});
ws.on("message", (raw) => {
const msg = parseMessage<ServerToDaemon>(raw.toString());
if (msg?.type === "server:input") {
pty.write(msg.data);
}
});
ws.on("close", () => {
console.log("[daemon] anslutning stängd — återansluter om 2s");
scheduleReconnect();
});
ws.on("error", (err: Error) => {
console.error("[daemon] ws-fel:", err.message);
});
}
function scheduleReconnect(): void {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 2000);
}
pty.onData((data) => send({ type: "daemon:output", data }));
pty.onExit(({ exitCode }) => {
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
send({ type: "daemon:exit", code: exitCode });
setTimeout(() => process.exit(exitCode), 200);
});
process.on("SIGINT", () => {
pty.kill();
process.exit(0);
});
connect();