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,90 @@
import { ref } from "vue";
import type { ServerToWeb, SessionMeta, WebMessage } from "@agent-helm/shared";
/** Callbacks som terminal-vyn registrerar för den session den visar. */
export interface HelmHandlers {
onSnapshot?: (sessionId: string, data: string, meta: SessionMeta) => void;
onOutput?: (sessionId: string, data: string) => void;
onExit?: (sessionId: string, code: number) => void;
}
export function createHelmClient() {
const connected = ref(false);
const connecting = ref(false);
const error = ref<string | null>(null);
const sessions = ref<SessionMeta[]>([]);
const handlers: HelmHandlers = {};
let ws: WebSocket | null = null;
function send(msg: WebMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(url: string, token: string): void {
error.value = null;
connecting.value = true;
ws = new WebSocket(url);
ws.onopen = () => send({ type: "web:hello", token });
ws.onmessage = (ev) => {
let msg: ServerToWeb;
try {
msg = JSON.parse(ev.data as string) as ServerToWeb;
} catch {
return;
}
switch (msg.type) {
case "server:welcome":
connected.value = true;
connecting.value = false;
break;
case "server:sessions":
sessions.value = msg.sessions;
break;
case "server:snapshot":
handlers.onSnapshot?.(msg.sessionId, msg.data, msg.meta);
break;
case "server:output":
handlers.onOutput?.(msg.sessionId, msg.data);
break;
case "server:exit":
sessions.value = sessions.value.map((s) =>
s.sessionId === msg.sessionId ? { ...s, status: "exited", exitCode: msg.code } : s,
);
handlers.onExit?.(msg.sessionId, msg.code);
break;
case "server:error":
error.value = msg.message;
connecting.value = false;
break;
}
};
ws.onclose = () => {
connected.value = false;
connecting.value = false;
};
ws.onerror = () => {
error.value = "Kunde inte ansluta till servern.";
connecting.value = false;
};
}
function subscribe(sessionId: string): void {
send({ type: "web:subscribe", sessionId });
}
function sendInput(sessionId: string, data: string): void {
send({ type: "web:input", sessionId, data });
}
function disconnect(): void {
ws?.close();
ws = null;
connected.value = false;
}
return { connected, connecting, error, sessions, handlers, connect, disconnect, subscribe, sendInput };
}
export type HelmClient = ReturnType<typeof createHelmClient>;