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:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Delad token som BÅDE daemon och frontend måste skicka för att få ansluta till servern.
|
||||
# Byt till något hemligt — den här skyddar fjärrstyrning av en agent som kan köra shell.
|
||||
AGENT_HELM_TOKEN=change-me
|
||||
|
||||
# --- server ---
|
||||
PORT=8787
|
||||
|
||||
# --- daemon ---
|
||||
SERVER_URL=ws://localhost:8787
|
||||
# Kommandot som körs i pty:n. Default: gemini. Sätt t.ex. AGENT_CMD=bash för att testa pipelinen.
|
||||
AGENT_CMD=gemini
|
||||
# AGENT_ARGS=
|
||||
# AGENT_CWD=
|
||||
# AGENT_TITLE=
|
||||
|
||||
# --- frontend (Vite läser bara VITE_*-prefix) ---
|
||||
VITE_SERVER_WS=ws://localhost:8787
|
||||
VITE_AGENT_HELM_TOKEN=change-me
|
||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@@ -0,0 +1,5 @@
|
||||
# Tåligare mot flakigt nätverk vid install (parsas till nummer av pnpm).
|
||||
# OBS: sätt INTE fetch-timeout via --config på CLI:n — då skickas värdet som
|
||||
# sträng och kraschar AbortSignal.timeout(). pnpm:s default-timeout är ok.
|
||||
fetch-retries=5
|
||||
network-concurrency=8
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "agent-helm",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:server": "pnpm --filter @agent-helm/server dev",
|
||||
"dev:daemon": "pnpm --filter @agent-helm/daemon dev",
|
||||
"dev:web": "pnpm --filter @agent-helm/web dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r --if-present typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
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"]
|
||||
}
|
||||
24
packages/server/package.json
Normal file
24
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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)`);
|
||||
});
|
||||
12
packages/server/tsconfig.json
Normal file
12
packages/server/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"]
|
||||
}
|
||||
18
packages/shared/package.json
Normal file
18
packages/shared/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@agent-helm/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
125
packages/shared/src/index.ts
Normal file
125
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Delat WebSocket-protokoll mellan daemon, server och frontend.
|
||||
* Allt skickas som JSON-strängar. Typerna nedan är enda källan till sanning
|
||||
* för meddelandeformaten — importeras av alla tre paketen.
|
||||
*/
|
||||
|
||||
export type SessionStatus = "running" | "exited";
|
||||
|
||||
export interface SessionMeta {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
status: SessionStatus;
|
||||
cols: number;
|
||||
rows: number;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
/* ----------------------------- Daemon -> Server ---------------------------- */
|
||||
|
||||
export interface DaemonHello {
|
||||
type: "daemon:hello";
|
||||
token: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface DaemonOutput {
|
||||
type: "daemon:output";
|
||||
/** Rå pty-output (utf8). */
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface DaemonExit {
|
||||
type: "daemon:exit";
|
||||
code: number;
|
||||
}
|
||||
|
||||
export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit;
|
||||
|
||||
/* ----------------------------- Server -> Daemon ---------------------------- */
|
||||
|
||||
export interface ServerInput {
|
||||
type: "server:input";
|
||||
/** Text eller escape-sekvenser som ska skrivas till pty:ns stdin. */
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type ServerToDaemon = ServerInput;
|
||||
|
||||
/* ---------------------------- Frontend -> Server --------------------------- */
|
||||
|
||||
export interface WebHello {
|
||||
type: "web:hello";
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface WebSubscribe {
|
||||
type: "web:subscribe";
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface WebInput {
|
||||
type: "web:input";
|
||||
sessionId: string;
|
||||
/** Text eller escape-sekvens (piltangenter, Enter, Ctrl-C ...). */
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type WebMessage = WebHello | WebSubscribe | WebInput;
|
||||
|
||||
/* ---------------------------- Server -> Frontend --------------------------- */
|
||||
|
||||
export interface ServerWelcome {
|
||||
type: "server:welcome";
|
||||
}
|
||||
|
||||
export interface ServerSessions {
|
||||
type: "server:sessions";
|
||||
sessions: SessionMeta[];
|
||||
}
|
||||
|
||||
export interface ServerSnapshot {
|
||||
type: "server:snapshot";
|
||||
sessionId: string;
|
||||
/** Serialiserad terminal-state (scrollback) så en ny klient ser var den är. */
|
||||
data: string;
|
||||
meta: SessionMeta;
|
||||
}
|
||||
|
||||
export interface ServerOutput {
|
||||
type: "server:output";
|
||||
sessionId: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface ServerSessionExit {
|
||||
type: "server:exit";
|
||||
sessionId: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface ServerError {
|
||||
type: "server:error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ServerToWeb =
|
||||
| ServerWelcome
|
||||
| ServerSessions
|
||||
| ServerSnapshot
|
||||
| ServerOutput
|
||||
| ServerSessionExit
|
||||
| ServerError;
|
||||
|
||||
/* --------------------------------- Helpers --------------------------------- */
|
||||
|
||||
export function parseMessage<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
packages/web/index.html
Normal file
12
packages/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>agent-helm</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
packages/web/package.json
Normal file
25
packages/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@agent-helm/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-helm/shared": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
209
packages/web/src/App.vue
Normal file
209
packages/web/src/App.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { createHelmClient } from "./lib/helm";
|
||||
import TerminalView from "./components/TerminalView.vue";
|
||||
|
||||
const defaultUrl = import.meta.env.VITE_SERVER_WS ?? "ws://localhost:8787";
|
||||
const defaultToken = import.meta.env.VITE_AGENT_HELM_TOKEN ?? "";
|
||||
|
||||
const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
|
||||
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
|
||||
|
||||
const client = createHelmClient();
|
||||
const { connected, connecting, error, sessions } = client;
|
||||
|
||||
const selected = ref<string | null>(null);
|
||||
const sidebarOpen = ref(true);
|
||||
|
||||
const activeMeta = computed(
|
||||
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
|
||||
);
|
||||
|
||||
function connect(): void {
|
||||
localStorage.setItem("helm.url", url.value);
|
||||
localStorage.setItem("helm.token", token.value);
|
||||
client.connect(url.value, token.value);
|
||||
}
|
||||
|
||||
function select(id: string): void {
|
||||
selected.value = id;
|
||||
if (window.matchMedia("(max-width: 720px)").matches) sidebarOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Inloggning -->
|
||||
<div v-if="!connected" class="login">
|
||||
<div class="card">
|
||||
<h1>agent-helm</h1>
|
||||
<p class="sub">Fjärrstyr dina agent-sessioner</p>
|
||||
<label>
|
||||
Server (WebSocket)
|
||||
<input v-model="url" autocapitalize="off" autocorrect="off" spellcheck="false" />
|
||||
</label>
|
||||
<label>
|
||||
Token
|
||||
<input v-model="token" type="password" placeholder="delad token" @keyup.enter="connect" />
|
||||
</label>
|
||||
<button class="primary" :disabled="connecting" @click="connect">
|
||||
{{ connecting ? "Ansluter…" : "Anslut" }}
|
||||
</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Huvudvy -->
|
||||
<div v-else class="app">
|
||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||
<header>
|
||||
<span class="brand">agent-helm</span>
|
||||
<span class="count">{{ sessions.length }}</span>
|
||||
</header>
|
||||
<ul>
|
||||
<li
|
||||
v-for="s in sessions"
|
||||
:key="s.sessionId"
|
||||
:class="{ active: s.sessionId === selected, exited: s.status === 'exited' }"
|
||||
@click="select(s.sessionId)"
|
||||
>
|
||||
<span class="dot" :class="s.status"></span>
|
||||
<span class="title">{{ s.title }}</span>
|
||||
<span v-if="s.status === 'exited'" class="badge">exit {{ s.exitCode }}</span>
|
||||
</li>
|
||||
<li v-if="sessions.length === 0" class="empty">Inga anslutna sessioner än</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="stage">
|
||||
<button class="toggle" @click="sidebarOpen = !sidebarOpen" aria-label="Sessioner">☰</button>
|
||||
<TerminalView
|
||||
v-if="selected && activeMeta"
|
||||
:key="selected"
|
||||
:client="client"
|
||||
:session-id="selected"
|
||||
:meta="activeMeta"
|
||||
/>
|
||||
<div v-else class="placeholder">Välj en session till vänster</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Inloggning */
|
||||
.login {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
.card {
|
||||
width: min(380px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
.card h1 { margin: 0; font-size: 1.4rem; }
|
||||
.card .sub { margin: -0.6rem 0 0.4rem; color: var(--muted); font-size: 0.9rem; }
|
||||
.card label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem; color: var(--muted); }
|
||||
.card input {
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.primary {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary:disabled { opacity: 0.6; }
|
||||
.err { color: var(--red); font-size: 0.85rem; margin: 0; }
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; height: 100%; }
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex: 0 0 260px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar .brand { font-weight: 700; }
|
||||
.sidebar .count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.5rem;
|
||||
}
|
||||
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; }
|
||||
.sidebar li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.65rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.sidebar li:hover { background: var(--panel-2); }
|
||||
.sidebar li.active { background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.sidebar li.exited .title { color: var(--muted); }
|
||||
.sidebar li .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar li .badge { font-size: 0.68rem; color: var(--red); }
|
||||
.sidebar li.empty { color: var(--muted); cursor: default; justify-content: center; padding: 1.2rem 0; }
|
||||
.sidebar li.empty:hover { background: none; }
|
||||
|
||||
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--muted); flex: none; }
|
||||
.dot.running { background: var(--green); }
|
||||
.dot.exited { background: var(--red); }
|
||||
|
||||
.stage { flex: 1; min-width: 0; position: relative; display: flex; }
|
||||
.stage > .terminal-view { flex: 1; }
|
||||
.toggle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 5;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.placeholder { margin: auto; color: var(--muted); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.toggle { display: block; }
|
||||
}
|
||||
</style>
|
||||
176
packages/web/src/components/TerminalView.vue
Normal file
176
packages/web/src/components/TerminalView.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, useTemplateRef } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { SessionMeta } from "@agent-helm/shared";
|
||||
import type { HelmClient } from "../lib/helm";
|
||||
|
||||
const props = defineProps<{ client: HelmClient; sessionId: string; meta: SessionMeta }>();
|
||||
|
||||
const host = useTemplateRef<HTMLDivElement>("host");
|
||||
let term: Terminal | null = null;
|
||||
let fit: FitAddon | null = null;
|
||||
|
||||
// Fallback-navigering: skickar escape-sekvenser rakt till pty:n så att agentens
|
||||
// egna TUI-prompts kan besvaras helt utan hooks.
|
||||
const KEYS: Record<string, string> = {
|
||||
up: "\x1b[A",
|
||||
down: "\x1b[B",
|
||||
right: "\x1b[C",
|
||||
left: "\x1b[D",
|
||||
enter: "\r",
|
||||
esc: "\x1b",
|
||||
tab: "\t",
|
||||
ctrlc: "\x03",
|
||||
y: "y",
|
||||
n: "n",
|
||||
};
|
||||
|
||||
function key(name: keyof typeof KEYS): void {
|
||||
props.client.sendInput(props.sessionId, KEYS[name]);
|
||||
term?.focus();
|
||||
}
|
||||
|
||||
function onResize(): void {
|
||||
fit?.fit();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
term = new Terminal({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
theme: { background: "#0b0e14", foreground: "#d6dcea" },
|
||||
});
|
||||
fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(host.value!);
|
||||
fit.fit();
|
||||
|
||||
// Fysiskt tangentbord -> pty (samma kanal som fallback-knapparna).
|
||||
term.onData((d) => props.client.sendInput(props.sessionId, d));
|
||||
|
||||
props.client.handlers.onSnapshot = (sid, data) => {
|
||||
if (sid !== props.sessionId || !term) return;
|
||||
term.reset();
|
||||
term.write(data);
|
||||
};
|
||||
props.client.handlers.onOutput = (sid, data) => {
|
||||
if (sid === props.sessionId) term?.write(data);
|
||||
};
|
||||
|
||||
props.client.subscribe(props.sessionId);
|
||||
window.addEventListener("resize", onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
props.client.handlers.onSnapshot = undefined;
|
||||
props.client.handlers.onOutput = undefined;
|
||||
term?.dispose();
|
||||
term = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-view">
|
||||
<div class="bar">
|
||||
<span class="title">{{ meta.title }}</span>
|
||||
<span class="status" :class="meta.status">{{ meta.status }}</span>
|
||||
</div>
|
||||
|
||||
<div ref="host" class="xterm-host"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="group arrows">
|
||||
<button @click="key('left')" aria-label="Vänster">←</button>
|
||||
<button @click="key('up')" aria-label="Upp">↑</button>
|
||||
<button @click="key('down')" aria-label="Ner">↓</button>
|
||||
<button @click="key('right')" aria-label="Höger">→</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button @click="key('enter')">⏎</button>
|
||||
<button @click="key('esc')">Esc</button>
|
||||
<button @click="key('tab')">Tab</button>
|
||||
<button @click="key('ctrlc')">^C</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button class="yn" @click="key('y')">y</button>
|
||||
<button class="yn" @click="key('n')">n</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.bar .title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar .status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.bar .status.running { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); }
|
||||
.bar .status.exited { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
||||
|
||||
.xterm-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: #0b0e14;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.group { display: flex; gap: 0.3rem; }
|
||||
.toolbar button {
|
||||
min-width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
padding: 0 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.toolbar button:active { background: var(--accent); color: #06121f; }
|
||||
.toolbar .yn { font-weight: 700; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.toolbar { gap: 0.35rem 0.5rem; }
|
||||
.toolbar button { min-width: 2.7rem; height: 2.7rem; }
|
||||
}
|
||||
</style>
|
||||
90
packages/web/src/lib/helm.ts
Normal file
90
packages/web/src/lib/helm.ts
Normal 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>;
|
||||
5
packages/web/src/main.ts
Normal file
5
packages/web/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
31
packages/web/src/style.css
Normal file
31
packages/web/src/style.css
Normal file
@@ -0,0 +1,31 @@
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--panel: #11151f;
|
||||
--panel-2: #161b29;
|
||||
--border: #232a3b;
|
||||
--text: #d6dcea;
|
||||
--muted: #8a93a8;
|
||||
--accent: #5aa6ff;
|
||||
--green: #43d17a;
|
||||
--red: #ff6b6b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
1
packages/web/src/vite-env.d.ts
vendored
Normal file
1
packages/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
14
packages/web/tsconfig.json
Normal file
14
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@agent-helm/shared": ["../shared/src/index.ts"]
|
||||
},
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
packages/web/vite.config.ts
Normal file
22
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Pekar @agent-helm/shared direkt på TS-källan och låter Vite transpilera den
|
||||
// som vanlig app-kod (slipper bygg-steg för shared i den tunna slicen).
|
||||
const sharedSrc = fileURLToPath(new URL("../shared/src/index.ts", import.meta.url));
|
||||
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
envDir: repoRoot,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@agent-helm/shared": sharedSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
fs: { allow: [repoRoot] },
|
||||
},
|
||||
});
|
||||
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
|
||||
# pnpm 10+ blockar native build-scripts som standard. Tillåt dem vi behöver:
|
||||
# node-pty kompilerar sin binär, esbuild hämtar sin plattformsbinär (vite/tsx).
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- node-pty
|
||||
14
tsconfig.base.json
Normal file
14
tsconfig.base.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user