ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,88 @@
1
+ // WebSocket client with auto-reconnect
2
+ let ws;
3
+ let reconnectDelay = 1000;
4
+ let onSnapshot = null;
5
+ let onSessionUpdate = null;
6
+ let onDurationAlert = null;
7
+ let onTeamUpdate = null;
8
+ let onHookStats = null;
9
+ let onSessionRemoved = null;
10
+ let onTerminalOutput = null;
11
+ let onTerminalReady = null;
12
+ let onTerminalClosed = null;
13
+ let onClearBrowserDb = null;
14
+ let reconnectTimer = null;
15
+ let reconnectTarget = 0; // timestamp when reconnect fires
16
+
17
+ export let connected = false;
18
+
19
+ export function getReconnectRemaining() {
20
+ if (connected || !reconnectTarget) return 0;
21
+ return Math.max(0, Math.ceil((reconnectTarget - Date.now()) / 1000));
22
+ }
23
+
24
+ export function connect({ onSnapshotCb, onSessionUpdateCb, onSessionRemovedCb, onDurationAlertCb, onTeamUpdateCb, onHookStatsCb, onTerminalOutputCb, onTerminalReadyCb, onTerminalClosedCb, onClearBrowserDbCb }) {
25
+ onSnapshot = onSnapshotCb;
26
+ onSessionUpdate = onSessionUpdateCb;
27
+ onSessionRemoved = onSessionRemovedCb || null;
28
+ onDurationAlert = onDurationAlertCb;
29
+ onTeamUpdate = onTeamUpdateCb;
30
+ onHookStats = onHookStatsCb;
31
+ onTerminalOutput = onTerminalOutputCb || null;
32
+ onTerminalReady = onTerminalReadyCb || null;
33
+ onTerminalClosed = onTerminalClosedCb || null;
34
+ onClearBrowserDb = onClearBrowserDbCb || null;
35
+ _connect();
36
+ }
37
+
38
+ export function getWs() {
39
+ return ws;
40
+ }
41
+
42
+ function _connect() {
43
+ ws = new WebSocket(`ws://${window.location.host}`);
44
+
45
+ ws.onopen = () => {
46
+ reconnectDelay = 1000;
47
+ reconnectTarget = 0;
48
+ connected = true;
49
+ console.log('[WS] Connected');
50
+ document.dispatchEvent(new CustomEvent('ws-status', { detail: 'connected' }));
51
+ };
52
+
53
+ ws.onmessage = (event) => {
54
+ const data = JSON.parse(event.data);
55
+ if (data.type === 'snapshot' && onSnapshot) {
56
+ onSnapshot(data.sessions, data.teams);
57
+ } else if (data.type === 'session_update' && onSessionUpdate) {
58
+ onSessionUpdate(data.session, data.team);
59
+ } else if (data.type === 'session_removed' && onSessionRemoved) {
60
+ onSessionRemoved(data.sessionId);
61
+ } else if (data.type === 'team_update' && onTeamUpdate) {
62
+ onTeamUpdate(data.team);
63
+ } else if (data.type === 'hook_stats' && onHookStats) {
64
+ onHookStats(data.stats);
65
+ } else if (data.type === 'duration_alert' && onDurationAlert) {
66
+ onDurationAlert(data);
67
+ } else if (data.type === 'terminal_output' && onTerminalOutput) {
68
+ onTerminalOutput(data.terminalId, data.data);
69
+ } else if (data.type === 'terminal_ready' && onTerminalReady) {
70
+ onTerminalReady(data.terminalId);
71
+ } else if (data.type === 'terminal_closed' && onTerminalClosed) {
72
+ onTerminalClosed(data.terminalId, data.reason);
73
+ } else if (data.type === 'clearBrowserDb' && onClearBrowserDb) {
74
+ onClearBrowserDb();
75
+ }
76
+ };
77
+
78
+ ws.onclose = () => {
79
+ connected = false;
80
+ console.log(`[WS] Disconnected, reconnecting in ${reconnectDelay}ms`);
81
+ document.dispatchEvent(new CustomEvent('ws-status', { detail: 'disconnected' }));
82
+ reconnectTarget = Date.now() + reconnectDelay;
83
+ reconnectTimer = setTimeout(_connect, reconnectDelay);
84
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
85
+ };
86
+
87
+ ws.onerror = () => ws.close();
88
+ }
@@ -0,0 +1,321 @@
1
+ // apiRouter.js — Express router for all API endpoints (no SQLite/database dependencies)
2
+ import { Router } from 'express';
3
+ import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSummary, getSession, detectSessionSource, createTerminalSession, deleteSessionFromMemory } from './sessionStore.js';
4
+ import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions } from './sshManager.js';
5
+ import { getStats as getHookStats, resetStats as resetHookStats } from './hookStats.js';
6
+ import { getMqStats } from './mqReader.js';
7
+ import { execFile } from 'child_process';
8
+ import { readFileSync, writeFileSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { homedir } from 'os';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __apiDirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ const router = Router();
16
+
17
+ // Hook performance stats
18
+ router.get('/hook-stats', (req, res) => {
19
+ res.json(getHookStats());
20
+ });
21
+
22
+ router.post('/hook-stats/reset', (req, res) => {
23
+ resetHookStats();
24
+ res.json({ ok: true });
25
+ });
26
+
27
+ // Full reset — broadcast to all connected browsers to clear their IndexedDB
28
+ router.post('/reset', async (req, res) => {
29
+ const { broadcast } = await import('./wsManager.js');
30
+ broadcast({ type: 'clearBrowserDb' });
31
+ res.json({ ok: true, message: 'Browser DB clear signal sent' });
32
+ });
33
+
34
+ // MQ reader stats
35
+ router.get('/mq-stats', (req, res) => {
36
+ res.json(getMqStats());
37
+ });
38
+
39
+ // ---- Hook Density Management ----
40
+
41
+ const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
42
+ const INSTALL_HOOKS_SCRIPT = join(__apiDirname, '..', 'hooks', 'install-hooks.js');
43
+ const HOOK_PATTERN = 'dashboard-hook.';
44
+ const ALL_HOOK_EVENTS = [
45
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
46
+ 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
47
+ 'TeammateIdle', 'TaskCompleted', 'PreCompact', 'SessionEnd'
48
+ ];
49
+ const DENSITY_EVENTS = {
50
+ high: ALL_HOOK_EVENTS,
51
+ medium: [
52
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
53
+ 'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
54
+ 'TaskCompleted', 'SessionEnd'
55
+ ],
56
+ low: ['SessionStart', 'UserPromptSubmit', 'PermissionRequest', 'Stop', 'SessionEnd']
57
+ };
58
+
59
+ // Get current hooks status from ~/.claude/settings.json
60
+ router.get('/hooks/status', (req, res) => {
61
+ try {
62
+ let claudeSettings = {};
63
+ try {
64
+ claudeSettings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
65
+ } catch { /* file doesn't exist yet */ }
66
+
67
+ const hooks = claudeSettings.hooks || {};
68
+ const installedEvents = ALL_HOOK_EVENTS.filter(event =>
69
+ hooks[event]?.some(group => group.hooks?.some(h => h.command?.includes(HOOK_PATTERN)))
70
+ );
71
+
72
+ // Infer density from installed events
73
+ let density = 'off';
74
+ if (installedEvents.length > 0) {
75
+ if (installedEvents.length === DENSITY_EVENTS.high.length &&
76
+ DENSITY_EVENTS.high.every(e => installedEvents.includes(e))) {
77
+ density = 'high';
78
+ } else if (installedEvents.length === DENSITY_EVENTS.medium.length &&
79
+ DENSITY_EVENTS.medium.every(e => installedEvents.includes(e))) {
80
+ density = 'medium';
81
+ } else if (installedEvents.length === DENSITY_EVENTS.low.length &&
82
+ DENSITY_EVENTS.low.every(e => installedEvents.includes(e))) {
83
+ density = 'low';
84
+ } else {
85
+ density = 'custom';
86
+ }
87
+ }
88
+
89
+ res.json({ installed: installedEvents.length > 0, density, events: installedEvents });
90
+ } catch (err) {
91
+ res.status(500).json({ error: err.message });
92
+ }
93
+ });
94
+
95
+ // Install hooks with specified density
96
+ router.post('/hooks/install', (req, res) => {
97
+ const { density } = req.body;
98
+ if (!density || !DENSITY_EVENTS[density]) {
99
+ return res.status(400).json({ error: 'density must be one of: high, medium, low' });
100
+ }
101
+
102
+ // Run install-hooks.js with --density flag
103
+ execFile('node', [INSTALL_HOOKS_SCRIPT, '--density', density], { timeout: 15000 }, (err, stdout, stderr) => {
104
+ if (err) {
105
+ console.error('[hooks/install] Error:', err.message);
106
+ return res.status(500).json({ error: err.message, stdout, stderr });
107
+ }
108
+ console.log('[hooks/install]', stdout);
109
+ res.json({ ok: true, density, events: DENSITY_EVENTS[density], output: stdout });
110
+ });
111
+ });
112
+
113
+ // Uninstall all dashboard hooks
114
+ router.post('/hooks/uninstall', (req, res) => {
115
+ // Run install-hooks.js with --uninstall flag
116
+ execFile('node', [INSTALL_HOOKS_SCRIPT, '--uninstall'], { timeout: 15000 }, (err, stdout, stderr) => {
117
+ if (err) {
118
+ console.error('[hooks/uninstall] Error:', err.message);
119
+ return res.status(500).json({ error: err.message, stdout, stderr });
120
+ }
121
+ console.log('[hooks/uninstall]', stdout);
122
+ res.json({ ok: true, output: stdout });
123
+ });
124
+ });
125
+
126
+ // ---- Session Control Endpoints ----
127
+
128
+ // Kill session process — sends SIGTERM, then SIGKILL after 3s if still alive
129
+ router.post('/sessions/:id/kill', (req, res) => {
130
+ if (!req.body.confirm) {
131
+ return res.status(400).json({ error: 'Must send {confirm: true} to kill a session' });
132
+ }
133
+ const sessionId = req.params.id;
134
+ const mem = getSession(sessionId);
135
+ const pid = findClaudeProcess(sessionId, mem?.projectPath);
136
+ const source = detectSessionSource(sessionId);
137
+ if (pid) {
138
+ try {
139
+ process.kill(pid, 'SIGTERM');
140
+ // Follow up with SIGKILL after 3s if process is still alive
141
+ setTimeout(() => {
142
+ try {
143
+ process.kill(pid, 0); // Check if still alive
144
+ process.kill(pid, 'SIGKILL');
145
+ } catch(e) { /* already dead — good */ }
146
+ }, 3000);
147
+ } catch (e) {
148
+ return res.status(500).json({ error: `Failed to kill PID ${pid}: ${e.message}` });
149
+ }
150
+ }
151
+ const session = killSession(sessionId);
152
+ archiveSession(sessionId, true);
153
+ // Close associated SSH terminal if present
154
+ if (session && session.terminalId) {
155
+ closeTerminal(session.terminalId);
156
+ } else if (mem && mem.terminalId) {
157
+ closeTerminal(mem.terminalId);
158
+ }
159
+ if (!session && !pid) {
160
+ return res.status(404).json({ error: 'Session not found and no matching process' });
161
+ }
162
+ res.json({ ok: true, pid: pid || null, source });
163
+ });
164
+
165
+ // Permanently delete a session — removes from memory, broadcasts removal to clients
166
+ router.delete('/sessions/:id', async (req, res) => {
167
+ const sessionId = req.params.id;
168
+ const session = getSession(sessionId);
169
+ // Close terminal if still active
170
+ if (session && session.terminalId) {
171
+ closeTerminal(session.terminalId);
172
+ }
173
+ const removed = deleteSessionFromMemory(sessionId);
174
+ // Broadcast session_removed so all connected browsers remove the card
175
+ try {
176
+ const { broadcast } = await import('./wsManager.js');
177
+ broadcast({ type: 'session_removed', sessionId });
178
+ } catch (e) {}
179
+ res.json({ ok: true, removed });
180
+ });
181
+
182
+ // Detect session source (vscode / terminal)
183
+ router.get('/sessions/:id/source', (req, res) => {
184
+ const source = detectSessionSource(req.params.id);
185
+ res.json({ source });
186
+ });
187
+
188
+
189
+
190
+ // Update session title (in-memory only, no DB write)
191
+ router.put('/sessions/:id/title', (req, res) => {
192
+ const { title } = req.body;
193
+ if (title === undefined) return res.status(400).json({ error: 'title is required' });
194
+ setSessionTitle(req.params.id, title);
195
+ res.json({ ok: true });
196
+ });
197
+
198
+ // Update session label (in-memory only, no DB write)
199
+ router.put('/sessions/:id/label', (req, res) => {
200
+ const { label } = req.body;
201
+ if (label === undefined) return res.status(400).json({ error: 'label is required' });
202
+ setSessionLabel(req.params.id, label);
203
+ res.json({ ok: true });
204
+ });
205
+
206
+ // Summarize session using Claude CLI
207
+ // The frontend sends { context, promptTemplate } from IndexedDB data.
208
+ // If custom_prompt is provided, use it directly as the prompt template.
209
+ router.post('/sessions/:id/summarize', async (req, res) => {
210
+ const sessionId = req.params.id;
211
+ const { context, promptTemplate: bodyPromptTemplate, custom_prompt: customPrompt } = req.body;
212
+
213
+ if (!context) {
214
+ return res.status(400).json({ error: 'context is required in request body (prepared from IndexedDB data)' });
215
+ }
216
+
217
+ // Determine prompt template: custom_prompt > bodyPromptTemplate > default
218
+ const promptTemplate = customPrompt || bodyPromptTemplate || 'Summarize this Claude Code session in detail.';
219
+
220
+ const summaryPrompt = `${promptTemplate}\n\n--- SESSION TRANSCRIPT ---\n${context}`;
221
+
222
+ try {
223
+ const summary = await new Promise((resolve, reject) => {
224
+ const child = execFile('claude', ['-p', '--model', 'haiku'], {
225
+ timeout: 60000,
226
+ maxBuffer: 1024 * 1024,
227
+ }, (error, stdout, stderr) => {
228
+ if (error) return reject(error);
229
+ resolve(stdout.trim());
230
+ });
231
+ child.stdin.write(summaryPrompt);
232
+ child.stdin.end();
233
+ });
234
+
235
+ // Store summary in memory
236
+ setSummary(sessionId, summary);
237
+ archiveSession(sessionId, true);
238
+
239
+ res.json({ ok: true, summary });
240
+ } catch (err) {
241
+ console.error('[apiRouter] Summarize error:', err.message);
242
+ res.status(500).json({ error: `Summarize failed: ${err.message}` });
243
+ }
244
+ });
245
+
246
+ // ── SSH Keys ──
247
+
248
+ router.get('/ssh-keys', (req, res) => {
249
+ res.json({ keys: listSshKeys() });
250
+ });
251
+
252
+ // ── Tmux Sessions ──
253
+
254
+ router.post('/tmux-sessions', async (req, res) => {
255
+ try {
256
+ const { host, port, username, password, privateKeyPath, authMethod, passphrase } = req.body;
257
+ if (!username) return res.status(400).json({ error: 'username required' });
258
+ const config = {
259
+ host: host || 'localhost',
260
+ port: port || 22,
261
+ username,
262
+ authMethod: authMethod || 'key',
263
+ privateKeyPath,
264
+ password,
265
+ passphrase,
266
+ };
267
+ const sessions = await listTmuxSessions(config);
268
+ res.json({ sessions });
269
+ } catch (err) {
270
+ res.status(500).json({ error: err.message });
271
+ }
272
+ });
273
+
274
+ // ── Terminals ──
275
+
276
+ router.post('/terminals', async (req, res) => {
277
+ try {
278
+ const { host, port, username, password, privateKeyPath, authMethod, workingDir, command, apiKey, tmuxSession, useTmux, sessionTitle, label } = req.body;
279
+
280
+ if (!username) return res.status(400).json({ error: 'username required' });
281
+ const config = {
282
+ host: host || 'localhost',
283
+ port: port || 22,
284
+ username,
285
+ authMethod: authMethod || 'key',
286
+ privateKeyPath,
287
+ workingDir: workingDir || '~',
288
+ command: command || 'claude',
289
+ password,
290
+ };
291
+
292
+ // Tmux modes
293
+ if (tmuxSession) config.tmuxSession = tmuxSession; // attach to existing
294
+ if (useTmux) config.useTmux = true; // wrap in new tmux session
295
+ if (sessionTitle) config.sessionTitle = sessionTitle;
296
+ if (label) config.label = label;
297
+
298
+ // Resolve API key from request body only (no DB lookup)
299
+ if (apiKey) {
300
+ config.apiKey = apiKey;
301
+ }
302
+
303
+ const terminalId = await createTerminal(config, null);
304
+ // Create session card immediately so it appears in the dashboard
305
+ await createTerminalSession(terminalId, config);
306
+ res.json({ ok: true, terminalId });
307
+ } catch (err) {
308
+ res.status(500).json({ error: err.message });
309
+ }
310
+ });
311
+
312
+ router.get('/terminals', (req, res) => {
313
+ res.json({ terminals: getTerminals() });
314
+ });
315
+
316
+ router.delete('/terminals/:id', (req, res) => {
317
+ closeTerminal(req.params.id);
318
+ res.json({ ok: true });
319
+ });
320
+
321
+ export default router;
@@ -0,0 +1,120 @@
1
+ // config.js — Extracted session status & approval detection configuration
2
+ import { config as serverConfig } from './serverConfig.js';
3
+
4
+ // ---- Tool Categories for Approval Detection ----
5
+ // When PreToolUse fires, we start a timer. If PostToolUse doesn't arrive
6
+ // within the timeout, the tool is likely pending user interaction.
7
+ // NOTE: PermissionRequest event (when available at medium+ density) provides
8
+ // a direct signal for approval-needed state, replacing the timeout heuristic.
9
+
10
+ export const TOOL_CATEGORIES = {
11
+ // Tools that complete instantly when auto-approved (3s timeout)
12
+ fast: ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'NotebookEdit'],
13
+ // Tools that ALWAYS require user interaction — not approval, but input (3s timeout)
14
+ userInput: ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'],
15
+ // Tools that can be slow but not minutes-slow (15s timeout)
16
+ medium: ['WebFetch', 'WebSearch'],
17
+ // Tools that can run for minutes but still need approval detection (8s timeout).
18
+ // Tradeoff: auto-approved long-running commands (npm install, builds) will
19
+ // briefly show as "approval" after 8s until PostToolUse clears it.
20
+ slow: ['Bash', 'Task'],
21
+ };
22
+
23
+ export const TOOL_TIMEOUTS = {
24
+ fast: 3000,
25
+ userInput: 3000,
26
+ medium: 15000,
27
+ slow: 8000,
28
+ };
29
+
30
+ // Status to set when each category's timeout fires
31
+ export const WAITING_REASONS = {
32
+ fast: 'approval', // "NEEDS YOUR APPROVAL"
33
+ userInput: 'input', // "WAITING FOR YOUR ANSWER"
34
+ medium: 'approval', // "NEEDS YOUR APPROVAL"
35
+ slow: 'approval', // "NEEDS YOUR APPROVAL"
36
+ };
37
+
38
+ // Human-readable labels for waitingDetail per category
39
+ export const WAITING_LABELS = {
40
+ approval: (toolName, detail) =>
41
+ detail ? `Approve ${toolName}: ${detail}` : `Approve ${toolName}`,
42
+ input: (toolName, _detail) => {
43
+ if (toolName === 'AskUserQuestion') return 'Waiting for your answer';
44
+ if (toolName === 'EnterPlanMode') return 'Review plan mode request';
45
+ if (toolName === 'ExitPlanMode') return 'Review plan';
46
+ return `Waiting for input on ${toolName}`;
47
+ },
48
+ };
49
+
50
+ // ---- Auto-Idle Timeouts ----
51
+ // Sessions transition to idle/waiting if no activity for these durations (ms)
52
+ export const AUTO_IDLE_TIMEOUTS = {
53
+ prompting: 30_000, // prompting → waiting (user likely cancelled)
54
+ waiting: 120_000, // waiting → idle (2 min)
55
+ working: 180_000, // working → idle (3 min)
56
+ approval: 600_000, // approval → idle (10 min safety net)
57
+ input: 600_000, // input → idle (10 min safety net)
58
+ };
59
+
60
+ // ---- Process Liveness Check ----
61
+ // How often to check if session PIDs are still alive (ms).
62
+ // When a user closes VS Code, JetBrains, or terminal abruptly, the SessionEnd
63
+ // hook never fires. This monitor detects dead processes and auto-ends sessions.
64
+ export const PROCESS_CHECK_INTERVAL = serverConfig.processCheckInterval || 15_000;
65
+
66
+ // ---- Animation State Mappings ----
67
+ export const STATUS_ANIMATIONS = {
68
+ idle: { animationState: 'Idle', emote: null },
69
+ prompting: { animationState: 'Walking', emote: 'Wave' },
70
+ working: { animationState: 'Running', emote: null },
71
+ approval: { animationState: 'Waiting', emote: null },
72
+ input: { animationState: 'Waiting', emote: null },
73
+ waiting: { animationState: 'Waiting', emote: 'ThumbsUp' },
74
+ ended: { animationState: 'Death', emote: null },
75
+ };
76
+
77
+ // ---- Precomputed Tool → Category Lookup ----
78
+ // Built once at import time for O(1) lookups in hot path
79
+ const _toolToCategory = new Map();
80
+ for (const [category, tools] of Object.entries(TOOL_CATEGORIES)) {
81
+ for (const tool of tools) {
82
+ _toolToCategory.set(tool, category);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the category for a tool name.
88
+ * @returns {string|null} 'fast' | 'userInput' | 'medium' | 'slow' | null (no timeout)
89
+ */
90
+ export function getToolCategory(toolName) {
91
+ return _toolToCategory.get(toolName) || null;
92
+ }
93
+
94
+ /**
95
+ * Get the approval/input timeout for a tool, or 0 if no detection applies.
96
+ */
97
+ export function getToolTimeout(toolName) {
98
+ const cat = getToolCategory(toolName);
99
+ return cat ? (TOOL_TIMEOUTS[cat] || 0) : 0;
100
+ }
101
+
102
+ /**
103
+ * Get the waiting status to set when a tool's timeout fires.
104
+ * @returns {'approval'|'input'|null}
105
+ */
106
+ export function getWaitingStatus(toolName) {
107
+ const cat = getToolCategory(toolName);
108
+ return cat ? (WAITING_REASONS[cat] || null) : null;
109
+ }
110
+
111
+ /**
112
+ * Get the human-readable waitingDetail label for a tool.
113
+ */
114
+ export function getWaitingLabel(toolName, detail) {
115
+ const cat = getToolCategory(toolName);
116
+ if (!cat) return null;
117
+ const status = WAITING_REASONS[cat];
118
+ const labelFn = WAITING_LABELS[status];
119
+ return labelFn ? labelFn(toolName, detail) : null;
120
+ }
@@ -0,0 +1,55 @@
1
+ // hookProcessor.js — Shared hook event processing pipeline
2
+ // Used by both hookRouter.js (HTTP) and mqReader.js (file-based MQ)
3
+ import { handleEvent } from './sessionStore.js';
4
+ import { broadcast } from './wsManager.js';
5
+ import { recordHook, getStats } from './hookStats.js';
6
+ import log from './logger.js';
7
+
8
+ /**
9
+ * Process a hook event from any transport (HTTP or MQ).
10
+ * Validates, calls handleEvent(), records stats, broadcasts to WebSocket clients.
11
+ *
12
+ * @param {object} hookData - Parsed hook JSON payload
13
+ * @param {'http'|'mq'} [source='http'] - Transport source for logging
14
+ * @returns {object|null} Session delta if event was processed, null otherwise
15
+ */
16
+ export function processHookEvent(hookData, source = 'http') {
17
+ const receivedAt = Date.now();
18
+
19
+ if (!hookData || !hookData.session_id) {
20
+ log.warn('hook', `Received hook without session_id (via ${source})`);
21
+ return null;
22
+ }
23
+
24
+ log.debug('hook', `Event: ${hookData.hook_event_name || 'unknown'} session=${hookData.session_id} via=${source}`);
25
+ log.debugJson('hook', 'Hook payload', hookData);
26
+
27
+ // Measure server processing time
28
+ const processStart = Date.now();
29
+ const delta = handleEvent(hookData);
30
+ const processingTime = Date.now() - processStart;
31
+
32
+ // Calculate delivery latency (hook_sent_at is seconds * 1000 from bash `date +%s`)
33
+ let deliveryLatency = null;
34
+ if (hookData.hook_sent_at) {
35
+ deliveryLatency = receivedAt - hookData.hook_sent_at;
36
+ if (deliveryLatency < 0) deliveryLatency = 0;
37
+ }
38
+
39
+ // Record stats
40
+ const eventType = hookData.hook_event_name || 'unknown';
41
+ recordHook(eventType, deliveryLatency, processingTime);
42
+
43
+ // Broadcast to WebSocket clients
44
+ if (delta) {
45
+ log.debug('hook', `Broadcasting session_update for ${hookData.session_id} status=${delta.session?.status}`);
46
+ broadcast({ type: 'session_update', ...delta });
47
+ if (delta.team) {
48
+ log.debug('hook', `Broadcasting team_update for team=${delta.team.teamId}`);
49
+ broadcast({ type: 'team_update', team: delta.team });
50
+ }
51
+ broadcast({ type: 'hook_stats', stats: getStats() });
52
+ }
53
+
54
+ return delta;
55
+ }
@@ -0,0 +1,18 @@
1
+ // hookRouter.js — POST /api/hooks endpoint (HTTP transport adapter)
2
+ import { Router } from 'express';
3
+ import { processHookEvent } from './hookProcessor.js';
4
+ import log from './logger.js';
5
+
6
+ const router = Router();
7
+
8
+ router.post('/', (req, res) => {
9
+ const hookData = req.body;
10
+ if (!hookData || !hookData.session_id) {
11
+ log.warn('hook', 'Received hook without session_id');
12
+ return res.status(400).json({ error: 'Missing session_id' });
13
+ }
14
+ processHookEvent(hookData, 'http');
15
+ res.json({ ok: true });
16
+ });
17
+
18
+ export default router;