diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index 5524853bd..c4a54ae81 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -6,12 +6,22 @@ * 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 */ const fs = require('fs'); const path = require('path'); const http = require('http'); +const os = require('os'); const { LOOPBACK_HOSTNAMES, buildAllowedHostnames, @@ -437,6 +447,7 @@ function renderHTML(data) { +
@@ -562,7 +573,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')+'
' + @@ -811,6 +822,85 @@ document.addEventListener('keydown', e => { if((e.metaKey||e.ctrlKey)&&e.key===' setLang(lang); handleRoute(); + `; /* eslint-enable no-useless-escape */ } @@ -831,6 +921,43 @@ function sendHtml(res, statusCode, html) { res.end(html); } +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 { + 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 { + 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. + } + } + return out; +} + function loadDashboardData(root) { return { agents: loadAgents(root), @@ -879,6 +1006,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 { @@ -940,6 +1082,7 @@ module.exports = { createDashboardServer, listenDashboardServer, loadAgents, + loadActivity, loadCommands, loadHooks, loadMcps, diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 43e5b7afe..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,13 +562,28 @@ 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; } 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. 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; } 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 {{{'; 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(/