BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
All checks were successful
build-and-push / build (push) Successful in 22s
release-client / build-release (push) Successful in 48s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 18:03:12 +02:00
parent a06a02927c
commit 98c4d13360
9 changed files with 460 additions and 97 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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=<lång-slumpad-hemlighet>`
(generera t.ex. `openssl rand -hex 32`).
4. I stackens **env**: `AGENT_HELM_TOKEN=<lång slumpad hemlighet>`
— 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`.

View File

@@ -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).

View File

@@ -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<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 readStdin(): Promise<string> {
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<void> {
const raw = await readStdin();
let payload: Record<string, unknown> = {};
try {
payload = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
} catch {
payload = {};
}
// Geminis hook-payload kan variera mellan versioner — plocka defensivt.
const p = payload as Record<string, any>;
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<Decision> {
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<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;
const pending = new Map<string, (d: Decision) => 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<ServerToDaemon>(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();
}

View File

@@ -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);
}
}

View File

@@ -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 --------------------------------- */

View File

@@ -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<string | null>(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 {
/>
<div v-else class="placeholder">Välj en session till vänster</div>
</main>
<!-- Strukturerade godkännanden (BeforeTool-hook) -->
<div v-if="approvals.length" class="approvals">
<div v-for="a in approvals" :key="a.approvalId" class="approval-card">
<div class="ac-head">
<span class="ac-badge">godkännande</span>
<span class="ac-session">{{ sessionTitle(a.sessionId) }}</span>
</div>
<div class="ac-tool">{{ a.toolName }}</div>
<pre v-if="fmtInput(a.toolInput)" class="ac-input">{{ fmtInput(a.toolInput) }}</pre>
<div class="ac-actions">
<button class="deny" @click="client.approve(a.sessionId, a.approvalId, 'deny')">Neka</button>
<button class="allow" @click="client.approve(a.sessionId, a.approvalId, 'allow')">Tillåt</button>
</div>
</div>
</div>
</div>
</template>
@@ -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;

View File

@@ -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<string | null>(null);
const sessions = ref<SessionMeta[]>([]);
const approvals = ref<ApprovalCard[]>([]);
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<typeof createHelmClient>;