From 98c4d13360141747b79190dbd6ecb1517215c9d6 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Sun, 28 Jun 2026 18:03:12 +0200 Subject: [PATCH] =?UTF-8?q?BeforeTool-hook=20+=20kort-vy=20(strukturerade?= =?UTF-8?q?=20godk=C3=A4nnanden)=20+=20release=20p=C3=A5=20master-push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature: strukturerade verktygsgodkännanden - shared: protokoll för approval (daemon:approval, server:approval[-resolved], web:approval-decision, server:approval-decision) - daemon: hook-läge ('agent-helm hook' — samma binär), lokal 127.0.0.1-endpoint som hooken POST:ar till, long-poll tills beslut; graceful default (allow) när ingen UI; timeout-fallback; skickar AGENT_HELM_HOOK_PORT till agentens env - server: routar daemon:approval -> prenumeranter, beslut -> daemon + döljer kort - web: reaktiv approvals-lista + kort-overlay (Tillåt/Neka), mobilanpassad CI: - release.yaml triggar nu även på master-push -> rullande 'latest'-release (versionerade releaser kvar på v*-taggar); install.sh hämtar 'latest' Docs: hook-konfig (gemini settings.json) + hook-env i .env.example + DEPLOY.md. Säkerhet: hook-endpoint binder endast 127.0.0.1; inga hemligheter i repot. Verifierat lokalt: hook->daemon->server->kort->beslut->hook ger {"decision":"allow"}; alla paket typecheckar; web bygger. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 + .gitea/workflows/release.yaml | 14 +- deploy/DEPLOY.md | 34 +++- install.sh | 6 +- packages/daemon/src/index.ts | 295 +++++++++++++++++++++++++--------- packages/server/src/index.ts | 32 +++- packages/shared/src/index.ts | 52 +++++- packages/web/src/App.vue | 92 ++++++++++- packages/web/src/lib/helm.ts | 27 +++- 9 files changed, 460 insertions(+), 97 deletions(-) diff --git a/.env.example b/.env.example index 5caee1f..01ae52f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ AGENT_CMD=gemini # AGENT_CWD= # AGENT_TITLE= +# --- hook (valfritt: strukturerade verktygsgodkännanden via BeforeTool) --- +# AGENT_HELM_HOOK_PORT=8788 # lokal 127.0.0.1-endpoint som `agent-helm hook` anropar +# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny) +# AGENT_HELM_HOOK_TIMEOUT=120000 # ms att vänta på beslut innan default används + # --- frontend (Vite läser bara VITE_*-prefix) --- VITE_SERVER_WS=ws://localhost:8787 VITE_AGENT_HELM_TOKEN=change-me diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 4ef16a8..e3a3680 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -6,12 +6,13 @@ name: release-client # dem som assets på en Gitea-release. on: push: - tags: ["v*"] + branches: [master, main] # varje master-push -> rullande 'latest'-release + tags: ["v*"] # vX.Y.Z -> versionerad release workflow_dispatch: inputs: tag: - description: "Release-tag (t.ex. v0.1.0)" - required: true + description: "Release-tag (lämna tom för 'latest')" + required: false jobs: build-release: @@ -23,7 +24,12 @@ jobs: - name: Bestäm tag id: t run: | - if [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"; else echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"; fi + case "$GITHUB_REF" in + refs/tags/*) T="$GITHUB_REF_NAME" ;; + *) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;; + esac + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "bygger release: $T" - name: Bundla daemonen run: | diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index cd62f3a..85cda9c 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -94,8 +94,10 @@ curl -s http://localhost:5000/v2/agent-helm-server/tags/list containern kör som uid 1000 = `brasse` så filerna blir korrekt ägda. 2. Ny stack i Dockge, namn `agent-helm`. 3. Klistra in `deploy/agent-helm.compose.yaml`. -4. I stackens **env**: `AGENT_HELM_TOKEN=` - (generera t.ex. `openssl rand -hex 32`). +4. I stackens **env**: `AGENT_HELM_TOKEN=` + — en stark slumpad sträng (≥ 32 byte). Förvaras **bara** i stackens `.env` + på Pi5, aldrig i git. Det är denna token som skyddar åtkomsten till dina + sessioner, så håll den hemlig. 5. Deploy. Kolla i Dockge att containern `agent-helm` är *running*. --- @@ -164,6 +166,34 @@ systemctl --user daemon-reload && systemctl --user enable --now agent-helm ``` Klienten ansluter till `wss://rcai.brasse-pc.eu` och dyker upp som en session i UI:t. +## Strukturerade godkännanden (BeforeTool-hook + kort-vy) + +Utöver fallback-tangentbordet kan agentens verktygsanrop visas som **godkännande-kort** +i UI:t (Tillåt/Neka). Det sker via en `BeforeTool`-hook i Gemini som anropar +`agent-helm hook` — samma binär, så inget extra beroende. + +**Aktivera** genom att lägga till hooken i `~/.gemini/settings.json` på maskinen där +klienten kör (justera vid behov efter din Gemini-versions hook-schema): +```json +{ + "hooks": { + "BeforeTool": [ + { "hooks": [{ "type": "command", "command": "agent-helm hook" }] } + ] + } +} +``` +Daemonen skickar automatiskt `AGENT_HELM_HOOK_PORT` till agentens miljö, så +`agent-helm hook` hittar den lokala endpointen själv. + +**Beteende:** +- UI anslutet → verktygsanropet visas som kort, agenten väntar på ditt beslut. +- Ingen UI / servern onåbar → `AGENT_HELM_HOOK_DEFAULT` (default `allow` — agenten + fungerar som utan hook). Sätt `AGENT_HELM_HOOK_DEFAULT=deny` för strikt läge. +- Inget svar inom `AGENT_HELM_HOOK_TIMEOUT` (ms) → default-beslutet används. + +Den lokala hook-endpointen binder **endast** `127.0.0.1` och är aldrig nåbar utifrån. + ## Uppdatera till senaste 1. `git push master` → runnern bygger + pushar `:latest`. diff --git a/install.sh b/install.sh index b0c5a07..79935e1 100755 --- a/install.sh +++ b/install.sh @@ -26,10 +26,8 @@ ASSET="agent-helm-linux-$ARCH" command -v curl >/dev/null 2>&1 || { echo "agent-helm: curl krävs"; exit 1; } -# --- senaste release-tag --- -echo "agent-helm: slår upp senaste release ..." -TAG=$(curl -fsSL "$API/releases/latest" | tr ',' '\n' | grep -m1 '"tag_name"' | cut -d'"' -f4) -[ -n "${TAG:-}" ] || { echo "agent-helm: hittade ingen release på $BASE"; exit 1; } +# --- version: 'latest' (rullande master-bygge) om inget annat anges --- +TAG="${AGENT_HELM_VERSION:-latest}" URL="$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" # --- bin-katalog: /usr/local/bin om root/skrivbar, annars ~/.local/bin (ingen sudo). diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 0b4115b..bbdb1a1 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -1,103 +1,238 @@ /** - * agent-helm daemon ("appen"). + * agent-helm daemon ("appen") + hook-läge. * - * 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. + * Normalläge: spawnar en agent (default gemini) i en pty, strömmar pty över WS till + * servern och skriver tillbaka input. Kör dessutom en LOKAL http-endpoint (endast + * 127.0.0.1) som BeforeTool-hooken anropar för att få ett godkännande-beslut från UI:t. * - * 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. + * Hook-läge (`agent-helm hook`): läser verktygsanropet på stdin, frågar den körande + * daemonen via den lokala endpointen, och skriver beslutet på stdout i Geminis format. + * Samma binär gör båda jobben -> inga extra beroenden på målet. + * + * Persistens (att daemonen själv överlever) är ett yttre lager: tmux eller en + * Docker-container med restart-policy. Hanteras inte här. */ import { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch"; import { WebSocket } from "ws"; +import http from "node:http"; 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()}`; +type Decision = { decision: "allow" | "deny"; reason?: string }; -// 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; +const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788); -if (!TOKEN) { - console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön."); - process.exit(1); +if (process.argv[2] === "hook") { + void runHookMode(); +} else { + runDaemon(); } -const sessionId = randomUUID(); +/* --------------------------------- hook-läge -------------------------------- */ -const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, { - name: "xterm-256color", - cols: COLS, - rows: ROWS, - cwd: AGENT_CWD, - env: { ...process.env } as Record, -}); - -console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`); - -let ws: WebSocket | null = null; -let reconnectTimer: ReturnType | 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(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 readStdin(): Promise { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => resolve(data)); + setTimeout(() => resolve(data), 1500); // fortsätt även om stdin aldrig stängs }); } -function scheduleReconnect(): void { - if (reconnectTimer) return; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connect(); - }, 2000); -} +async function runHookMode(): Promise { + const raw = await readStdin(); + let payload: Record = {}; + try { + payload = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + payload = {}; + } + // Geminis hook-payload kan variera mellan versioner — plocka defensivt. + const p = payload as Record; + const toolName: string = + p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg"; + const toolInput: unknown = + p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload; -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); -}); - -function shutdown(): void { - pty.kill(); + const decision = await askDaemon(toolName, toolInput); + if (decision.decision === "deny") { + process.stdout.write(JSON.stringify({ decision: "deny", reason: decision.reason ?? "Nekat via agent-helm" })); + } else { + process.stdout.write(JSON.stringify({ decision: "allow" })); + } process.exit(0); } -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); -connect(); +function askDaemon(toolName: string, toolInput: unknown): Promise { + return new Promise((resolve) => { + const body = JSON.stringify({ toolName, toolInput }); + const req = http.request( + { + host: "127.0.0.1", + port: HOOK_PORT, + path: "/approve", + method: "POST", + headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) }, + }, + (res) => { + let out = ""; + res.on("data", (c) => (out += c)); + res.on("end", () => { + try { + resolve(JSON.parse(out) as Decision); + } catch { + resolve({ decision: "allow" }); + } + }); + }, + ); + // Ingen daemon igång / fel -> tillåt (graceful: agenten funkar som utan hook). + req.on("error", () => resolve({ decision: "allow" })); + req.setTimeout(180000, () => { + req.destroy(); + resolve({ decision: "allow" }); + }); + req.write(body); + req.end(); + }); +} + +/* -------------------------------- normal daemon ----------------------------- */ + +function runDaemon(): void { + 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()}`; + const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow"; + const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000); + 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, + // Skicka hook-porten vidare så att `agent-helm hook` (som gemini spawnar) hittar oss. + env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record, + }); + console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`); + + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + const pending = new Map void>(); + + 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(raw.toString()); + if (!msg) return; + if (msg.type === "server:input") { + pty.write(msg.data); + } else if (msg.type === "server:approval-decision") { + const resolve = pending.get(msg.approvalId); + if (resolve) { + pending.delete(msg.approvalId); + resolve({ decision: msg.decision, reason: msg.reason }); + } + } + }); + 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); + }); + + // Lokal hook-endpoint (endast 127.0.0.1 — aldrig nåbar utifrån). + const hookServer = http.createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/approve") { + res.writeHead(404); + res.end(); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + let parsed: { toolName?: string; toolInput?: unknown } = {}; + try { + parsed = JSON.parse(body); + } catch { + /* tom */ + } + const toolName = parsed.toolName ?? "okänt verktyg"; + const approvalId = randomUUID(); + + const finish = (d: Decision) => { + if (res.writableEnded) return; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(d)); + }; + + // Ingen server ansluten -> svara med default direkt (graceful degradation). + if (!ws || ws.readyState !== WebSocket.OPEN) { + finish({ decision: HOOK_DEFAULT }); + return; + } + + const timer = setTimeout(() => { + if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" }); + }, HOOK_TIMEOUT); + pending.set(approvalId, (d) => { + clearTimeout(timer); + finish(d); + }); + + send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput }); + }); + }); + hookServer.listen(HOOK_PORT, "127.0.0.1", () => { + console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`); + }); + + function shutdown(): void { + pty.kill(); + process.exit(0); + } + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + connect(); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d1990dd..0bc56f4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -146,6 +146,15 @@ function handleDaemonMessage(sessionId: string, text: string): void { sendWeb(sub, { type: "server:exit", sessionId, code: msg.code }); } broadcastSessions(); + } else if (msg.type === "daemon:approval") { + const out: ServerToWeb = { + type: "server:approval", + sessionId, + approvalId: msg.approvalId, + toolName: msg.toolName, + toolInput: msg.toolInput, + }; + for (const sub of session.subscribers) sendWeb(sub, out); } } @@ -166,8 +175,29 @@ function handleWebMessage(ws: WebSocket, text: string): void { } 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). + // Terminal-input (text/tangenter) -> pty. Hook-svar går via web:approval-decision. session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data })); + } else if (msg.type === "web:approval-decision") { + const session = sessions.get(msg.sessionId); + if (!session) return; + if (session.daemon && session.daemon.readyState === WebSocket.OPEN) { + session.daemon.send( + JSON.stringify({ + type: "server:approval-decision", + approvalId: msg.approvalId, + decision: msg.decision, + reason: msg.reason, + }), + ); + } + // Dölj kortet hos alla andra klienter som ser samma session. + const resolved: ServerToWeb = { + type: "server:approval-resolved", + sessionId: msg.sessionId, + approvalId: msg.approvalId, + decision: msg.decision, + }; + for (const sub of session.subscribers) sendWeb(sub, resolved); } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92c943d..c1687e1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -37,7 +37,15 @@ export interface DaemonExit { code: number; } -export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit; +/** Agenten vill köra ett verktyg (via BeforeTool-hooken) och vill ha godkännande. */ +export interface DaemonApproval { + type: "daemon:approval"; + approvalId: string; + toolName: string; + toolInput: unknown; +} + +export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit | DaemonApproval; /* ----------------------------- Server -> Daemon ---------------------------- */ @@ -47,7 +55,15 @@ export interface ServerInput { data: string; } -export type ServerToDaemon = ServerInput; +/** Beslut på en hook-förfrågan, vidarebefordrat till daemonen som äger sessionen. */ +export interface ServerApprovalDecision { + type: "server:approval-decision"; + approvalId: string; + decision: "allow" | "deny"; + reason?: string; +} + +export type ServerToDaemon = ServerInput | ServerApprovalDecision; /* ---------------------------- Frontend -> Server --------------------------- */ @@ -68,7 +84,16 @@ export interface WebInput { data: string; } -export type WebMessage = WebHello | WebSubscribe | WebInput; +/** Användarens svar på ett godkännande-kort. */ +export interface WebApprovalDecision { + type: "web:approval-decision"; + sessionId: string; + approvalId: string; + decision: "allow" | "deny"; + reason?: string; +} + +export type WebMessage = WebHello | WebSubscribe | WebInput | WebApprovalDecision; /* ---------------------------- Server -> Frontend --------------------------- */ @@ -106,13 +131,32 @@ export interface ServerError { message: string; } +/** Strukturerad godkännande-förfrågan (från en BeforeTool-hook) -> visas som kort. */ +export interface ServerApproval { + type: "server:approval"; + sessionId: string; + approvalId: string; + toolName: string; + toolInput: unknown; +} + +/** En förfrågan har besvarats (av dig eller en annan klient) -> dölj kortet. */ +export interface ServerApprovalResolved { + type: "server:approval-resolved"; + sessionId: string; + approvalId: string; + decision: "allow" | "deny"; +} + export type ServerToWeb = | ServerWelcome | ServerSessions | ServerSnapshot | ServerOutput | ServerSessionExit - | ServerError; + | ServerError + | ServerApproval + | ServerApprovalResolved; /* --------------------------------- Helpers --------------------------------- */ diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 2718e9c..b87f956 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -14,11 +14,25 @@ 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 { connected, connecting, error, sessions, approvals } = client; const selected = ref(null); const sidebarOpen = ref(true); +function sessionTitle(id: string): string { + return sessions.value.find((s) => s.sessionId === id)?.title ?? id.slice(0, 8); +} +function fmtInput(v: unknown): string { + if (v == null || v === "") return ""; + let s: string; + try { + s = typeof v === "string" ? v : JSON.stringify(v, null, 2); + } catch { + s = String(v); + } + return s.length > 600 ? `${s.slice(0, 600)} …` : s; +} + const activeMeta = computed( () => sessions.value.find((s) => s.sessionId === selected.value) ?? null, ); @@ -89,6 +103,22 @@ function select(id: string): void { />
Välj en session till vänster
+ + +
+
+
+ godkännande + {{ sessionTitle(a.sessionId) }} +
+
{{ a.toolName }}
+
{{ fmtInput(a.toolInput) }}
+
+ + +
+
+
@@ -199,6 +229,66 @@ function select(id: string): void { } .placeholder { margin: auto; color: var(--muted); } +/* Godkännande-kort (BeforeTool-hook) */ +.approvals { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.6rem; + width: min(380px, calc(100vw - 2rem)); +} +.approval-card { + background: var(--panel); + border: 1px solid var(--accent); + border-radius: 0.7rem; + padding: 0.75rem 0.85rem; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); +} +.ac-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } +.ac-badge { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.05em; + background: color-mix(in srgb, var(--accent) 25%, transparent); + color: var(--accent); + padding: 0.1rem 0.45rem; + border-radius: 999px; +} +.ac-session { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ac-tool { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.35rem; word-break: break-word; } +.ac-input { + margin: 0 0 0.6rem; + max-height: 9rem; + overflow: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 0.45rem; + padding: 0.5rem; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, Menlo, Consolas, monospace; + color: var(--text); +} +.ac-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } +.ac-actions button { + flex: 1; + padding: 0.55rem; + border-radius: 0.5rem; + border: 1px solid var(--border); + font-weight: 700; + font-size: 0.9rem; +} +.ac-actions .allow { background: var(--green); color: #06121f; border-color: var(--green); } +.ac-actions .deny { background: var(--panel-2); color: var(--red); } +.ac-actions button:active { opacity: 0.8; } +@media (max-width: 720px) { + .approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; } +} + @media (max-width: 720px) { .sidebar { position: absolute; diff --git a/packages/web/src/lib/helm.ts b/packages/web/src/lib/helm.ts index 54e91d1..528d38c 100644 --- a/packages/web/src/lib/helm.ts +++ b/packages/web/src/lib/helm.ts @@ -8,11 +8,20 @@ export interface HelmHandlers { onExit?: (sessionId: string, code: number) => void; } +/** En väntande hook-förfrågan (verktygsgodkännande) -> visas som kort. */ +export interface ApprovalCard { + sessionId: string; + approvalId: string; + toolName: string; + toolInput: unknown; +} + export function createHelmClient() { const connected = ref(false); const connecting = ref(false); const error = ref(null); const sessions = ref([]); + const approvals = ref([]); const handlers: HelmHandlers = {}; let ws: WebSocket | null = null; @@ -55,6 +64,15 @@ export function createHelmClient() { ); handlers.onExit?.(msg.sessionId, msg.code); break; + case "server:approval": + approvals.value = [ + ...approvals.value, + { sessionId: msg.sessionId, approvalId: msg.approvalId, toolName: msg.toolName, toolInput: msg.toolInput }, + ]; + break; + case "server:approval-resolved": + approvals.value = approvals.value.filter((a) => a.approvalId !== msg.approvalId); + break; case "server:error": error.value = msg.message; connecting.value = false; @@ -83,8 +101,15 @@ export function createHelmClient() { ws = null; connected.value = false; } + function approve(sessionId: string, approvalId: string, decision: "allow" | "deny"): void { + send({ type: "web:approval-decision", sessionId, approvalId, decision }); + approvals.value = approvals.value.filter((a) => a.approvalId !== approvalId); + } - return { connected, connecting, error, sessions, handlers, connect, disconnect, subscribe, sendInput }; + return { + connected, connecting, error, sessions, approvals, handlers, + connect, disconnect, subscribe, sendInput, approve, + }; } export type HelmClient = ReturnType;