ai-agent-session-center 2.0.2 → 2.0.3

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 (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. package/server/wsManager.ts +200 -0
package/server/db.ts ADDED
@@ -0,0 +1,475 @@
1
+ // db.ts — SQLite persistence layer for all session data
2
+ // Stores sessions, prompts, responses, tool calls, events, and notes in data/sessions.db
3
+ // so all browsers (localhost, LAN IP, etc.) see the same data.
4
+
5
+ import Database from 'better-sqlite3';
6
+ import { mkdirSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import log from './logger.js';
10
+ import type { Session } from '../src/types/session.js';
11
+ import type {
12
+ DbSessionRow, DbPromptRow, DbResponseRow, DbToolCallRow, DbEventRow, DbNoteRow,
13
+ SessionDetailResponse, SessionSearchResponse, SessionSearchParams,
14
+ FullTextSearchResult, FullTextSearchResponse,
15
+ } from '../src/types/api.js';
16
+ import type {
17
+ AnalyticsSummary, ToolBreakdownEntry, ActiveProject, HeatmapEntry, DistinctProject,
18
+ } from '../src/types/analytics.js';
19
+
20
+ const __dbDirname = dirname(fileURLToPath(import.meta.url));
21
+ const DB_DIR = join(__dbDirname, '..', 'data');
22
+ const DB_PATH = join(DB_DIR, 'sessions.db');
23
+
24
+ mkdirSync(DB_DIR, { recursive: true });
25
+
26
+ const db = new Database(DB_PATH);
27
+
28
+ db.pragma('journal_mode = WAL');
29
+
30
+ // ---- Schema ----
31
+
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS sessions (
34
+ id TEXT PRIMARY KEY,
35
+ project_path TEXT,
36
+ project_name TEXT,
37
+ title TEXT,
38
+ model TEXT,
39
+ status TEXT,
40
+ source TEXT DEFAULT 'hook',
41
+ label TEXT,
42
+ summary TEXT,
43
+ team_id TEXT,
44
+ team_role TEXT,
45
+ character_model TEXT,
46
+ accent_color TEXT,
47
+ started_at INTEGER,
48
+ ended_at INTEGER,
49
+ last_activity_at INTEGER,
50
+ total_prompts INTEGER DEFAULT 0,
51
+ total_tool_calls INTEGER DEFAULT 0,
52
+ archived INTEGER DEFAULT 0
53
+ );
54
+ CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path);
55
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
56
+ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
57
+ CREATE INDEX IF NOT EXISTS idx_sessions_last_activity_at ON sessions(last_activity_at);
58
+
59
+ CREATE TABLE IF NOT EXISTS prompts (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ session_id TEXT NOT NULL,
62
+ text TEXT,
63
+ timestamp INTEGER,
64
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_prompts_session_id ON prompts(session_id);
67
+ CREATE INDEX IF NOT EXISTS idx_prompts_timestamp ON prompts(timestamp);
68
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_prompts_dedup ON prompts(session_id, timestamp);
69
+
70
+ CREATE TABLE IF NOT EXISTS responses (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ session_id TEXT NOT NULL,
73
+ text_excerpt TEXT,
74
+ timestamp INTEGER,
75
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_responses_session_id ON responses(session_id);
78
+ CREATE INDEX IF NOT EXISTS idx_responses_timestamp ON responses(timestamp);
79
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_responses_dedup ON responses(session_id, timestamp);
80
+
81
+ CREATE TABLE IF NOT EXISTS tool_calls (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ session_id TEXT NOT NULL,
84
+ tool_name TEXT,
85
+ tool_input_summary TEXT,
86
+ timestamp INTEGER,
87
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session_id ON tool_calls(session_id);
90
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
91
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_name ON tool_calls(tool_name);
92
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_calls_dedup ON tool_calls(session_id, timestamp, tool_name);
93
+
94
+ CREATE TABLE IF NOT EXISTS events (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ session_id TEXT NOT NULL,
97
+ event_type TEXT,
98
+ detail TEXT,
99
+ timestamp INTEGER,
100
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
101
+ );
102
+ CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
103
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
104
+
105
+ CREATE TABLE IF NOT EXISTS notes (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ session_id TEXT NOT NULL,
108
+ text TEXT,
109
+ created_at INTEGER,
110
+ updated_at INTEGER,
111
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_notes_session_id ON notes(session_id);
114
+ `);
115
+
116
+ log.info('db', `SQLite database opened: ${DB_PATH}`);
117
+
118
+ // ---- Prepared Statements ----
119
+
120
+ const stmts = {
121
+ upsertSession: db.prepare(`
122
+ INSERT INTO sessions (id, project_path, project_name, title, model, status, source, label, summary, team_id, team_role, character_model, accent_color, started_at, ended_at, last_activity_at, total_prompts, total_tool_calls, archived)
123
+ VALUES (@id, @project_path, @project_name, @title, @model, @status, @source, @label, @summary, @team_id, @team_role, @character_model, @accent_color, @started_at, @ended_at, @last_activity_at, @total_prompts, @total_tool_calls, @archived)
124
+ ON CONFLICT(id) DO UPDATE SET
125
+ project_path = @project_path, project_name = @project_name, title = @title,
126
+ model = @model, status = @status, source = @source, label = @label,
127
+ summary = @summary, team_id = @team_id, team_role = @team_role,
128
+ character_model = @character_model, accent_color = @accent_color,
129
+ ended_at = @ended_at, last_activity_at = @last_activity_at,
130
+ total_prompts = @total_prompts, total_tool_calls = @total_tool_calls, archived = @archived
131
+ `),
132
+
133
+ insertPrompt: db.prepare(`
134
+ INSERT OR IGNORE INTO prompts (session_id, text, timestamp) VALUES (?, ?, ?)
135
+ `),
136
+
137
+ insertResponse: db.prepare(`
138
+ INSERT OR IGNORE INTO responses (session_id, text_excerpt, timestamp) VALUES (?, ?, ?)
139
+ `),
140
+
141
+ insertToolCall: db.prepare(`
142
+ INSERT OR IGNORE INTO tool_calls (session_id, tool_name, tool_input_summary, timestamp) VALUES (?, ?, ?, ?)
143
+ `),
144
+
145
+ insertEvent: db.prepare(`
146
+ INSERT INTO events (session_id, event_type, detail, timestamp) VALUES (?, ?, ?, ?)
147
+ `),
148
+
149
+ insertNote: db.prepare(`
150
+ INSERT INTO notes (session_id, text, created_at, updated_at) VALUES (?, ?, ?, ?)
151
+ `),
152
+
153
+ // Queries
154
+ getSessionById: db.prepare('SELECT * FROM sessions WHERE id = ?'),
155
+ getAllSessions: db.prepare('SELECT * FROM sessions ORDER BY last_activity_at DESC'),
156
+ getSessionsByProjectPath: db.prepare('SELECT * FROM sessions WHERE project_path = ? ORDER BY last_activity_at DESC'),
157
+ deleteSession: db.prepare('DELETE FROM sessions WHERE id = ?'),
158
+ updateSessionArchived: db.prepare('UPDATE sessions SET archived = ? WHERE id = ?'),
159
+ updateSessionSummary: db.prepare('UPDATE sessions SET summary = ? WHERE id = ?'),
160
+ updateSessionTitle: db.prepare('UPDATE sessions SET title = ? WHERE id = ?'),
161
+ updateSessionLabel: db.prepare('UPDATE sessions SET label = ? WHERE id = ?'),
162
+
163
+ getPromptsBySession: db.prepare('SELECT * FROM prompts WHERE session_id = ? ORDER BY timestamp ASC'),
164
+ getResponsesBySession: db.prepare('SELECT * FROM responses WHERE session_id = ? ORDER BY timestamp ASC'),
165
+ getToolCallsBySession: db.prepare('SELECT * FROM tool_calls WHERE session_id = ? ORDER BY timestamp ASC'),
166
+ getEventsBySession: db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC'),
167
+ getNotesBySession: db.prepare('SELECT * FROM notes WHERE session_id = ? ORDER BY created_at DESC'),
168
+ deleteNote: db.prepare('DELETE FROM notes WHERE id = ?'),
169
+
170
+ // Cascade delete helpers
171
+ deletePromptsBySession: db.prepare('DELETE FROM prompts WHERE session_id = ?'),
172
+ deleteResponsesBySession: db.prepare('DELETE FROM responses WHERE session_id = ?'),
173
+ deleteToolCallsBySession: db.prepare('DELETE FROM tool_calls WHERE session_id = ?'),
174
+ deleteEventsBySession: db.prepare('DELETE FROM events WHERE session_id = ?'),
175
+ deleteNotesBySession: db.prepare('DELETE FROM notes WHERE session_id = ?'),
176
+
177
+ // Search
178
+ searchPrompts: db.prepare(`SELECT p.*, s.project_name FROM prompts p JOIN sessions s ON p.session_id = s.id WHERE p.text LIKE ? ORDER BY p.timestamp DESC LIMIT ? OFFSET ?`),
179
+ searchResponses: db.prepare(`SELECT r.*, s.project_name FROM responses r JOIN sessions s ON r.session_id = s.id WHERE r.text_excerpt LIKE ? ORDER BY r.timestamp DESC LIMIT ? OFFSET ?`),
180
+ countSearchPrompts: db.prepare('SELECT COUNT(*) as cnt FROM prompts WHERE text LIKE ?'),
181
+ countSearchResponses: db.prepare('SELECT COUNT(*) as cnt FROM responses WHERE text_excerpt LIKE ?'),
182
+
183
+ // Analytics
184
+ distinctProjects: db.prepare(`SELECT DISTINCT project_path, project_name FROM sessions WHERE project_path IS NOT NULL AND project_path != '' ORDER BY project_name`),
185
+ summaryStats: db.prepare(`SELECT COUNT(*) as total_sessions, SUM(CASE WHEN status != 'ended' THEN 1 ELSE 0 END) as active_sessions FROM sessions`),
186
+ totalPrompts: db.prepare('SELECT COUNT(*) as cnt FROM prompts'),
187
+ totalToolCalls: db.prepare('SELECT COUNT(*) as cnt FROM tool_calls'),
188
+ toolBreakdown: db.prepare(`SELECT tool_name, COUNT(*) as count FROM tool_calls GROUP BY tool_name ORDER BY count DESC`),
189
+ activeProjects: db.prepare(`
190
+ SELECT s.project_path, s.project_name,
191
+ COUNT(DISTINCT s.id) as session_count,
192
+ MAX(s.last_activity_at) as last_activity
193
+ FROM sessions s
194
+ WHERE s.project_path IS NOT NULL AND s.project_path != ''
195
+ GROUP BY s.project_path
196
+ ORDER BY last_activity DESC
197
+ `),
198
+ };
199
+
200
+ // Batch insert transaction for persisting full session state
201
+ const persistSessionTx = db.transaction((session: Session) => {
202
+ stmts.upsertSession.run({
203
+ id: session.sessionId,
204
+ project_path: session.projectPath || '',
205
+ project_name: session.projectName || '',
206
+ title: session.title || '',
207
+ model: session.model || '',
208
+ status: session.status || '',
209
+ source: session.source || 'hook',
210
+ label: session.label || null,
211
+ summary: session.summary || null,
212
+ team_id: session.teamId || null,
213
+ team_role: session.teamRole || null,
214
+ character_model: session.characterModel || null,
215
+ accent_color: session.accentColor || null,
216
+ started_at: session.startedAt || null,
217
+ ended_at: session.endedAt || null,
218
+ last_activity_at: session.lastActivityAt || null,
219
+ total_prompts: session.promptHistory?.length || 0,
220
+ total_tool_calls: session.totalToolCalls || 0,
221
+ archived: session.archived || 0,
222
+ });
223
+
224
+ if (session.promptHistory?.length) {
225
+ for (const p of session.promptHistory) {
226
+ stmts.insertPrompt.run(session.sessionId, p.text, p.timestamp);
227
+ }
228
+ }
229
+
230
+ if (session.responseLog?.length) {
231
+ for (const r of session.responseLog) {
232
+ stmts.insertResponse.run(session.sessionId, r.text, r.timestamp);
233
+ }
234
+ }
235
+
236
+ if (session.toolLog?.length) {
237
+ for (const t of session.toolLog) {
238
+ stmts.insertToolCall.run(session.sessionId, t.tool, t.input, t.timestamp);
239
+ }
240
+ }
241
+
242
+ if (session.events?.length) {
243
+ for (const e of session.events) {
244
+ stmts.insertEvent.run(session.sessionId, e.type, e.detail || '', e.timestamp);
245
+ }
246
+ }
247
+ });
248
+
249
+ // ---- Exports: Session CRUD ----
250
+
251
+ /** Upsert a full session with all child records (prompts, responses, tools, events). */
252
+ export function upsertSession(session: Session): void {
253
+ try {
254
+ persistSessionTx(session);
255
+ } catch (err: unknown) {
256
+ log.warn('db', `Failed to upsert session ${session.sessionId}: ${(err as Error).message}`);
257
+ }
258
+ }
259
+
260
+ /** Get a single session by ID with all child records. */
261
+ export function getSessionDetail(id: string): SessionDetailResponse | null {
262
+ const session = stmts.getSessionById.get(id) as DbSessionRow | undefined;
263
+ if (!session) return null;
264
+ return {
265
+ session,
266
+ prompts: stmts.getPromptsBySession.all(id) as DbPromptRow[],
267
+ responses: stmts.getResponsesBySession.all(id) as DbResponseRow[],
268
+ tool_calls: stmts.getToolCallsBySession.all(id) as DbToolCallRow[],
269
+ events: stmts.getEventsBySession.all(id) as DbEventRow[],
270
+ notes: stmts.getNotesBySession.all(id) as DbNoteRow[],
271
+ };
272
+ }
273
+
274
+ export function getSessionsByProjectPath(projectPath: string): DbSessionRow[] {
275
+ return stmts.getSessionsByProjectPath.all(projectPath) as DbSessionRow[];
276
+ }
277
+
278
+ export function getAllPersistedSessions(): DbSessionRow[] {
279
+ return stmts.getAllSessions.all() as DbSessionRow[];
280
+ }
281
+
282
+ /** Cascade-delete a session and all child records. */
283
+ export const deleteSessionCascade: (id: string) => void = db.transaction((id: string) => {
284
+ stmts.deletePromptsBySession.run(id);
285
+ stmts.deleteResponsesBySession.run(id);
286
+ stmts.deleteToolCallsBySession.run(id);
287
+ stmts.deleteEventsBySession.run(id);
288
+ stmts.deleteNotesBySession.run(id);
289
+ stmts.deleteSession.run(id);
290
+ });
291
+
292
+ export function updateSessionArchived(id: string, archived: boolean | number): void {
293
+ stmts.updateSessionArchived.run(archived ? 1 : 0, id);
294
+ }
295
+
296
+ export function updateSessionSummary(id: string, summary: string | null): void {
297
+ stmts.updateSessionSummary.run(summary || null, id);
298
+ }
299
+
300
+ export function updateSessionTitle(id: string, title: string): void {
301
+ stmts.updateSessionTitle.run(title || '', id);
302
+ }
303
+
304
+ export function updateSessionLabel(id: string, label: string | null): void {
305
+ stmts.updateSessionLabel.run(label || null, id);
306
+ }
307
+
308
+ // ---- Notes ----
309
+
310
+ export function getNotes(sessionId: string): DbNoteRow[] {
311
+ return stmts.getNotesBySession.all(sessionId) as DbNoteRow[];
312
+ }
313
+
314
+ export function addNote(sessionId: string, text: string): DbNoteRow {
315
+ const now = Date.now();
316
+ const info = stmts.insertNote.run(sessionId, text, now, now);
317
+ return { id: Number(info.lastInsertRowid), session_id: sessionId, text, created_at: now, updated_at: now };
318
+ }
319
+
320
+ export function deleteNote(id: number): void {
321
+ stmts.deleteNote.run(id);
322
+ }
323
+
324
+ // ---- Search ----
325
+
326
+ export function searchSessions(params: SessionSearchParams = {}): SessionSearchResponse {
327
+ const { query, project, status, dateFrom, dateTo, archived, sortBy = 'started_at', sortDir = 'desc', page = 1, pageSize = 50 } = params;
328
+ const conditions: string[] = [];
329
+ const sqlParams: unknown[] = [];
330
+
331
+ if (project) { conditions.push('project_path = ?'); sqlParams.push(project); }
332
+ if (status) { conditions.push('status = ?'); sqlParams.push(status); }
333
+ if (dateFrom) { conditions.push('started_at >= ?'); sqlParams.push(dateFrom); }
334
+ if (dateTo) { conditions.push('started_at <= ?'); sqlParams.push(dateTo); }
335
+ if (archived === true || archived === 'true' || archived === 1) {
336
+ conditions.push('archived = 1');
337
+ } else if (archived !== 'all') {
338
+ conditions.push('(archived = 0 OR archived IS NULL)');
339
+ }
340
+
341
+ // Text search in prompts
342
+ if (query) {
343
+ conditions.push(`id IN (SELECT DISTINCT session_id FROM prompts WHERE text LIKE ?)`);
344
+ sqlParams.push(`%${query}%`);
345
+ }
346
+
347
+ const allowedSort = ['started_at', 'last_activity_at', 'project_name', 'status'];
348
+ const col = allowedSort.includes(sortBy || '') ? sortBy : 'started_at';
349
+ const dir = sortDir === 'asc' ? 'ASC' : 'DESC';
350
+
351
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
352
+ const countSql = `SELECT COUNT(*) as cnt FROM sessions ${where}`;
353
+ const dataSql = `SELECT * FROM sessions ${where} ORDER BY ${col} ${dir} LIMIT ? OFFSET ?`;
354
+
355
+ const offset = ((page || 1) - 1) * (pageSize || 50);
356
+ const total = (db.prepare(countSql).get(...sqlParams) as { cnt: number }).cnt;
357
+ const sessions = db.prepare(dataSql).all(...sqlParams, pageSize || 50, offset) as DbSessionRow[];
358
+
359
+ return { sessions, total, page: page || 1, pageSize: pageSize || 50 };
360
+ }
361
+
362
+ export function fullTextSearch(params: { query?: string; type?: string; page?: number; pageSize?: number } = {}): FullTextSearchResponse {
363
+ const { query, type = 'all', page = 1, pageSize = 50 } = params;
364
+ if (!query) return { results: [], total: 0, page, pageSize };
365
+ const pattern = `%${query}%`;
366
+ const offset = (page - 1) * pageSize;
367
+ const results: FullTextSearchResult[] = [];
368
+
369
+ if (type === 'all' || type === 'prompts') {
370
+ const rows = stmts.searchPrompts.all(pattern, pageSize, offset) as Array<DbPromptRow & { project_name: string }>;
371
+ for (const r of rows) {
372
+ results.push({ session_id: r.session_id, project_name: r.project_name, type: 'prompt', text: r.text, timestamp: r.timestamp });
373
+ }
374
+ }
375
+
376
+ if (type === 'all' || type === 'responses') {
377
+ const rows = stmts.searchResponses.all(pattern, pageSize, offset) as Array<DbResponseRow & { project_name: string }>;
378
+ for (const r of rows) {
379
+ results.push({ session_id: r.session_id, project_name: r.project_name, type: 'response', text: r.text_excerpt, timestamp: r.timestamp });
380
+ }
381
+ }
382
+
383
+ results.sort((a, b) => b.timestamp - a.timestamp);
384
+
385
+ let total = 0;
386
+ if (type === 'all' || type === 'prompts') total += (stmts.countSearchPrompts.get(pattern) as { cnt: number }).cnt;
387
+ if (type === 'all' || type === 'responses') total += (stmts.countSearchResponses.get(pattern) as { cnt: number }).cnt;
388
+
389
+ return { results: results.slice(0, pageSize), total, page, pageSize };
390
+ }
391
+
392
+ // ---- Analytics ----
393
+
394
+ export function getDistinctProjects(): DistinctProject[] {
395
+ return stmts.distinctProjects.all() as DistinctProject[];
396
+ }
397
+
398
+ export function getSummaryStats(): AnalyticsSummary {
399
+ const stats = stmts.summaryStats.get() as { total_sessions: number; active_sessions: number | null };
400
+ const promptCount = (stmts.totalPrompts.get() as { cnt: number }).cnt;
401
+ const toolCount = (stmts.totalToolCalls.get() as { cnt: number }).cnt;
402
+
403
+ const tools = stmts.toolBreakdown.all() as Array<{ tool_name: string; count: number }>;
404
+ const mostUsedTool = tools.length > 0 ? { tool_name: tools[0].tool_name, count: tools[0].count } : null;
405
+
406
+ const projects = stmts.activeProjects.all() as Array<{ project_path: string; project_name: string; session_count: number }>;
407
+ const busiestProject = projects.length > 0
408
+ ? { project_path: projects[0].project_path, name: projects[0].project_name, count: projects[0].session_count }
409
+ : null;
410
+
411
+ return {
412
+ total_sessions: stats.total_sessions,
413
+ active_sessions: stats.active_sessions || 0,
414
+ total_prompts: promptCount,
415
+ total_tool_calls: toolCount,
416
+ most_used_tool: mostUsedTool,
417
+ busiest_project: busiestProject,
418
+ };
419
+ }
420
+
421
+ export function getToolBreakdown(): ToolBreakdownEntry[] {
422
+ const tools = stmts.toolBreakdown.all() as Array<{ tool_name: string; count: number }>;
423
+ const total = tools.reduce((s, t) => s + t.count, 0);
424
+ return tools.map(t => ({
425
+ tool_name: t.tool_name,
426
+ count: t.count,
427
+ percentage: total > 0 ? Math.round(t.count / total * 1000) / 10 : 0,
428
+ }));
429
+ }
430
+
431
+ export function getActiveProjects(): ActiveProject[] {
432
+ return stmts.activeProjects.all() as ActiveProject[];
433
+ }
434
+
435
+ export function getHeatmap(): HeatmapEntry[] {
436
+ const rows = db.prepare('SELECT timestamp FROM events').all() as Array<{ timestamp: number }>;
437
+ const grid: Record<string, number> = {};
438
+ for (const { timestamp } of rows) {
439
+ const d = new Date(timestamp);
440
+ const jsDay = d.getDay();
441
+ const day = jsDay === 0 ? 6 : jsDay - 1;
442
+ const hour = d.getHours();
443
+ const key = `${day}-${hour}`;
444
+ grid[key] = (grid[key] || 0) + 1;
445
+ }
446
+ const result: HeatmapEntry[] = [];
447
+ for (let day = 0; day < 7; day++) {
448
+ for (let hour = 0; hour < 24; hour++) {
449
+ const key = `${day}-${hour}`;
450
+ if (grid[key]) result.push({ day_of_week: day, hour, count: grid[key] });
451
+ }
452
+ }
453
+ return result;
454
+ }
455
+
456
+ // ---- Session ID migration ----
457
+
458
+ export const migrateSessionId: (oldId: string, newId: string) => void = db.transaction((oldId: string, newId: string) => {
459
+ db.prepare('UPDATE prompts SET session_id = ? WHERE session_id = ?').run(newId, oldId);
460
+ db.prepare('UPDATE responses SET session_id = ? WHERE session_id = ?').run(newId, oldId);
461
+ db.prepare('UPDATE tool_calls SET session_id = ? WHERE session_id = ?').run(newId, oldId);
462
+ db.prepare('UPDATE events SET session_id = ? WHERE session_id = ?').run(newId, oldId);
463
+ db.prepare('UPDATE notes SET session_id = ? WHERE session_id = ?').run(newId, oldId);
464
+ });
465
+
466
+ // ---- Shutdown ----
467
+
468
+ export function closeDb(): void {
469
+ try {
470
+ db.close();
471
+ log.info('db', 'SQLite database closed');
472
+ } catch (err: unknown) {
473
+ log.warn('db', `Failed to close database: ${(err as Error).message}`);
474
+ }
475
+ }
@@ -0,0 +1,3 @@
1
+ import type { ServerConfig } from '../src/types/settings.js';
2
+
3
+ export function ensureHooksInstalled(config: ServerConfig): void;
@@ -0,0 +1,108 @@
1
+ // hookProcessor.ts — Shared hook event processing pipeline
2
+ // Used by both hookRouter.ts (HTTP) and mqReader.ts (file-based MQ)
3
+ import { handleEvent } from './sessionStore.js';
4
+ import { broadcast } from './wsManager.js';
5
+ import { recordHook, getStats } from './hookStats.js';
6
+ import { KNOWN_EVENTS, WS_TYPES } from './constants.js';
7
+ import log from './logger.js';
8
+ import type { HookPayload } from '../src/types/hook.js';
9
+ import type { HandleEventResult } from '../src/types/session.js';
10
+
11
+ /**
12
+ * Validate a hook payload. Returns null if valid, or an error string if invalid.
13
+ */
14
+ function validateHookPayload(hookData: unknown): string | null {
15
+ if (!hookData || typeof hookData !== 'object') {
16
+ return 'payload must be a JSON object';
17
+ }
18
+ const data = hookData as Record<string, unknown>;
19
+ // session_id: required, must be string, reasonable length
20
+ if (!data.session_id) {
21
+ return 'missing session_id';
22
+ }
23
+ if (typeof data.session_id !== 'string') {
24
+ return 'session_id must be a string';
25
+ }
26
+ if (data.session_id.length > 256) {
27
+ return 'session_id too long (max 256 chars)';
28
+ }
29
+ // hook_event_name: required, must be a known event type
30
+ const eventName = (data.hook_event_name || data.event) as string | undefined;
31
+ if (!eventName) {
32
+ return 'missing hook_event_name';
33
+ }
34
+ if (typeof eventName !== 'string' || !KNOWN_EVENTS.has(eventName)) {
35
+ return `unknown event type: ${String(eventName).substring(0, 64)}`;
36
+ }
37
+ // claude_pid: if present, must be a positive integer
38
+ if (data.claude_pid != null) {
39
+ const pid = Number(data.claude_pid);
40
+ if (!Number.isFinite(pid) || pid <= 0 || Math.floor(pid) !== pid) {
41
+ return 'claude_pid must be a positive integer';
42
+ }
43
+ }
44
+ // timestamp: if present, must be valid number
45
+ if (data.timestamp != null) {
46
+ const ts = Number(data.timestamp);
47
+ if (!Number.isFinite(ts)) {
48
+ return 'timestamp must be a valid number';
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Process a hook event from any transport (HTTP or MQ).
56
+ * Validates, calls handleEvent(), records stats, broadcasts to WebSocket clients.
57
+ */
58
+ export function processHookEvent(
59
+ hookData: HookPayload,
60
+ source: 'http' | 'mq' = 'http',
61
+ ): HandleEventResult | { error: string } | null {
62
+ const receivedAt = Date.now();
63
+
64
+ const validationError = validateHookPayload(hookData);
65
+ if (validationError) {
66
+ log.warn('hook', `Rejected hook payload (via ${source}): ${validationError}`);
67
+ return { error: validationError };
68
+ }
69
+
70
+ log.debug('hook', `Event: ${hookData.hook_event_name || 'unknown'} session=${hookData.session_id} via=${source}`);
71
+ log.debugJson('hook', 'Hook payload', hookData);
72
+
73
+ // Measure server processing time
74
+ const processStart = Date.now();
75
+ let delta: HandleEventResult | null;
76
+ try {
77
+ delta = handleEvent(hookData);
78
+ } catch (e: unknown) {
79
+ const msg = e instanceof Error ? e.message : String(e);
80
+ log.error('hook', `handleEvent threw for session=${hookData.session_id}: ${msg}`);
81
+ return null;
82
+ }
83
+ const processingTime = Date.now() - processStart;
84
+
85
+ // Calculate delivery latency (hook_sent_at is seconds * 1000 from bash `date +%s`)
86
+ let deliveryLatency: number | null = null;
87
+ if (hookData.hook_sent_at) {
88
+ deliveryLatency = receivedAt - hookData.hook_sent_at;
89
+ if (deliveryLatency < 0) deliveryLatency = 0;
90
+ }
91
+
92
+ // Record stats
93
+ const eventType = hookData.hook_event_name || 'unknown';
94
+ recordHook(eventType, deliveryLatency, processingTime);
95
+
96
+ // Broadcast to WebSocket clients
97
+ if (delta) {
98
+ log.debug('hook', `Broadcasting session_update for ${hookData.session_id} status=${delta.session?.status}`);
99
+ broadcast({ type: WS_TYPES.SESSION_UPDATE, ...delta });
100
+ if (delta.team) {
101
+ log.debug('hook', `Broadcasting team_update for team=${delta.team.teamId}`);
102
+ broadcast({ type: WS_TYPES.TEAM_UPDATE, team: delta.team });
103
+ }
104
+ broadcast({ type: WS_TYPES.HOOK_STATS, stats: getStats() });
105
+ }
106
+
107
+ return delta;
108
+ }
@@ -0,0 +1,18 @@
1
+ // hookRouter.ts — POST /api/hooks endpoint (HTTP transport adapter)
2
+ import { Router } from 'express';
3
+ import type { Request, Response } from 'express';
4
+ import { processHookEvent } from './hookProcessor.js';
5
+
6
+ const router = Router();
7
+
8
+ router.post('/', (req: Request, res: Response) => {
9
+ const hookData = req.body;
10
+ const result = processHookEvent(hookData, 'http');
11
+ if (result && 'error' in result) {
12
+ res.status(400).json({ success: false, error: result.error });
13
+ return;
14
+ }
15
+ res.json({ ok: true });
16
+ });
17
+
18
+ export default router;