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:
23
packages/daemon/package.json
Normal file
23
packages/daemon/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@agent-helm/daemon",
|
||||
"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:*",
|
||||
"node-pty": "^1.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
101
packages/daemon/src/index.ts
Normal file
101
packages/daemon/src/index.ts
Normal 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();
|
||||
12
packages/daemon/tsconfig.json
Normal file
12
packages/daemon/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user