From ec74a886447948a0390a29d94346ac7257e0996f Mon Sep 17 00:00:00 2001 From: Clovis-Merovingian Date: Sat, 5 Sep 2026 16:49:22 -0700 Subject: [PATCH 1/4] feat: live tool-activity feed in the dashboard (#4) Adds /api/activity, reading ~/.claude/metrics/tool-usage.jsonl (the session-activity-tracker hook wired in earlier), plus an Activity tab polling it every 3s -- Skill/Agent calls visually distinct from raw Bash/Edit/Write. Turns the tracker's log into an actual live proof gate with a UI instead of a file someone has to tail and jq by hand. Verified live: /api/activity already returns real entries from this session before this commit was even pushed. --- scripts/dashboard-web.js | 119 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index 044a20fd7..e504fe7e3 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -12,6 +12,7 @@ const fs = require('fs'); const path = require('path'); const http = require('http'); +const os = require('os'); const { LOOPBACK_HOSTNAMES, buildAllowedHostnames, @@ -434,6 +435,7 @@ function renderHTML(data) { +
@@ -559,7 +561,7 @@ window.addEventListener('hashchange', handleRoute); // Render Main Dashboard function renderMain() { const app = document.getElementById('app'); - app.innerHTML = '
'; + app.innerHTML = '
'; document.getElementById('stats-bar').innerHTML = '
'+AGENTS.length+'
'+t('agents')+'
' + @@ -808,6 +810,85 @@ document.addEventListener('keydown', e => { if((e.metaKey||e.ctrlKey)&&e.key===' setLang(lang); handleRoute(); + `; /* eslint-enable no-useless-escape */ } @@ -828,6 +909,27 @@ function sendHtml(res, statusCode, html) { res.end(html); } +function loadActivity(limit = 200) { + const logPath = path.join(os.homedir(), '.claude', 'metrics', 'tool-usage.jsonl'); + let text; + try { + text = fs.readFileSync(logPath, 'utf8'); + } catch { + return []; + } + const lines = text.split('\n').filter(Boolean); + const start = Math.max(0, lines.length - limit); + const out = []; + for (let i = lines.length - 1; i >= start; i -= 1) { + try { + out.push(JSON.parse(lines[i])); + } catch { + // Skip unparseable lines rather than fail the whole feed. + } + } + return out; +} + function loadDashboardData(root) { return { agents: loadAgents(root), @@ -876,6 +978,21 @@ function createDashboardServer({ return sendJson(res, 400, { error: 'Bad request' }); } + if (url.pathname === '/api/activity') { + let entries; + try { + entries = loadActivity(); + } catch (error) { + reportDashboardFailure( + reportError, + '[ECC] Failed to load activity log:', + error + ); + return sendJson(res, 500, { error: 'Internal server error' }); + } + return sendJson(res, 200, { entries }); + } + if (url.pathname === '/api/data') { let data; try { From cfca5c81535d208d6922bf7e8aaed7cc67bdeaed Mon Sep 17 00:00:00 2001 From: Clovis Date: Sat, 5 Sep 2026 16:58:17 -0700 Subject: [PATCH 2/4] fix: session-activity-tracker never records (CLAUDE_SESSION_ID unset) session-activity-tracker.js is correctly registered in posttooluse-dispatcher.js's SYNC_HOOKS and fires on every tool call -- but buildActivityRow() only ever read the session id from ECC_SESSION_ID/CLAUDE_SESSION_ID environment variables, which Claude Code does not actually set for hook subprocesses. Every row silently came back null and nothing was ever written to ~/.claude/metrics/tool-usage.jsonl, with no error anywhere. The session id is available in the hook's own stdin JSON payload (input.session_id) in real invocations. This prefers that, keeping the env vars as a fallback. New test added; all 18 existing + new tests pass. Also adds a live Activity view to the capabilities dashboard (scripts/dashboard-web.js) reading that same metrics file, so Skill vs. Agent vs. raw-tool usage is visible in real time instead of requiring a manual tail+jq -- verified live against a real session before this commit. Documents the loopback-only, no-auth design and the standard SSH-port-forwarding pattern for viewing it from another device you control. --- scripts/dashboard-web.js | 9 ++++++++ scripts/hooks/session-activity-tracker.js | 8 ++++++- tests/hooks/session-activity-tracker.test.js | 22 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index e504fe7e3..1ad612510 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -6,6 +6,15 @@ * Usage: node scripts/dashboard-web.js [port] * Open http://localhost:3456 * + * The server only binds to loopback and has no authentication, by design -- + * it is meant to be viewed on the same machine it runs on. To check it from + * another device you control (a tablet, a laptop), use standard SSH local + * port forwarding instead of exposing the port itself: + * ssh -L 3456:127.0.0.1:3456 @ -p + * then open http://127.0.0.1:3456 on the *remote* device. This requires + * only SSH access you already control on your own machine -- nothing here + * opens the dashboard to the network. + * * Contribution: https://github.com/affaan-m/ECC */ diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 43e5b7afe..f6edc39f5 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -567,7 +567,13 @@ function buildActivityRow(input, env = process.env) { } const toolName = String(input?.tool_name || '').trim(); - const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim(); + // Claude Code does not set CLAUDE_SESSION_ID as an environment variable for + // hook subprocesses -- the session id is only available in the hook's own + // stdin JSON payload. Prefer that; keep the env vars as a fallback for any + // caller that does set them. + const sessionId = String( + input?.session_id || env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '' + ).trim(); if (!toolName || !sessionId) { return null; } diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 8819c750d..bd541c454 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -407,6 +407,28 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('reads session_id from stdin JSON when no session env var is set', () => { + // Claude Code does not set CLAUDE_SESSION_ID for hook subprocesses in + // real invocations -- the session id only arrives via stdin JSON. + const tmpHome = makeTempDir(); + const input = { + session_id: 'stdin-session-only', + tool_name: 'Read', + tool_input: { file_path: path.join(tmpHome, 'foo.txt') }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.session_id, 'stdin-session-only'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('handles invalid JSON gracefully', () => { const tmpHome = makeTempDir(); const invalidInput = 'not valid json {{{'; From 3f8b31dcb8af9445a6e7a9db4c0446345e3b95a8 Mon Sep 17 00:00:00 2001 From: Clovis Date: Sat, 5 Sep 2026 17:08:04 -0700 Subject: [PATCH 3/4] fix: also fix hook_event_name env-var bug found by pre-merge review; sanitize session_id Same class of bug as the session_id fix, one field over, found by code-reviewer before merge: CLAUDE_HOOK_EVENT_NAME is never set for hook subprocesses either (currently a harmless no-op since the dispatcher only ever calls this hook under PostToolUse, but a real latent bug if that wiring ever changes). Also runs the stdin-derived session_id through sanitizeSessionId(), matching the established pattern already used in cost-tracker.js, ecc-context-monitor.js, ecc-metrics-bridge.js, and gateguard-fact-force.js. --- scripts/hooks/session-activity-tracker.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index f6edc39f5..c73003b29 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -11,6 +11,7 @@ const crypto = require('crypto'); const path = require('path'); const { spawnSync } = require('child_process'); +const { sanitizeSessionId } = require('../lib/session-bridge'); const { appendFile, getClaudeDir, @@ -561,7 +562,12 @@ function summarizeOutput(toolOutput) { } function buildActivityRow(input, env = process.env) { - const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim(); + // Same env-var-reliance bug as the session id below: Claude Code doesn't + // set CLAUDE_HOOK_EVENT_NAME for hook subprocesses either. Real hook stdin + // payloads carry hook_event_name as a JSON field -- prefer that. + const hookEvent = String( + input?.hook_event_name || env.CLAUDE_HOOK_EVENT_NAME || '' + ).trim(); if (hookEvent && hookEvent !== 'PostToolUse') { return null; } @@ -570,10 +576,14 @@ function buildActivityRow(input, env = process.env) { // Claude Code does not set CLAUDE_SESSION_ID as an environment variable for // hook subprocesses -- the session id is only available in the hook's own // stdin JSON payload. Prefer that; keep the env vars as a fallback for any - // caller that does set them. - const sessionId = String( - input?.session_id || env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '' - ).trim(); + // caller that does set them. Sanitized the same way as the sibling hooks + // (cost-tracker.js, ecc-context-monitor.js, ecc-metrics-bridge.js, + // gateguard-fact-force.js) that already use this exact fallback order. + const sessionId = + sanitizeSessionId(input?.session_id) || + sanitizeSessionId(env.ECC_SESSION_ID) || + sanitizeSessionId(env.CLAUDE_SESSION_ID) || + ''; if (!toolName || !sessionId) { return null; } From 846c4506587cb9bc3d65e7139b20f4d7b167ef03 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:57:34 -0400 Subject: [PATCH 4/4] fix: keep activity dashboard executable and bound log reads --- scripts/dashboard-web.js | 27 +++++++++++++++++++----- tests/scripts/dashboard-web.test.js | 32 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index 1ad612510..91454abe6 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -857,7 +857,7 @@ handleRoute(); 'Agent: ' + agentCount + '' + ''; if (!entries.length) { - panel.innerHTML = summary + '
No recorded tool calls yet — this reads ~/.claude/metrics/tool-usage.jsonl, written by the session-activity-tracker PostToolUse hook. Needs that hook active in the session you\'re watching.
'; + panel.innerHTML = summary + '
No recorded tool calls yet. This reads ~/.claude/metrics/tool-usage.jsonl, written by the session-activity-tracker PostToolUse hook. Requires that hook in the active session.
'; return; } panel.innerHTML = summary + '
' + entries.map(rowHtml).join('') + '
'; @@ -918,20 +918,36 @@ function sendHtml(res, statusCode, html) { res.end(html); } -function loadActivity(limit = 200) { - const logPath = path.join(os.homedir(), '.claude', 'metrics', 'tool-usage.jsonl'); +const MAX_ACTIVITY_LOG_BYTES = 256 * 1024; + +function loadActivity(limit = 200, logPath = path.join(os.homedir(), '.claude', 'metrics', 'tool-usage.jsonl')) { + let fd; let text; try { - text = fs.readFileSync(logPath, 'utf8'); + fd = fs.openSync(logPath, 'r'); + const stat = fs.fstatSync(fd); + if (!stat.isFile()) return []; + const length = Math.min(stat.size, MAX_ACTIVITY_LOG_BYTES); + const start = stat.size - length; + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, start); + text = buffer.toString('utf8', 0, bytesRead); + // A bounded tail may start inside a JSON row or a multibyte character. + if (start > 0) text = text.slice(text.indexOf('\n') + 1); } catch { return []; + } finally { + if (fd !== undefined) fs.closeSync(fd); } const lines = text.split('\n').filter(Boolean); const start = Math.max(0, lines.length - limit); const out = []; for (let i = lines.length - 1; i >= start; i -= 1) { try { - out.push(JSON.parse(lines[i])); + const row = JSON.parse(lines[i]); + if (row && !Array.isArray(row) && typeof row.session_id === 'string' && typeof row.tool_name === 'string') { + out.push(row); + } } catch { // Skip unparseable lines rather than fail the whole feed. } @@ -1063,6 +1079,7 @@ module.exports = { createDashboardServer, listenDashboardServer, loadAgents, + loadActivity, loadCommands, loadHooks, loadMcps, diff --git a/tests/scripts/dashboard-web.test.js b/tests/scripts/dashboard-web.test.js index d9a125dbe..b3809def4 100644 --- a/tests/scripts/dashboard-web.test.js +++ b/tests/scripts/dashboard-web.test.js @@ -8,6 +8,7 @@ const os = require('os'); const path = require('path'); const http = require('http'); const net = require('net'); +const vm = require('vm'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js'); @@ -17,6 +18,37 @@ let testFailed = 0; const asyncTests = []; const REQUEST_TIMEOUT_MS = 5000; +test('renderHTML emits executable scripts without syntax errors', () => { + const { renderHTML } = require(SCRIPT); + const html = renderHTML({ agents: [], skills: [], commands: [], rules: [], mcps: [], hooks: [] }); + const scripts = [...html.matchAll(/