BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
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:
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user