Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98c4d13360 |
@@ -13,6 +13,11 @@ AGENT_CMD=gemini
|
|||||||
# AGENT_CWD=
|
# AGENT_CWD=
|
||||||
# AGENT_TITLE=
|
# 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) ---
|
# --- frontend (Vite läser bara VITE_*-prefix) ---
|
||||||
VITE_SERVER_WS=ws://localhost:8787
|
VITE_SERVER_WS=ws://localhost:8787
|
||||||
VITE_AGENT_HELM_TOKEN=change-me
|
VITE_AGENT_HELM_TOKEN=change-me
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ name: release-client
|
|||||||
# dem som assets på en Gitea-release.
|
# dem som assets på en Gitea-release.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*"]
|
branches: [master, main] # varje master-push -> rullande 'latest'-release
|
||||||
|
tags: ["v*"] # vX.Y.Z -> versionerad release
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Release-tag (t.ex. v0.1.0)"
|
description: "Release-tag (lämna tom för 'latest')"
|
||||||
required: true
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-release:
|
build-release:
|
||||||
@@ -23,7 +24,12 @@ jobs:
|
|||||||
- name: Bestäm tag
|
- name: Bestäm tag
|
||||||
id: t
|
id: t
|
||||||
run: |
|
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
|
- name: Bundla daemonen
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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.
|
containern kör som uid 1000 = `brasse` så filerna blir korrekt ägda.
|
||||||
2. Ny stack i Dockge, namn `agent-helm`.
|
2. Ny stack i Dockge, namn `agent-helm`.
|
||||||
3. Klistra in `deploy/agent-helm.compose.yaml`.
|
3. Klistra in `deploy/agent-helm.compose.yaml`.
|
||||||
4. I stackens **env**: `AGENT_HELM_TOKEN=<lång-slumpad-hemlighet>`
|
4. I stackens **env**: `AGENT_HELM_TOKEN=<lång slumpad hemlighet>`
|
||||||
(generera t.ex. `openssl rand -hex 32`).
|
— 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*.
|
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.
|
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
|
## Uppdatera till senaste
|
||||||
|
|
||||||
1. `git push master` → runnern bygger + pushar `:latest`.
|
1. `git push master` → runnern bygger + pushar `:latest`.
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ ASSET="agent-helm-linux-$ARCH"
|
|||||||
|
|
||||||
command -v curl >/dev/null 2>&1 || { echo "agent-helm: curl krävs"; exit 1; }
|
command -v curl >/dev/null 2>&1 || { echo "agent-helm: curl krävs"; exit 1; }
|
||||||
|
|
||||||
# --- senaste release-tag ---
|
# --- version: 'latest' (rullande master-bygge) om inget annat anges ---
|
||||||
echo "agent-helm: slår upp senaste release ..."
|
TAG="${AGENT_HELM_VERSION:-latest}"
|
||||||
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; }
|
|
||||||
URL="$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
URL="$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||||
|
|
||||||
# --- bin-katalog: /usr/local/bin om root/skrivbar, annars ~/.local/bin (ingen sudo).
|
# --- bin-katalog: /usr/local/bin om root/skrivbar, annars ~/.local/bin (ingen sudo).
|
||||||
|
|||||||
@@ -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
|
* Normalläge: spawnar en agent (default gemini) i en pty, strömmar pty över WS till
|
||||||
* control-plane-servern över WebSocket, och skriver tillbaka input som kommer
|
* servern och skriver tillbaka input. Kör dessutom en LOKAL http-endpoint (endast
|
||||||
* från servern till pty:ns stdin. Återansluter automatiskt om servern går ner.
|
* 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
|
* Hook-läge (`agent-helm hook`): läser verktygsanropet på stdin, frågar den körande
|
||||||
* eller som en Docker-container med restart-policy. Det hanteras inte här.
|
* 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 { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
import http from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
|
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
|
||||||
import { parseMessage } from "@agent-helm/shared";
|
import { parseMessage } from "@agent-helm/shared";
|
||||||
|
|
||||||
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
|
type Decision = { decision: "allow" | "deny"; reason?: string };
|
||||||
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)
|
const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788);
|
||||||
// tittar samtidigt. Frontenden skalar/scrollar istället för att reflowa.
|
|
||||||
const COLS = 100;
|
|
||||||
const ROWS = 30;
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
if (process.argv[2] === "hook") {
|
||||||
console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
|
void runHookMode();
|
||||||
process.exit(1);
|
} else {
|
||||||
|
runDaemon();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
/* --------------------------------- hook-läge -------------------------------- */
|
||||||
|
|
||||||
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
name: "xterm-256color",
|
||||||
cols: COLS,
|
cols: COLS,
|
||||||
rows: ROWS,
|
rows: ROWS,
|
||||||
cwd: AGENT_CWD,
|
cwd: AGENT_CWD,
|
||||||
env: { ...process.env } as Record<string, string>,
|
// 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}`);
|
||||||
|
|
||||||
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>();
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
function send(msg: DaemonMessage): void {
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function send(msg: DaemonMessage): void {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
|
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
|
||||||
ws = new WebSocket(SERVER_URL);
|
ws = new WebSocket(SERVER_URL);
|
||||||
|
|
||||||
ws.on("open", () => {
|
ws.on("open", () => {
|
||||||
console.log("[daemon] ansluten — registrerar session");
|
console.log("[daemon] ansluten — registrerar session");
|
||||||
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
|
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (raw) => {
|
ws.on("message", (raw) => {
|
||||||
const msg = parseMessage<ServerToDaemon>(raw.toString());
|
const msg = parseMessage<ServerToDaemon>(raw.toString());
|
||||||
if (msg?.type === "server:input") {
|
if (!msg) return;
|
||||||
|
if (msg.type === "server:input") {
|
||||||
pty.write(msg.data);
|
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", () => {
|
ws.on("close", () => {
|
||||||
console.log("[daemon] anslutning stängd — återansluter om 2s");
|
console.log("[daemon] anslutning stängd — återansluter om 2s");
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message));
|
||||||
|
}
|
||||||
|
|
||||||
ws.on("error", (err: Error) => {
|
function scheduleReconnect(): void {
|
||||||
console.error("[daemon] ws-fel:", err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect(): void {
|
|
||||||
if (reconnectTimer) return;
|
if (reconnectTimer) return;
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
connect();
|
connect();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
pty.onData((data) => send({ type: "daemon:output", data }));
|
pty.onData((data) => send({ type: "daemon:output", data }));
|
||||||
|
pty.onExit(({ exitCode }) => {
|
||||||
pty.onExit(({ exitCode }) => {
|
|
||||||
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
|
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
|
||||||
send({ type: "daemon:exit", code: exitCode });
|
send({ type: "daemon:exit", code: exitCode });
|
||||||
setTimeout(() => process.exit(exitCode), 200);
|
setTimeout(() => process.exit(exitCode), 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown(): void {
|
// 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();
|
pty.kill();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,15 @@ function handleDaemonMessage(sessionId: string, text: string): void {
|
|||||||
sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
|
sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
|
||||||
}
|
}
|
||||||
broadcastSessions();
|
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") {
|
} else if (msg.type === "web:input") {
|
||||||
const session = sessions.get(msg.sessionId);
|
const session = sessions.get(msg.sessionId);
|
||||||
if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return;
|
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 }));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ export interface DaemonExit {
|
|||||||
code: number;
|
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 ---------------------------- */
|
/* ----------------------------- Server -> Daemon ---------------------------- */
|
||||||
|
|
||||||
@@ -47,7 +55,15 @@ export interface ServerInput {
|
|||||||
data: string;
|
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 --------------------------- */
|
/* ---------------------------- Frontend -> Server --------------------------- */
|
||||||
|
|
||||||
@@ -68,7 +84,16 @@ export interface WebInput {
|
|||||||
data: string;
|
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 --------------------------- */
|
/* ---------------------------- Server -> Frontend --------------------------- */
|
||||||
|
|
||||||
@@ -106,13 +131,32 @@ export interface ServerError {
|
|||||||
message: string;
|
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 =
|
export type ServerToWeb =
|
||||||
| ServerWelcome
|
| ServerWelcome
|
||||||
| ServerSessions
|
| ServerSessions
|
||||||
| ServerSnapshot
|
| ServerSnapshot
|
||||||
| ServerOutput
|
| ServerOutput
|
||||||
| ServerSessionExit
|
| ServerSessionExit
|
||||||
| ServerError;
|
| ServerError
|
||||||
|
| ServerApproval
|
||||||
|
| ServerApprovalResolved;
|
||||||
|
|
||||||
/* --------------------------------- Helpers --------------------------------- */
|
/* --------------------------------- Helpers --------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,25 @@ const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
|
|||||||
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
|
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
|
||||||
|
|
||||||
const client = createHelmClient();
|
const client = createHelmClient();
|
||||||
const { connected, connecting, error, sessions } = client;
|
const { connected, connecting, error, sessions, approvals } = client;
|
||||||
|
|
||||||
const selected = ref<string | null>(null);
|
const selected = ref<string | null>(null);
|
||||||
const sidebarOpen = ref(true);
|
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(
|
const activeMeta = computed(
|
||||||
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
|
() => 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>
|
<div v-else class="placeholder">Välj en session till vänster</div>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -199,6 +229,66 @@ function select(id: string): void {
|
|||||||
}
|
}
|
||||||
.placeholder { margin: auto; color: var(--muted); }
|
.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) {
|
@media (max-width: 720px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ export interface HelmHandlers {
|
|||||||
onExit?: (sessionId: string, code: number) => void;
|
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() {
|
export function createHelmClient() {
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
const connecting = ref(false);
|
const connecting = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const sessions = ref<SessionMeta[]>([]);
|
const sessions = ref<SessionMeta[]>([]);
|
||||||
|
const approvals = ref<ApprovalCard[]>([]);
|
||||||
const handlers: HelmHandlers = {};
|
const handlers: HelmHandlers = {};
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
@@ -55,6 +64,15 @@ export function createHelmClient() {
|
|||||||
);
|
);
|
||||||
handlers.onExit?.(msg.sessionId, msg.code);
|
handlers.onExit?.(msg.sessionId, msg.code);
|
||||||
break;
|
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":
|
case "server:error":
|
||||||
error.value = msg.message;
|
error.value = msg.message;
|
||||||
connecting.value = false;
|
connecting.value = false;
|
||||||
@@ -83,8 +101,15 @@ export function createHelmClient() {
|
|||||||
ws = null;
|
ws = null;
|
||||||
connected.value = false;
|
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>;
|
export type HelmClient = ReturnType<typeof createHelmClient>;
|
||||||
|
|||||||
Reference in New Issue
Block a user