browsertrack 0.0.1 → 0.1.1

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 (110) hide show
  1. package/.github/workflows/docs.yml +54 -0
  2. package/AGENTS.md +133 -0
  3. package/README.md +143 -0
  4. package/dist/chunk-6VA7GBAO.js +246 -0
  5. package/dist/chunk-6VA7GBAO.js.map +1 -0
  6. package/dist/chunk-G2Y3CXCY.js +659 -0
  7. package/dist/chunk-G2Y3CXCY.js.map +1 -0
  8. package/dist/chunk-ILRYKMME.js +1228 -0
  9. package/dist/chunk-ILRYKMME.js.map +1 -0
  10. package/dist/chunk-SKCMT2DE.js +2641 -0
  11. package/dist/chunk-SKCMT2DE.js.map +1 -0
  12. package/dist/chunk-SPCIROIU.js +616 -0
  13. package/dist/chunk-SPCIROIU.js.map +1 -0
  14. package/dist/cli/index.js +2942 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/client/index.cjs +2780 -0
  17. package/dist/client/index.d.ts +211 -0
  18. package/dist/client/index.js +20 -0
  19. package/dist/client/index.js.map +1 -0
  20. package/dist/client.iife.js +630 -0
  21. package/dist/core/index.d.ts +86 -0
  22. package/dist/core/index.js +37 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/daemon/index.d.ts +58 -0
  25. package/dist/daemon/index.js +32 -0
  26. package/dist/daemon/index.js.map +1 -0
  27. package/dist/engine-CeT9URuN.d.ts +161 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +56 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/mcp/index.d.ts +11 -0
  32. package/dist/mcp/index.js +12 -0
  33. package/dist/mcp/index.js.map +1 -0
  34. package/dist/notes-CBvN91Wf.d.ts +260 -0
  35. package/dist/projects-CY8ungMt.d.ts +99 -0
  36. package/dist/server-Dd8NX2Mk.d.ts +55 -0
  37. package/docboot.config.js +16 -0
  38. package/docs/cli.md +51 -0
  39. package/docs/closed-loop-verification.md +57 -0
  40. package/docs/getting-started.md +122 -0
  41. package/docs/incidents-diagnostics.md +61 -0
  42. package/docs/index.md +46 -0
  43. package/docs/mcp-reference.md +128 -0
  44. package/docs/security-privacy.md +32 -0
  45. package/docs/visual-notes.md +67 -0
  46. package/examples/test-app/index.html +299 -0
  47. package/package.json +56 -7
  48. package/packages/cli/src/index.ts +413 -0
  49. package/packages/client/package.json +28 -0
  50. package/packages/client/src/breadcrumbs.ts +30 -0
  51. package/packages/client/src/client.ts +278 -0
  52. package/packages/client/src/commands/handler.ts +301 -0
  53. package/packages/client/src/config.ts +37 -0
  54. package/packages/client/src/index.ts +50 -0
  55. package/packages/client/src/interceptors/console.ts +68 -0
  56. package/packages/client/src/interceptors/interaction.ts +119 -0
  57. package/packages/client/src/interceptors/navigation.ts +93 -0
  58. package/packages/client/src/interceptors/network.ts +165 -0
  59. package/packages/client/src/interceptors/runtime.ts +65 -0
  60. package/packages/client/src/notes/inspector.ts +1553 -0
  61. package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
  62. package/packages/client/src/screenshot/driver.ts +14 -0
  63. package/packages/client/src/transport/websocket.ts +194 -0
  64. package/packages/client/tsconfig.json +8 -0
  65. package/packages/core/package.json +26 -0
  66. package/packages/core/src/fingerprint.ts +120 -0
  67. package/packages/core/src/index.ts +9 -0
  68. package/packages/core/src/redaction.ts +174 -0
  69. package/packages/core/src/selector.ts +77 -0
  70. package/packages/core/src/types/commands.ts +101 -0
  71. package/packages/core/src/types/events.ts +108 -0
  72. package/packages/core/src/types/incidents.ts +54 -0
  73. package/packages/core/src/types/notes.ts +106 -0
  74. package/packages/core/src/types/probes.ts +44 -0
  75. package/packages/core/src/types/projects.ts +20 -0
  76. package/packages/core/tsconfig.json +8 -0
  77. package/packages/daemon/src/config.ts +29 -0
  78. package/packages/daemon/src/incidents/engine.ts +211 -0
  79. package/packages/daemon/src/index.ts +18 -0
  80. package/packages/daemon/src/notes/engine.ts +92 -0
  81. package/packages/daemon/src/notes/verification.ts +191 -0
  82. package/packages/daemon/src/server/daemon.ts +112 -0
  83. package/packages/daemon/src/server/http.ts +165 -0
  84. package/packages/daemon/src/server/ws.ts +246 -0
  85. package/packages/daemon/src/session/manager.ts +147 -0
  86. package/packages/daemon/src/storage/db.ts +742 -0
  87. package/packages/daemon/src/storage/screenshot-store.ts +49 -0
  88. package/packages/daemon/src/verification/engine.ts +264 -0
  89. package/packages/mcp/src/handlers.ts +398 -0
  90. package/packages/mcp/src/index.ts +3 -0
  91. package/packages/mcp/src/server.ts +86 -0
  92. package/packages/mcp/src/tools.ts +236 -0
  93. package/src/index.ts +4 -0
  94. package/test/client/interceptors.test.ts +130 -0
  95. package/test/core/fingerprint.test.ts +53 -0
  96. package/test/core/notes.test.ts +66 -0
  97. package/test/core/redaction.test.ts +60 -0
  98. package/test/daemon/incident-engine.test.ts +98 -0
  99. package/test/daemon/notes-storage.test.ts +130 -0
  100. package/test/daemon/notes-verification.test.ts +135 -0
  101. package/test/daemon/storage.test.ts +123 -0
  102. package/test/daemon/verification-engine.test.ts +88 -0
  103. package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
  104. package/test/e2e/visual-notes-e2e.test.ts +205 -0
  105. package/test/mcp/handlers.test.ts +116 -0
  106. package/test/mcp/notes.test.ts +107 -0
  107. package/tsconfig.base.json +17 -0
  108. package/tsconfig.json +26 -0
  109. package/tsup.config.ts +54 -0
  110. package/vitest.config.ts +9 -0
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { StorageDB } from '../storage/db.js';
6
+ import type { SessionManager } from '../session/manager.js';
7
+
8
+ export function createHttpHandler(db: StorageDB, sessionManager: SessionManager, baseScreenshotsDir: string) {
9
+ return (req: http.IncomingMessage, res: http.ServerResponse) => {
10
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
11
+ const pathname = parsedUrl.pathname;
12
+
13
+ // CORS headers for local development access
14
+ res.setHeader('Access-Control-Allow-Origin', '*');
15
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
16
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
17
+
18
+ if (req.method === 'OPTIONS') {
19
+ res.writeHead(204);
20
+ res.end();
21
+ return;
22
+ }
23
+
24
+ // 1. Health check
25
+ if (pathname === '/health' || pathname === '/') {
26
+ res.writeHead(200, { 'Content-Type': 'application/json' });
27
+ res.end(
28
+ JSON.stringify({
29
+ status: 'ok',
30
+ name: 'browsertrack',
31
+ version: '0.1.0',
32
+ activeSessions: sessionManager.getActiveCount(),
33
+ timestamp: new Date().toISOString(),
34
+ })
35
+ );
36
+ return;
37
+ }
38
+
39
+ // 2. Client script tag bundle
40
+ if (pathname === '/client.js' || pathname === '/browserdiag.js') {
41
+ let scriptContent = '';
42
+ try {
43
+ const __filename = fileURLToPath(import.meta.url);
44
+ const __dirname = path.dirname(__filename);
45
+ // Look in dist or adjacent package
46
+ const candidatePaths = [
47
+ path.resolve(__dirname, '../client.iife.js'),
48
+ path.resolve(__dirname, '../../dist/client.iife.js'),
49
+ path.resolve(__dirname, '../../../dist/client.iife.js'),
50
+ ];
51
+
52
+ for (const p of candidatePaths) {
53
+ if (fs.existsSync(p)) {
54
+ scriptContent = fs.readFileSync(p, 'utf-8');
55
+ break;
56
+ }
57
+ }
58
+ } catch {}
59
+
60
+ if (!scriptContent) {
61
+ scriptContent = `console.warn("[BrowserTrack] Standalone client bundle not built yet. Run 'npm run build'.");`;
62
+ }
63
+
64
+ res.writeHead(200, {
65
+ 'Content-Type': 'application/javascript; charset=utf-8',
66
+ 'Cache-Control': 'no-cache',
67
+ });
68
+ res.end(scriptContent);
69
+ return;
70
+ }
71
+
72
+ // 3. API - Projects
73
+ if (pathname === '/api/projects') {
74
+ const projects = db.listProjects();
75
+ res.writeHead(200, { 'Content-Type': 'application/json' });
76
+ res.end(JSON.stringify({ ok: true, projects }));
77
+ return;
78
+ }
79
+
80
+ // 4. API - Sessions
81
+ if (pathname === '/api/sessions') {
82
+ const projectId = parsedUrl.searchParams.get('project') || undefined;
83
+ const activeOnly = parsedUrl.searchParams.get('active') === 'true';
84
+ const sessions = db.listSessions(projectId, activeOnly);
85
+ res.writeHead(200, { 'Content-Type': 'application/json' });
86
+ res.end(JSON.stringify({ ok: true, sessions }));
87
+ return;
88
+ }
89
+
90
+ // 5. API - Incidents
91
+ if (pathname === '/api/incidents') {
92
+ const projectId = parsedUrl.searchParams.get('project') || undefined;
93
+ const status = (parsedUrl.searchParams.get('status') as any) || undefined;
94
+ const limit = parseInt(parsedUrl.searchParams.get('limit') || '50', 10);
95
+ const incidents = db.listIncidents({ projectId, status, limit });
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ ok: true, incidents }));
98
+ return;
99
+ }
100
+
101
+ // 6. API - Incident Detail
102
+ if (pathname.startsWith('/api/incidents/')) {
103
+ const incidentId = pathname.replace('/api/incidents/', '');
104
+ const incident = db.getIncident(incidentId);
105
+ if (!incident) {
106
+ res.writeHead(404, { 'Content-Type': 'application/json' });
107
+ res.end(JSON.stringify({ ok: false, error: 'Incident not found' }));
108
+ return;
109
+ }
110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ ok: true, incident }));
112
+ return;
113
+ }
114
+
115
+ // 7. API - Notes
116
+ if (pathname === '/api/notes') {
117
+ const projectId = parsedUrl.searchParams.get('project') || undefined;
118
+ const status = (parsedUrl.searchParams.get('status') as any) || undefined;
119
+ const limit = parseInt(parsedUrl.searchParams.get('limit') || '50', 10);
120
+ const notes = db.listNotes({ projectId, status, limit });
121
+ res.writeHead(200, { 'Content-Type': 'application/json' });
122
+ res.end(JSON.stringify({ ok: true, notes }));
123
+ return;
124
+ }
125
+
126
+ // 8. API - Note Detail
127
+ if (pathname.startsWith('/api/notes/')) {
128
+ const noteId = pathname.replace('/api/notes/', '');
129
+ const note = db.getNote(noteId);
130
+ if (!note) {
131
+ res.writeHead(404, { 'Content-Type': 'application/json' });
132
+ res.end(JSON.stringify({ ok: false, error: 'Note not found' }));
133
+ return;
134
+ }
135
+ res.writeHead(200, { 'Content-Type': 'application/json' });
136
+ res.end(JSON.stringify({ ok: true, note }));
137
+ return;
138
+ }
139
+
140
+ // 9. Screenshots Serving
141
+ if (pathname.startsWith('/screenshots/')) {
142
+ const relativePath = pathname.replace('/screenshots/', '');
143
+ const filePath = path.join(baseScreenshotsDir, relativePath);
144
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
145
+ const ext = path.extname(filePath).toLowerCase();
146
+ const mimeTypes: Record<string, string> = {
147
+ '.webp': 'image/webp',
148
+ '.png': 'image/png',
149
+ '.jpg': 'image/jpeg',
150
+ '.jpeg': 'image/jpeg',
151
+ };
152
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
153
+ fs.createReadStream(filePath).pipe(res);
154
+ return;
155
+ }
156
+ res.writeHead(404, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ ok: false, error: 'Screenshot not found' }));
158
+ return;
159
+ }
160
+
161
+ // Not found
162
+ res.writeHead(404, { 'Content-Type': 'application/json' });
163
+ res.end(JSON.stringify({ ok: false, error: 'Not found' }));
164
+ };
165
+ }
@@ -0,0 +1,246 @@
1
+ import crypto from 'node:crypto';
2
+ import type { WebSocket, WebSocketServer } from 'ws';
3
+ import type { ClientEventMessage, CommandResponse, HelloMessage } from '../../../core/src/index.js';
4
+ import type { IncidentEngine } from '../incidents/engine.js';
5
+ import type { NotesEngine } from '../notes/engine.js';
6
+ import type { SessionManager } from '../session/manager.js';
7
+ import type { StorageDB } from '../storage/db.js';
8
+
9
+ export function setupWebSocketServer(
10
+ wss: WebSocketServer,
11
+ db: StorageDB,
12
+ sessionManager: SessionManager,
13
+ incidentEngine: IncidentEngine,
14
+ notesEngine?: NotesEngine,
15
+ maxEventsPerSession = 1000,
16
+ verbose = false
17
+ ): void {
18
+ wss.on('connection', (ws: WebSocket) => {
19
+ let currentSessionId: string | null = null;
20
+
21
+ ws.on('message', (raw: string | Buffer) => {
22
+ try {
23
+ const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
24
+ const data = JSON.parse(text);
25
+
26
+ // 1. Hello handshake
27
+ if (data.type === 'hello') {
28
+ const hello = data as HelloMessage;
29
+ const sessionId = `sess_${crypto.randomUUID().slice(0, 8)}`;
30
+ currentSessionId = sessionId;
31
+
32
+ // Determine or create project
33
+ let project = hello.projectId ? db.getProject(hello.projectId) : null;
34
+ if (!project && hello.origin) {
35
+ project = db.getProjectByOrigin(hello.origin);
36
+ }
37
+ if (!project) {
38
+ // Auto-create project based on origin or hostname
39
+ let projName = 'default';
40
+ try {
41
+ const url = new URL(hello.origin || 'http://localhost');
42
+ projName = url.port ? `app-${url.port}` : url.hostname;
43
+ } catch {
44
+ projName = 'app';
45
+ }
46
+ project = db.upsertProject({
47
+ id: `proj_${crypto.randomUUID().slice(0, 8)}`,
48
+ name: projName,
49
+ origin: hello.origin || 'http://localhost',
50
+ });
51
+ }
52
+
53
+ const now = new Date().toISOString();
54
+ db.upsertSession({
55
+ id: sessionId,
56
+ projectId: project.id,
57
+ origin: hello.origin || '',
58
+ url: hello.url || '',
59
+ title: hello.title || '',
60
+ userAgent: hello.userAgent || '',
61
+ connectedAt: now,
62
+ lastSeenAt: now,
63
+ active: true,
64
+ });
65
+
66
+ sessionManager.registerSocket(sessionId, ws, hello.origin || '', project.id);
67
+
68
+ ws.send(
69
+ JSON.stringify({
70
+ type: 'hello_ack',
71
+ sessionId,
72
+ projectId: project.id,
73
+ projectName: project.name,
74
+ })
75
+ );
76
+
77
+ // Sync existing notes for this project on load
78
+ const existingNotes = db.listNotes({ projectId: project.id, limit: 100 });
79
+ ws.send(
80
+ JSON.stringify({
81
+ type: 'notes_sync',
82
+ notes: existingNotes,
83
+ })
84
+ );
85
+
86
+ if (verbose) {
87
+ console.log(`[BrowserTrack] New session connected: ${sessionId} (${project.name} @ ${hello.origin})`);
88
+ }
89
+ return;
90
+ }
91
+
92
+ // 2. Client Event (runtime error, console, network, breadcrumb)
93
+ if (data.type === 'event') {
94
+ const eventMsg = data as ClientEventMessage;
95
+ const sessionId = eventMsg.sessionId || currentSessionId;
96
+ if (!sessionId) return;
97
+
98
+ const eventId = `evt_${crypto.randomUUID().slice(0, 8)}`;
99
+ db.insertEvent({
100
+ id: eventId,
101
+ sessionId,
102
+ eventType: eventMsg.eventType,
103
+ payload: eventMsg.payload,
104
+ timestamp: eventMsg.timestamp || Date.now(),
105
+ route: eventMsg.route,
106
+ url: eventMsg.url,
107
+ });
108
+
109
+ // Session retention prune
110
+ db.pruneSessionEvents(sessionId, maxEventsPerSession);
111
+
112
+ // Incident processing
113
+ const incident = incidentEngine.processClientEvent(eventMsg);
114
+ if (incident && verbose) {
115
+ console.log(
116
+ `[BrowserTrack] Incident recorded: ${incident.id} (${incident.type}: ${incident.message}) [${incident.occurrences}x]`
117
+ );
118
+ }
119
+ return;
120
+ }
121
+
122
+ // 3. Visual Note Creation
123
+ if (data.type === 'create_note' && notesEngine) {
124
+ const note = notesEngine.createNoteFromClient({
125
+ sessionId: data.sessionId || currentSessionId || '',
126
+ noteType: data.noteType,
127
+ message: data.message,
128
+ route: data.route,
129
+ url: data.url,
130
+ viewport: data.viewport,
131
+ scroll: data.scroll,
132
+ target: data.target,
133
+ elementContext: data.elementContext,
134
+ region: data.region,
135
+ screenshot: data.screenshot,
136
+ incidentId: data.incidentId,
137
+ });
138
+
139
+ if (verbose) {
140
+ console.log(`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")`);
141
+ }
142
+
143
+ ws.send(
144
+ JSON.stringify({
145
+ type: 'note_created_ack',
146
+ noteId: note.id,
147
+ status: note.status,
148
+ })
149
+ );
150
+
151
+ // Broadcast updated notes to all active browser tabs in this project
152
+ const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
153
+ sessionManager.broadcastToProject(note.projectId, {
154
+ type: 'notes_sync',
155
+ notes: allNotes,
156
+ });
157
+ return;
158
+ }
159
+
160
+ // 4. Visual Note Status / Delete Actions
161
+ if (data.type === 'resolve_note' && data.noteId) {
162
+ const note = db.getNote(data.noteId);
163
+ if (note) {
164
+ db.updateNoteStatus(data.noteId, 'RESOLVED');
165
+ const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
166
+ sessionManager.broadcastToProject(note.projectId, {
167
+ type: 'notes_sync',
168
+ notes: allNotes,
169
+ });
170
+ }
171
+ return;
172
+ }
173
+
174
+ if (data.type === 'reopen_note' && data.noteId) {
175
+ const note = db.getNote(data.noteId);
176
+ if (note) {
177
+ db.updateNoteStatus(data.noteId, 'OPEN');
178
+ const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
179
+ sessionManager.broadcastToProject(note.projectId, {
180
+ type: 'notes_sync',
181
+ notes: allNotes,
182
+ });
183
+ }
184
+ return;
185
+ }
186
+
187
+ if (data.type === 'delete_note' && data.noteId) {
188
+ const note = db.getNote(data.noteId);
189
+ if (note) {
190
+ db.deleteNote(data.noteId);
191
+ const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
192
+ sessionManager.broadcastToProject(note.projectId, {
193
+ type: 'notes_sync',
194
+ notes: allNotes,
195
+ });
196
+ }
197
+ return;
198
+ }
199
+
200
+ if (data.type === 'get_notes') {
201
+ const session = currentSessionId ? db.getSession(currentSessionId) : null;
202
+ const projectId = data.projectId || session?.projectId;
203
+ if (projectId) {
204
+ const allNotes = db.listNotes({ projectId, limit: 100 });
205
+ ws.send(
206
+ JSON.stringify({
207
+ type: 'notes_sync',
208
+ notes: allNotes,
209
+ })
210
+ );
211
+ }
212
+ return;
213
+ }
214
+
215
+ // 5. Command Response
216
+ if (data.type === 'command_response') {
217
+ const res = data.response as CommandResponse;
218
+ const sessionId = data.sessionId || currentSessionId;
219
+ if (sessionId && res) {
220
+ sessionManager.handleCommandResponse(sessionId, res);
221
+ }
222
+ return;
223
+ }
224
+ } catch (err: any) {
225
+ if (verbose) {
226
+ console.error('[BrowserTrack] WebSocket message error:', err?.message);
227
+ }
228
+ }
229
+ });
230
+
231
+ ws.on('close', () => {
232
+ if (currentSessionId) {
233
+ sessionManager.unregisterSocket(currentSessionId);
234
+ if (verbose) {
235
+ console.log(`[BrowserTrack] Session disconnected: ${currentSessionId}`);
236
+ }
237
+ }
238
+ });
239
+
240
+ ws.on('error', () => {
241
+ if (currentSessionId) {
242
+ sessionManager.unregisterSocket(currentSessionId);
243
+ }
244
+ });
245
+ });
246
+ }
@@ -0,0 +1,147 @@
1
+ import type { WebSocket } from 'ws';
2
+ import type { ClientCommand, CommandResponse, Session } from '../../../core/src/index.js';
3
+ import type { StorageDB } from '../storage/db.js';
4
+
5
+ interface ActiveSessionSocket {
6
+ sessionId: string;
7
+ ws: WebSocket;
8
+ origin: string;
9
+ projectId: string;
10
+ pendingCommands: Map<
11
+ string,
12
+ {
13
+ resolve: (res: CommandResponse) => void;
14
+ reject: (err: Error) => void;
15
+ timer: any;
16
+ }
17
+ >;
18
+ }
19
+
20
+ export class SessionManager {
21
+ private activeSockets: Map<string, ActiveSessionSocket> = new Map();
22
+ private db: StorageDB;
23
+
24
+ constructor(db: StorageDB) {
25
+ this.db = db;
26
+ }
27
+
28
+ public registerSocket(sessionId: string, ws: WebSocket, origin: string, projectId: string): void {
29
+ this.activeSockets.set(sessionId, {
30
+ sessionId,
31
+ ws,
32
+ origin,
33
+ projectId,
34
+ pendingCommands: new Map(),
35
+ });
36
+ }
37
+
38
+ public unregisterSocket(sessionId: string): void {
39
+ const active = this.activeSockets.get(sessionId);
40
+ if (active) {
41
+ for (const pending of active.pendingCommands.values()) {
42
+ clearTimeout(pending.timer);
43
+ pending.reject(new Error('Session disconnected before command completed'));
44
+ }
45
+ active.pendingCommands.clear();
46
+ this.activeSockets.delete(sessionId);
47
+ this.db.deactivateSession(sessionId);
48
+ }
49
+ }
50
+
51
+ public handleCommandResponse(sessionId: string, response: CommandResponse): void {
52
+ const active = this.activeSockets.get(sessionId);
53
+ if (!active) return;
54
+
55
+ const pending = active.pendingCommands.get(response.id);
56
+ if (pending) {
57
+ clearTimeout(pending.timer);
58
+ active.pendingCommands.delete(response.id);
59
+ pending.resolve(response);
60
+ }
61
+ }
62
+
63
+ public async sendCommand<T = any>(sessionId: string, command: ClientCommand<T>, timeoutMs = 5000): Promise<CommandResponse> {
64
+ const active = this.activeSockets.get(sessionId);
65
+ if (!active || active.ws.readyState !== 1 /* WebSocket.OPEN */) {
66
+ return {
67
+ id: command.id,
68
+ ok: false,
69
+ error: `Session ${sessionId} is not actively connected`,
70
+ };
71
+ }
72
+
73
+ return new Promise<CommandResponse>((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ active.pendingCommands.delete(command.id);
76
+ resolve({
77
+ id: command.id,
78
+ ok: false,
79
+ error: `Command ${command.type} timed out after ${timeoutMs}ms`,
80
+ });
81
+ }, timeoutMs);
82
+
83
+ active.pendingCommands.set(command.id, {
84
+ resolve,
85
+ reject,
86
+ timer,
87
+ });
88
+
89
+ try {
90
+ active.ws.send(
91
+ JSON.stringify({
92
+ type: 'command',
93
+ command,
94
+ })
95
+ );
96
+ } catch (err: any) {
97
+ clearTimeout(timer);
98
+ active.pendingCommands.delete(command.id);
99
+ resolve({
100
+ id: command.id,
101
+ ok: false,
102
+ error: err?.message || 'Failed to send command over WebSocket',
103
+ });
104
+ }
105
+ });
106
+ }
107
+
108
+ public getActiveSessionForProject(projectId: string): Session | null {
109
+ for (const active of this.activeSockets.values()) {
110
+ if (active.projectId === projectId) {
111
+ return this.db.getSession(active.sessionId);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ public getAnyActiveSession(): Session | null {
118
+ const first = this.activeSockets.keys().next().value;
119
+ if (!first) return null;
120
+ return this.db.getSession(first);
121
+ }
122
+
123
+ public getActiveCount(): number {
124
+ return this.activeSockets.size;
125
+ }
126
+
127
+ public broadcastToProject(projectId: string, message: any): void {
128
+ const raw = typeof message === 'string' ? message : JSON.stringify(message);
129
+ for (const active of this.activeSockets.values()) {
130
+ if (active.projectId === projectId && active.ws.readyState === 1 /* OPEN */) {
131
+ try {
132
+ active.ws.send(raw);
133
+ } catch {}
134
+ }
135
+ }
136
+ }
137
+
138
+ public sendToSession(sessionId: string, message: any): void {
139
+ const active = this.activeSockets.get(sessionId);
140
+ if (active && active.ws.readyState === 1) {
141
+ try {
142
+ const raw = typeof message === 'string' ? message : JSON.stringify(message);
143
+ active.ws.send(raw);
144
+ } catch {}
145
+ }
146
+ }
147
+ }