diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css
index 60c50d1..2e08af7 100644
--- a/public/dashboard/dashboard.css
+++ b/public/dashboard/dashboard.css
@@ -1383,3 +1383,144 @@ body {
line-height: 1.5;
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
}
+
+/* Hooks tab (PR3): visual rule builder and event-grouped card list. */
+.dash-hook-list { display: flex; flex-direction: column; gap: var(--space-5); }
+.dash-hook-event-section {
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-lg);
+ background: var(--color-base-100);
+ padding: var(--space-4);
+}
+.dash-hook-event-header { margin-bottom: var(--space-3); }
+.dash-hook-event-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--color-base-content);
+ margin: 0 0 4px;
+ font-family: 'JetBrains Mono', monospace;
+}
+.dash-hook-event-summary {
+ font-size: 12px;
+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
+ margin: 0;
+}
+.dash-hook-event-cards { display: flex; flex-direction: column; gap: var(--space-2); }
+
+.dash-hook-builder {
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-lg);
+ background: var(--color-base-100);
+ padding: var(--space-5);
+}
+.dash-hook-builder-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-4);
+}
+.dash-hook-builder-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0;
+}
+.dash-hook-columns {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+@media (max-width: 960px) { .dash-hook-columns { grid-template-columns: 1fr; } }
+.dash-hook-column {
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-md);
+ padding: var(--space-3);
+ background: var(--color-base-200);
+}
+.dash-hook-column-eyebrow {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
+ margin-bottom: var(--space-1);
+}
+.dash-hook-column-title {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 var(--space-3);
+}
+.dash-hook-column-help {
+ font-size: 11.5px;
+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
+ margin-top: var(--space-2);
+ line-height: 1.5;
+}
+.dash-hook-column-disabled {
+ font-size: 12px;
+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
+ font-style: italic;
+}
+.dash-hook-action-form { display: flex; flex-direction: column; gap: var(--space-3); margin-top: var(--space-3); }
+.dash-hook-preview {
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-md);
+ background: var(--color-base-200);
+ padding: var(--space-3);
+}
+.dash-hook-preview-summary {
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
+ user-select: none;
+}
+.dash-hook-preview-code {
+ margin-top: var(--space-3);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11.5px;
+ line-height: 1.55;
+ background: var(--color-base-100);
+ border-radius: var(--radius-sm);
+ padding: var(--space-3);
+ overflow-x: auto;
+ color: var(--color-base-content);
+}
+.dash-audit-row {
+ padding: 8px 10px;
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-sm);
+ background: var(--color-base-100);
+ margin-bottom: 6px;
+}
+.dash-audit-row-top { font-size: 12px; }
+.dash-audit-row-body { font-size: 11px; color: color-mix(in oklab, var(--color-base-content) 60%, transparent); margin-top: 4px; }
+
+/* Settings tab (PR3): grouped sections and a bottom save bar. */
+.dash-settings-section {
+ border: 1px solid var(--color-base-300);
+ border-radius: var(--radius-lg);
+ background: var(--color-base-100);
+ padding: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+.dash-settings-section header { margin-bottom: var(--space-3); }
+.dash-save-bar {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-base-200);
+ border-top: 1px solid var(--color-base-300);
+ margin-top: var(--space-4);
+ backdrop-filter: blur(6px);
+}
+.dash-save-bar-status {
+ font-size: 12px;
+ color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
+}
+.dash-save-bar-actions { display: flex; gap: var(--space-2); }
diff --git a/public/dashboard/dashboard.js b/public/dashboard/dashboard.js
index e437edb..d66515a 100644
--- a/public/dashboard/dashboard.js
+++ b/public/dashboard/dashboard.js
@@ -181,35 +181,30 @@
container.setAttribute("data-active", "true");
var labels = {
sessions: {
- eyebrow: "PR2",
+ eyebrow: "soon",
title: "Sessions",
body: "A live view of every session the agent has had, with channels, costs, turn counts, and outcomes. Click through for full transcripts and the memories consolidated from each run.",
},
cost: {
- eyebrow: "PR2",
+ eyebrow: "soon",
title: "Cost",
body: "Daily and weekly cost breakdowns with model-level detail. Charts across time so you can see where the agent's budget actually goes, and alerts when anything drifts out of its baseline.",
},
scheduler: {
- eyebrow: "PR3",
+ eyebrow: "soon",
title: "Scheduler",
body: "Every cron and one-shot job the agent has created, with next-run times, recent outcomes, and the ability to edit or pause a schedule without asking the agent to do it for you.",
},
evolution: {
- eyebrow: "PR3",
+ eyebrow: "soon",
title: "Evolution timeline",
body: "The 6-step self-evolution pipeline rendered as a timeline: reflections, judges, validated changes, version bumps, and rollback points. You see exactly how the agent is changing itself over time.",
},
memory: {
- eyebrow: "PR4",
+ eyebrow: "soon",
title: "Memory explorer",
body: "A read view over every episode, fact, and procedure the agent has consolidated. Search, filter by decay, inspect provenance, and watch memories get reinforced as they get reused.",
},
- settings: {
- eyebrow: "PR3",
- title: "Settings",
- body: "A curated form over the agent's Claude Code settings: permissions, MCP servers, hooks, and the knobs that actually change how it thinks. Raw JSON escape hatch for the power users.",
- },
};
var meta = labels[name] || { eyebrow: "Soon", title: name, body: "Coming in a later PR." };
container.innerHTML = (
@@ -246,8 +241,8 @@
var name = parsed.route;
deactivateAllRoutes();
- var liveRoutes = ["skills", "memory-files", "plugins"];
- var comingSoon = ["sessions", "cost", "scheduler", "evolution", "memory", "settings"];
+ var liveRoutes = ["skills", "memory-files", "plugins", "subagents", "hooks", "settings"];
+ var comingSoon = ["sessions", "cost", "scheduler", "evolution", "memory"];
if (liveRoutes.indexOf(name) >= 0 && routes[name]) {
var containerId = "route-" + name;
@@ -304,9 +299,61 @@
if (el) el.textContent = new Date().toISOString().split("T")[0];
}
+ // Server-sent events for live dashboard updates. PR3 adds a
+ // "plugin_init_snapshot" event that fires when the agent sees the SDK init
+ // message and resolves the enabled-plugin set. The plugins module flips
+ // optimistically-installed cards to their real state.
+ //
+ // Connection health: the browser auto-reconnects on transport errors
+ // but not on HTTP 401 or 503, which happens on session expiry or
+ // cold boot. We paint a small status dot in the sidebar that shows
+ // live / reconnecting / disconnected so the operator knows whether
+ // live updates are actually arriving.
+ function updateSSEDot(statusClass, label) {
+ var dot = document.getElementById("dashboard-sse-dot");
+ if (!dot) return;
+ dot.setAttribute("data-status", statusClass);
+ dot.setAttribute("title", label);
+ }
+
+ function openEventStream() {
+ if (!window.EventSource) return null;
+ try {
+ var es = new EventSource("/ui/api/events");
+ es.addEventListener("open", function () {
+ updateSSEDot("live", "Live updates connected");
+ });
+ es.addEventListener("plugin_init_snapshot", function (e) {
+ try {
+ var data = JSON.parse(e.data);
+ if (window.PhantomPluginsModule && typeof window.PhantomPluginsModule.onInitSnapshot === "function") {
+ window.PhantomPluginsModule.onInitSnapshot(data);
+ }
+ } catch (_) {
+ // SSE payload was malformed; nothing useful to show the user.
+ }
+ });
+ es.onerror = function (e) {
+ // EventSource auto-reconnects on transport errors. Log a
+ // soft warning for debuggers; do not toast the user.
+ console.warn("SSE error, browser will attempt reconnect", e);
+ if (es.readyState === EventSource.CONNECTING) {
+ updateSSEDot("reconnecting", "Live updates reconnecting");
+ } else if (es.readyState === EventSource.CLOSED) {
+ updateSSEDot("disconnected", "Live updates disconnected");
+ }
+ };
+ return es;
+ } catch (_) {
+ updateSSEDot("disconnected", "Live updates unavailable");
+ return null;
+ }
+ }
+
function init() {
setNavDate();
initThemeToggle();
+ openEventStream();
window.addEventListener("beforeunload", function (e) {
if (anyDirty()) {
diff --git a/public/dashboard/hooks.js b/public/dashboard/hooks.js
new file mode 100644
index 0000000..566897c
--- /dev/null
+++ b/public/dashboard/hooks.js
@@ -0,0 +1,762 @@
+// Hooks tab: visual rule builder for the 26 Claude Agent SDK hook events.
+//
+// Module contract: registers with PhantomDashboard via registerRoute('hooks').
+// mount(container, arg, ctx) is called on hash change. ctx provides esc, api,
+// toast, openModal, navigate, setBreadcrumb, registerDirtyChecker.
+//
+// The hooks tab is the breakthrough surface of PR3. No Claude Code product has
+// a visual hooks rule builder; this is the first one. The design takes after
+// Linear Automations: a three-column flow (trigger, matcher, action) with a
+// type-specific form for the action column and a live JSON preview pane.
+//
+// State model is a single closure-scoped object; no framework. Every edit
+// mutates state.draftHook and schedules a re-render of the preview pane.
+
+(function () {
+ var HOOK_EVENTS = [
+ "PreToolUse", "PostToolUse", "PostToolUseFailure", "Notification",
+ "UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
+ "SubagentStart", "SubagentStop", "PreCompact", "PostCompact",
+ "PermissionRequest", "Setup", "TeammateIdle", "TaskCreated", "TaskCompleted",
+ "Elicitation", "ElicitationResult", "ConfigChange",
+ "WorktreeCreate", "WorktreeRemove", "InstructionsLoaded",
+ "CwdChanged", "FileChanged",
+ ];
+
+ var EVENTS_WITH_MATCHER = {
+ PreToolUse: "Tool name (e.g. Bash, Write, Edit)",
+ PostToolUse: "Tool name",
+ PostToolUseFailure: "Tool name",
+ SubagentStart: "Subagent name",
+ SubagentStop: "Subagent name",
+ Elicitation: "MCP server name",
+ ElicitationResult: "MCP server name",
+ ConfigChange: "Settings source (user_settings, project_settings, local_settings, policy_settings, skills)",
+ InstructionsLoaded: "Memory type or load reason",
+ FileChanged: "Filename glob (e.g. .envrc|.env)",
+ };
+
+ var EVENT_SUMMARIES = {
+ PreToolUse: "Fires before a tool call is dispatched. Exit 2 blocks the call.",
+ PostToolUse: "Fires after a tool call succeeds.",
+ PostToolUseFailure: "Fires after a tool call throws.",
+ Notification: "Fires on user-facing notifications.",
+ UserPromptSubmit: "Fires when the user hits enter. Exit 2 blocks the send.",
+ SessionStart: "Fires when a new session starts.",
+ SessionEnd: "Fires when a session ends normally.",
+ Stop: "Fires when the agent voluntarily stops.",
+ StopFailure: "Fires when the agent crashes out.",
+ SubagentStart: "Fires when a subagent is invoked via the Task tool.",
+ SubagentStop: "Fires when a subagent finishes.",
+ PreCompact: "Fires before the transcript is auto-compacted.",
+ PostCompact: "Fires after compaction.",
+ PermissionRequest: "Fires when the CLI asks to approve a dangerous op.",
+ Setup: "Fires once on first-time setup.",
+ TeammateIdle: "Fires when a team channel teammate stops sending.",
+ TaskCreated: "Fires when a background task is scheduled.",
+ TaskCompleted: "Fires when a background task completes.",
+ Elicitation: "Fires when an MCP server requests user input.",
+ ElicitationResult: "Fires after the user answers an elicitation.",
+ ConfigChange: "Fires when any settings source mutates.",
+ WorktreeCreate: "Fires when a git worktree is created.",
+ WorktreeRemove: "Fires when a git worktree is removed.",
+ InstructionsLoaded: "Fires when a CLAUDE.md or rules file loads.",
+ CwdChanged: "Fires when the working directory changes.",
+ FileChanged: "Fires when a watched file changes.",
+ };
+
+ var HOOK_TYPES = [
+ { value: "command", label: "Shell command", help: "Run a shell command. Exit code 2 blocks the event." },
+ { value: "prompt", label: "LLM prompt", help: "Evaluate the hook input with a prompt on a small, fast model." },
+ { value: "agent", label: "Agent verifier", help: "Run a full subagent that decides whether to approve." },
+ { value: "http", label: "HTTP POST", help: "POST the hook input JSON to a URL." },
+ ];
+
+ var state = {
+ slice: {},
+ total: 0,
+ allowedHttpHookUrls: null,
+ trustAccepted: false,
+ trustByType: { command: false, prompt: false, agent: false, http: false },
+ auditEntries: [],
+ loading: false,
+ initialized: false,
+ editing: null, // null | { mode: 'new' | 'edit', event, groupIndex, hookIndex, draft }
+ };
+ var ctx = null;
+ var root = null;
+
+ function esc(s) { return ctx.esc(s); }
+
+ function blankDraft() {
+ return {
+ event: "PreToolUse",
+ matcher: "",
+ definition: {
+ type: "command",
+ command: "",
+ timeout: 30,
+ statusMessage: "",
+ once: false,
+ async: false,
+ asyncRewake: false,
+ },
+ };
+ }
+
+ // Per-hook-type trust scoping: accepting the trust modal for command
+ // hooks does not satisfy the modal for http hooks. Http has a very
+ // different risk profile (network egress, env var interpolation)
+ // and users should opt in to that separately.
+ function isTrustedFor(hookType) {
+ if (!hookType) return false;
+ return !!(state.trustByType && state.trustByType[hookType]);
+ }
+
+ function renderHeader() {
+ var subtitle = state.total === 0
+ ? "No hooks installed. Add a rule below to react to any of 26 events."
+ : state.total + " hook" + (state.total === 1 ? "" : "s") + " installed across the agent's event surface.";
+ return (
+ '
'
+ );
+ }
+
+ function renderHookCard(event, groupIndex, matcher, hookIndex, def) {
+ var typeBadge = '' + esc(def.type) + ' ';
+ var matcherChip = matcher ? 'matcher: ' + esc(matcher) + ' ' : "";
+ var titleForType = {
+ command: def.command,
+ prompt: def.prompt,
+ agent: def.prompt,
+ http: def.url,
+ }[def.type] || "";
+ return (
+ '' +
+ '' +
+ '
' + esc(event) + ' ' +
+ typeBadge +
+ '' +
+ '' + esc(String(titleForType).slice(0, 140)) + '
' +
+ '' +
+ matcherChip +
+ (def.once ? 'once ' : "") +
+ (def.async ? 'async ' : "") +
+ 'Edit ' +
+ 'Delete ' +
+ '
' +
+ ' '
+ );
+ }
+
+ function renderHookList() {
+ var eventsWithHooks = Object.keys(state.slice).filter(function (ev) { return (state.slice[ev] || []).length > 0; });
+ if (eventsWithHooks.length === 0) {
+ return (
+ '' +
+ '
' +
+ '
No hooks yet ' +
+ '
Add a rule to run a command before every Bash call, format files after an edit, or fire a webhook on task completion. Every install runs through a trust modal on first use.
' +
+ '
Add the first rule ' +
+ '
'
+ );
+ }
+ var sections = [];
+ eventsWithHooks.forEach(function (ev) {
+ var cards = [];
+ (state.slice[ev] || []).forEach(function (group, gi) {
+ (group.hooks || []).forEach(function (def, hi) {
+ cards.push(renderHookCard(ev, gi, group.matcher, hi, def));
+ });
+ });
+ sections.push(
+ '' +
+ '' +
+ '' + cards.join("") + '
' +
+ ' '
+ );
+ });
+ return sections.join("");
+ }
+
+ function renderPreview() {
+ if (!state.editing) return "";
+ var preview = {};
+ var ev = state.editing.draft.event;
+ preview[ev] = [
+ {
+ matcher: state.editing.draft.matcher || undefined,
+ hooks: [cleanDefinition(state.editing.draft.definition)],
+ },
+ ];
+ if (!preview[ev][0].matcher) delete preview[ev][0].matcher;
+ return JSON.stringify(preview, null, 2);
+ }
+
+ function cleanDefinition(def) {
+ var out = {};
+ Object.keys(def).forEach(function (k) {
+ var v = def[k];
+ if (v === "" || v === null || v === undefined) return;
+ if (v === false && (k === "once" || k === "async" || k === "asyncRewake")) return;
+ out[k] = v;
+ });
+ return out;
+ }
+
+ function renderTypeOptions(current) {
+ return HOOK_TYPES.map(function (t) {
+ return '' + esc(t.label) + ' ';
+ }).join("");
+ }
+
+ function renderEventOptions(current) {
+ return HOOK_EVENTS.map(function (ev) {
+ return '' + esc(ev) + ' ';
+ }).join("");
+ }
+
+ function renderActionForm(def) {
+ var t = def.type;
+ var parts = [];
+ if (t === "command") {
+ parts.push(
+ '' +
+ '
Command ' +
+ '
' +
+ '
Runs in bash by default. Exit code 2 blocks the event.
' +
+ '
' +
+ '' +
+ '' +
+ 'Run once then remove ' +
+ 'Run async (non-blocking) ' +
+ 'Async rewake (notify on completion) ' +
+ '
'
+ );
+ } else if (t === "prompt" || t === "agent") {
+ parts.push(
+ '' +
+ '
Prompt ' +
+ '
' +
+ '
$ARGUMENTS is replaced with the hook input JSON. Under 4000 chars.
' +
+ '
' +
+ '' +
+ 'Run once then remove '
+ );
+ } else if (t === "http") {
+ var envVarsJson = JSON.stringify(def.allowedEnvVars || [])
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ var envChips = (def.allowedEnvVars || []).map(function (v, i) {
+ return '' + esc(v) + ' × ';
+ }).join("");
+ parts.push(
+ '' +
+ '
URL ' +
+ '
' +
+ '
Must match an allowed HTTP hook URL pattern in settings.json. Patterns are anchored full-string; append * to allow query strings.
' +
+ '
' +
+ '' +
+ 'Headers (one per line, key: value) ' +
+ '' +
+ '
' +
+ '' +
+ '
Allowed env vars ' +
+ '
' +
+ envChips +
+ ' ' +
+ '
' +
+ '
Env var names HTTP hooks may interpolate into headers. Must match [A-Z_][A-Z0-9_]*.
' +
+ '
' +
+ 'Timeout (s)
'
+ );
+ }
+ return parts.join("");
+ }
+
+ function headersToText(headers) {
+ if (!headers) return "";
+ return Object.keys(headers).map(function (k) { return k + ": " + headers[k]; }).join("\n");
+ }
+
+ function parseHeadersText(text) {
+ if (!text) return null;
+ var lines = text.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
+ var out = {};
+ for (var i = 0; i < lines.length; i++) {
+ var idx = lines[i].indexOf(":");
+ if (idx < 0) continue;
+ out[lines[i].slice(0, idx).trim()] = lines[i].slice(idx + 1).trim();
+ }
+ return Object.keys(out).length > 0 ? out : null;
+ }
+
+ function renderBuilder() {
+ if (!state.editing) return "";
+ var draft = state.editing.draft;
+ var matcherSupported = Object.prototype.hasOwnProperty.call(EVENTS_WITH_MATCHER, draft.event);
+ var matcherPlaceholder = EVENTS_WITH_MATCHER[draft.event] || "";
+ return (
+ '' +
+ '' +
+
+ '
' +
+
+ '
' +
+ '1. Trigger
' +
+ 'When ' +
+ '' + renderEventOptions(draft.event) + ' ' +
+ '' + esc(EVENT_SUMMARIES[draft.event] || "") + '
' +
+ ' ' +
+
+ '
' +
+ '2. Matcher
' +
+ 'For which ' +
+ (matcherSupported
+ ? 'Leave blank to match every invocation. Supports literal names and regex patterns the CLI interprets.
'
+ : 'This event does not accept a matcher. Leave blank.
'
+ ) +
+ ' ' +
+
+ '
' +
+ '3. Action
' +
+ 'Do ' +
+ '' + renderTypeOptions(draft.definition.type) + ' ' +
+ '' + renderActionForm(draft.definition) + '
' +
+ ' ' +
+
+ '
' +
+
+ '
' +
+ 'Preview settings.json slice ' +
+ '' + esc(renderPreview()) + ' ' +
+ ' ' +
+
+ '
'
+ );
+ }
+
+ function render() {
+ var body = state.editing
+ ? renderBuilder()
+ : '' + renderHookList() + '
';
+ root.innerHTML = renderHeader() + body;
+ wireEvents();
+ if (state.editing) {
+ wireBuilder();
+ updateSaveEnabled();
+ }
+ ctx.setBreadcrumb("Hooks");
+ }
+
+ function wireEvents() {
+ var newBtn = document.getElementById("hooks-new-btn");
+ if (newBtn) newBtn.addEventListener("click", startNewRule);
+ var newBtnEmpty = document.getElementById("hooks-new-btn-empty");
+ if (newBtnEmpty) newBtnEmpty.addEventListener("click", startNewRule);
+ var auditBtn = document.getElementById("hooks-audit-btn");
+ if (auditBtn) auditBtn.addEventListener("click", showAuditPanel);
+
+ document.querySelectorAll("[data-hook-edit]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ var coords = btn.getAttribute("data-hook-edit").split("/");
+ startEditRule(coords[0], parseInt(coords[1], 10), parseInt(coords[2], 10));
+ });
+ });
+ document.querySelectorAll("[data-hook-delete]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ var coords = btn.getAttribute("data-hook-delete").split("/");
+ confirmDelete(coords[0], parseInt(coords[1], 10), parseInt(coords[2], 10));
+ });
+ });
+ }
+
+ function wireBuilder() {
+ var eventSel = document.getElementById("hook-event");
+ var matcherInput = document.getElementById("hook-matcher");
+ var typeSel = document.getElementById("hook-type");
+
+ if (eventSel) eventSel.addEventListener("change", function () {
+ state.editing.draft.event = eventSel.value;
+ if (!Object.prototype.hasOwnProperty.call(EVENTS_WITH_MATCHER, eventSel.value)) {
+ state.editing.draft.matcher = "";
+ }
+ render();
+ });
+ if (matcherInput) matcherInput.addEventListener("input", function () {
+ state.editing.draft.matcher = matcherInput.value;
+ updatePreview();
+ updateSaveEnabled();
+ });
+ if (typeSel) typeSel.addEventListener("change", function () {
+ var newType = typeSel.value;
+ state.editing.draft.definition = defaultDefinition(newType);
+ render();
+ });
+ wireActionFields();
+
+ var saveBtn = document.getElementById("hook-save-btn");
+ if (saveBtn) saveBtn.addEventListener("click", saveRule);
+ var cancelBtn = document.getElementById("hook-cancel-btn");
+ if (cancelBtn) cancelBtn.addEventListener("click", function () {
+ state.editing = null;
+ render();
+ });
+ }
+
+ function wireActionFields() {
+ var def = state.editing.draft.definition;
+ var t = def.type;
+ if (t === "command") {
+ bindInput("hook-command", function (v) { def.command = v; });
+ bindInput("hook-timeout", function (v) { def.timeout = v ? parseInt(v, 10) : undefined; });
+ bindSelect("hook-shell", function (v) { def.shell = v || undefined; });
+ bindCheckbox("hook-once", function (v) { def.once = v; });
+ bindCheckbox("hook-async", function (v) { def.async = v; });
+ bindCheckbox("hook-async-rewake", function (v) { def.asyncRewake = v; });
+ } else if (t === "prompt" || t === "agent") {
+ bindInput("hook-prompt", function (v) { def.prompt = v; });
+ bindInput("hook-timeout", function (v) { def.timeout = v ? parseInt(v, 10) : undefined; });
+ bindInput("hook-model", function (v) { def.model = v || undefined; });
+ bindCheckbox("hook-once", function (v) { def.once = v; });
+ } else if (t === "http") {
+ bindInput("hook-url", function (v) { def.url = v; });
+ bindInput("hook-headers", function (v) { def.headers = parseHeadersText(v); });
+ bindInput("hook-timeout", function (v) { def.timeout = v ? parseInt(v, 10) : undefined; });
+ wireHookEnvVarsChips(def);
+ }
+ }
+
+ function wireHookEnvVarsChips(def) {
+ var container = document.getElementById("hook-allowed-env");
+ var input = document.getElementById("hook-allowed-env-input");
+ if (!container || !input) return;
+ function items() { try { return JSON.parse(container.getAttribute("data-env-vars") || "[]"); } catch (_) { return []; } }
+ function save(next) {
+ container.setAttribute("data-env-vars", JSON.stringify(next));
+ def.allowedEnvVars = next.length > 0 ? next : undefined;
+ updatePreview();
+ updateSaveEnabled();
+ }
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === ",") {
+ e.preventDefault();
+ var v = input.value.trim().replace(/,$/, "");
+ if (!v) return;
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(v)) {
+ ctx.toast("error", "Invalid env var name", "Env var names must match [A-Z_][A-Z0-9_]*.");
+ return;
+ }
+ var existing = items();
+ if (existing.indexOf(v) < 0) existing.push(v);
+ input.value = "";
+ save(existing);
+ // Re-render to show the new chip.
+ render();
+ }
+ });
+ container.querySelectorAll("[data-hook-env-remove]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ var idx = parseInt(btn.getAttribute("data-hook-env-remove"), 10);
+ var existing = items();
+ existing.splice(idx, 1);
+ save(existing);
+ render();
+ });
+ });
+ }
+
+ function bindInput(id, setter) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener("input", function () { setter(el.value); updatePreview(); updateSaveEnabled(); });
+ }
+ function bindSelect(id, setter) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener("change", function () { setter(el.value); updatePreview(); updateSaveEnabled(); });
+ }
+ function bindCheckbox(id, setter) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener("change", function () { setter(el.checked); updatePreview(); updateSaveEnabled(); });
+ }
+
+ function updatePreview() {
+ var pre = document.querySelector(".dash-hook-preview-code");
+ if (pre) pre.textContent = renderPreview();
+ }
+
+ function defaultDefinition(type) {
+ if (type === "command") return { type: "command", command: "", timeout: 30, once: false, async: false };
+ if (type === "prompt") return { type: "prompt", prompt: "", timeout: 60, once: false };
+ if (type === "agent") return { type: "agent", prompt: "", timeout: 60, once: false };
+ if (type === "http") return { type: "http", url: "", headers: null, timeout: 10 };
+ return { type: "command", command: "" };
+ }
+
+ function validateDraft() {
+ if (!state.editing) return false;
+ var d = state.editing.draft;
+ if (!d.event) return false;
+ if (d.definition.type === "command" && (!d.definition.command || !d.definition.command.trim())) return false;
+ if ((d.definition.type === "prompt" || d.definition.type === "agent") && (!d.definition.prompt || !d.definition.prompt.trim())) return false;
+ if (d.definition.type === "http") {
+ if (!d.definition.url) return false;
+ try { new URL(d.definition.url); } catch (_) { return false; }
+ }
+ return true;
+ }
+
+ function updateSaveEnabled() {
+ var btn = document.getElementById("hook-save-btn");
+ if (btn) btn.disabled = !validateDraft();
+ }
+
+ function startNewRule() {
+ // The trust modal scopes to the draft's current hook type. A
+ // user who already accepted for command hooks still sees it for
+ // their first http hook because http is a different risk
+ // profile. Type check happens again at save time so a type
+ // switch inside the builder gets caught.
+ state.editing = { mode: "new", draft: blankDraft() };
+ render();
+ }
+
+ function startEditRule(event, groupIndex, hookIndex) {
+ var group = (state.slice[event] || [])[groupIndex];
+ if (!group) return;
+ var def = (group.hooks || [])[hookIndex];
+ if (!def) return;
+ state.editing = {
+ mode: "edit",
+ event: event,
+ groupIndex: groupIndex,
+ hookIndex: hookIndex,
+ draft: { event: event, matcher: group.matcher || "", definition: JSON.parse(JSON.stringify(def)) },
+ };
+ render();
+ }
+
+ function saveRule() {
+ if (!validateDraft()) return;
+ var d = state.editing.draft;
+ var cleaned = cleanDefinition(d.definition);
+ cleaned.type = d.definition.type;
+
+ // Fire the trust modal the first time the operator installs a
+ // given hook type. The check is per-type so accepting command
+ // hooks does not silently cover http, agent, or prompt.
+ if (!isTrustedFor(d.definition.type)) {
+ showTrustModal(d.definition.type, function () { saveRule(); });
+ return;
+ }
+
+ var promise;
+ if (state.editing.mode === "new") {
+ promise = ctx.api("POST", "/ui/api/hooks", {
+ event: d.event,
+ matcher: d.matcher || undefined,
+ definition: cleaned,
+ });
+ } else {
+ // Detect whether the operator changed the event or the
+ // matcher while in edit mode. If so, we route through the
+ // relocate path so the hook moves atomically between
+ // coordinates. Otherwise the existing in-place update
+ // route does the right thing.
+ var origEvent = state.editing.event;
+ var origMatcherRaw = (state.slice[origEvent] || [])[state.editing.groupIndex];
+ var origMatcher = origMatcherRaw ? (origMatcherRaw.matcher || "") : "";
+ var draftMatcher = d.matcher || "";
+ var isRelocate = d.event !== origEvent || draftMatcher !== origMatcher;
+ var putBody = { definition: cleaned };
+ if (isRelocate) {
+ putBody.to = { event: d.event, matcher: draftMatcher || undefined };
+ }
+ promise = ctx.api(
+ "PUT",
+ "/ui/api/hooks/" + encodeURIComponent(origEvent) + "/" + state.editing.groupIndex + "/" + state.editing.hookIndex,
+ putBody,
+ );
+ }
+
+ promise.then(function (res) {
+ state.slice = res.slice || {};
+ recomputeTotal();
+ state.editing = null;
+ ctx.toast("success", "Rule saved", "Takes effect on the agent's next message.");
+ return loadList();
+ }).catch(function (err) {
+ ctx.toast("error", "Save failed", err.message || String(err));
+ });
+ }
+
+ function confirmDelete(event, groupIndex, hookIndex) {
+ ctx.openModal({
+ title: "Delete hook?",
+ body: "Remove this " + event + " hook. It stops firing on the agent's next message.",
+ actions: [
+ { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} },
+ {
+ label: "Delete",
+ className: "dash-btn-danger",
+ onClick: function () {
+ return ctx.api("DELETE", "/ui/api/hooks/" + encodeURIComponent(event) + "/" + groupIndex + "/" + hookIndex)
+ .then(function (res) {
+ state.slice = res.slice || {};
+ recomputeTotal();
+ ctx.toast("success", "Deleted", "Hook removed.");
+ return loadList();
+ })
+ .catch(function (err) {
+ ctx.toast("error", "Delete failed", err.message || String(err));
+ return false;
+ });
+ },
+ },
+ ],
+ });
+ }
+
+ function showTrustModal(hookType, onAccept) {
+ var perType = {
+ command: "Command hooks run arbitrary shell commands under the agent user. Treat the command line as production code.",
+ prompt: "Prompt hooks run a small model call on the hook input. No tool calls, no side effects, but cost is real.",
+ agent: "Agent hooks run a full subagent that can decide to approve or deny. The subagent has tool access.",
+ http: "HTTP hooks POST the hook input JSON to an allowlisted URL. Env vars listed in allowedEnvVars are substituted into headers. Network egress leaves the machine.",
+ };
+ var typeLabel = hookType.charAt(0).toUpperCase() + hookType.slice(1);
+ var body = document.createElement("div");
+ body.innerHTML = (
+ 'Trust for ' + esc(typeLabel) + ' hooks has not been accepted on this machine yet. Read this before you continue:
' +
+ '' +
+ '' + esc(perType[hookType] || "") + ' ' +
+ 'Every hook you install is audited. You can delete any hook at any time. ' +
+ 'The agent itself can add or remove hooks via its Write tool. The dashboard captures dashboard-originated edits only. ' +
+ 'Accepting trust for one type does not accept it for the other types. Each type has a different risk profile. ' +
+ ' ' +
+ 'By clicking Accept, you acknowledge that ' + esc(hookType) + ' hook execution power is real and you are taking responsibility for what you install.
'
+ );
+ ctx.openModal({
+ title: "Before you install " + hookType + " hooks",
+ body: body,
+ actions: [
+ { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} },
+ {
+ label: "Accept and continue",
+ className: "dash-btn-primary",
+ onClick: function () {
+ return ctx.api("POST", "/ui/api/hooks/trust", { hook_type: hookType })
+ .then(function () {
+ if (!state.trustByType) state.trustByType = {};
+ state.trustByType[hookType] = true;
+ state.trustAccepted = true;
+ onAccept();
+ })
+ .catch(function (err) {
+ ctx.toast("error", "Could not record trust", err.message || String(err));
+ return false;
+ });
+ },
+ },
+ ],
+ });
+ }
+
+ function showAuditPanel() {
+ ctx.api("GET", "/ui/api/hooks/audit").then(function (res) {
+ var entries = res.entries || [];
+ var body = document.createElement("div");
+ body.style.maxHeight = "60vh";
+ body.style.overflowY = "auto";
+ body.innerHTML = entries.length === 0
+ ? 'No audit entries yet.
'
+ : entries.map(function (e) {
+ return (
+ '' +
+ '
' +
+ '' + esc(e.created_at) + ' ' +
+ ' ' + esc(e.action) + ' ' +
+ ' on ' + esc(e.event) + ' ' +
+ (e.matcher ? ' (matcher: ' + esc(e.matcher) + ')' : '') +
+ '
' +
+ (e.hook_type ? '
type: ' + esc(e.hook_type) + '
' : '') +
+ '
'
+ );
+ }).join("");
+ ctx.openModal({
+ title: "Hooks audit",
+ body: body,
+ actions: [{ label: "Close", className: "dash-btn-ghost", onClick: function () {} }],
+ });
+ }).catch(function (err) {
+ ctx.toast("error", "Failed to load audit", err.message || String(err));
+ });
+ }
+
+ function recomputeTotal() {
+ var total = 0;
+ Object.values(state.slice).forEach(function (groups) {
+ (groups || []).forEach(function (g) { total += (g.hooks || []).length; });
+ });
+ state.total = total;
+ }
+
+ function loadList() {
+ return ctx.api("GET", "/ui/api/hooks").then(function (res) {
+ state.slice = res.slice || {};
+ state.total = res.total || 0;
+ state.allowedHttpHookUrls = res.allowed_http_hook_urls;
+ state.trustAccepted = !!res.trust_accepted;
+ state.trustByType = res.trust_by_type || {
+ command: false,
+ prompt: false,
+ agent: false,
+ http: false,
+ };
+ render();
+ }).catch(function (err) {
+ ctx.toast("error", "Failed to load hooks", err.message || String(err));
+ });
+ }
+
+ function mount(container, _arg, dashCtx) {
+ ctx = dashCtx;
+ root = container;
+ ctx.setBreadcrumb("Hooks");
+ if (!state.initialized) {
+ ctx.registerDirtyChecker(function () { return state.editing != null; });
+ state.initialized = true;
+ }
+ return loadList();
+ }
+
+ window.PhantomDashboard.registerRoute("hooks", { mount: mount });
+})();
diff --git a/public/dashboard/index.html b/public/dashboard/index.html
index af25463..cfb4078 100644
--- a/public/dashboard/index.html
+++ b/public/dashboard/index.html
@@ -48,6 +48,18 @@
Plugins
+
+
+
@@ -77,15 +89,10 @@
Memory explorer
-
@@ -94,6 +101,9 @@
+
+
+
@@ -105,6 +115,9 @@
+
+
+