claudehq 1.0.1 → 1.0.2
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.
- package/bin/cli.js +2 -2
- package/lib/core/claude-events.js +308 -0
- package/lib/core/config.js +79 -0
- package/lib/core/event-bus.js +127 -0
- package/lib/core/sse.js +96 -0
- package/lib/core/watchers.js +127 -0
- package/lib/data/conversation.js +195 -0
- package/lib/data/plans.js +247 -0
- package/lib/data/tasks.js +263 -0
- package/lib/data/todos.js +205 -0
- package/lib/index.js +400 -0
- package/lib/routes/api.js +611 -0
- package/lib/server.js +778 -79
- package/lib/sessions/manager.js +870 -0
- package/package.json +10 -4
- package/public/index.html +6765 -0
package/bin/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ program
|
|
|
17
17
|
.action((options) => {
|
|
18
18
|
process.env.PORT = options.port;
|
|
19
19
|
process.env.NO_OPEN = options.open ? '' : '1';
|
|
20
|
-
require('../lib/
|
|
20
|
+
require('../lib/index.js');
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
program
|
|
@@ -50,7 +50,7 @@ program
|
|
|
50
50
|
.action(() => {
|
|
51
51
|
// If no command specified, run start
|
|
52
52
|
process.env.PORT = process.env.PORT || '3456';
|
|
53
|
-
require('../lib/
|
|
53
|
+
require('../lib/index.js');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
program.parse();
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Events - Real-time event pipeline for Claude Code hooks
|
|
3
|
+
*
|
|
4
|
+
* Captures, processes, stores, and broadcasts Claude Code events.
|
|
5
|
+
* Events come from hooks (PreToolUse, PostToolUse, Stop, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { EVENTS_DIR, MAX_EVENTS_IN_MEMORY } = require('./config');
|
|
11
|
+
const { eventBus, EventTypes } = require('./event-bus');
|
|
12
|
+
const { broadcastUpdate } = require('./sse');
|
|
13
|
+
|
|
14
|
+
const EVENTS_FILE = path.join(EVENTS_DIR, 'events.jsonl');
|
|
15
|
+
const MAX_EVENTS = MAX_EVENTS_IN_MEMORY || 1000;
|
|
16
|
+
|
|
17
|
+
// Event state
|
|
18
|
+
const claudeEvents = [];
|
|
19
|
+
const seenEventIds = new Set();
|
|
20
|
+
const pendingToolUses = new Map(); // Track pre_tool_use for duration calculation
|
|
21
|
+
let lastEventsFileSize = 0;
|
|
22
|
+
|
|
23
|
+
// Session update callback (set by sessions module to avoid circular dep)
|
|
24
|
+
let sessionUpdateCallback = null;
|
|
25
|
+
|
|
26
|
+
// Conversation file finder callback (set by conversation module)
|
|
27
|
+
let findConversationFileCallback = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Set callback for updating sessions from events
|
|
31
|
+
* @param {Function} callback - Function to call with event
|
|
32
|
+
*/
|
|
33
|
+
function setSessionUpdateCallback(callback) {
|
|
34
|
+
sessionUpdateCallback = callback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Set callback for finding conversation files
|
|
39
|
+
* @param {Function} callback - Function to call with sessionId
|
|
40
|
+
*/
|
|
41
|
+
function setConversationFileCallback(callback) {
|
|
42
|
+
findConversationFileCallback = callback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the latest assistant text from a conversation JSONL file
|
|
47
|
+
* @param {string} sessionId - Claude session ID
|
|
48
|
+
* @returns {string} Latest assistant text or empty string
|
|
49
|
+
*/
|
|
50
|
+
function getLatestAssistantText(sessionId) {
|
|
51
|
+
if (!findConversationFileCallback) return '';
|
|
52
|
+
|
|
53
|
+
const filePath = findConversationFileCallback(sessionId);
|
|
54
|
+
if (!filePath) return '';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const fileData = fs.readFileSync(filePath, 'utf-8');
|
|
58
|
+
const lines = fileData.trim().split('\n');
|
|
59
|
+
|
|
60
|
+
// Parse backwards to find the most recent assistant text
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
62
|
+
try {
|
|
63
|
+
const entry = JSON.parse(lines[i]);
|
|
64
|
+
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
65
|
+
for (const block of entry.message.content) {
|
|
66
|
+
if (block.type === 'text' && block.text) {
|
|
67
|
+
return block.text;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Skip malformed lines
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// File read error
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Process and enrich event (calculate tool duration, etc.)
|
|
83
|
+
* @param {Object} event - Raw event
|
|
84
|
+
* @returns {Object} Processed event
|
|
85
|
+
*/
|
|
86
|
+
function processClaudeEvent(event) {
|
|
87
|
+
// Track pre_tool_use for duration calculation
|
|
88
|
+
if (event.type === 'pre_tool_use' && event.toolUseId) {
|
|
89
|
+
pendingToolUses.set(event.toolUseId, event);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Calculate duration for post_tool_use
|
|
93
|
+
if (event.type === 'post_tool_use' && event.toolUseId) {
|
|
94
|
+
const preEvent = pendingToolUses.get(event.toolUseId);
|
|
95
|
+
if (preEvent) {
|
|
96
|
+
event.duration = event.timestamp - preEvent.timestamp;
|
|
97
|
+
pendingToolUses.delete(event.toolUseId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Enrich stop events with assistant response from conversation file
|
|
102
|
+
if (event.type === 'stop' && !event.response && event.sessionId) {
|
|
103
|
+
event.response = getLatestAssistantText(event.sessionId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return event;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Add event to memory and broadcast
|
|
111
|
+
* @param {Object} event - Event to add
|
|
112
|
+
*/
|
|
113
|
+
function addClaudeEvent(event) {
|
|
114
|
+
// Skip duplicates
|
|
115
|
+
if (seenEventIds.has(event.id)) return;
|
|
116
|
+
seenEventIds.add(event.id);
|
|
117
|
+
|
|
118
|
+
// Trim old IDs to prevent memory leak
|
|
119
|
+
if (seenEventIds.size > MAX_EVENTS * 2) {
|
|
120
|
+
const idsToKeep = [...seenEventIds].slice(-MAX_EVENTS);
|
|
121
|
+
seenEventIds.clear();
|
|
122
|
+
idsToKeep.forEach(id => seenEventIds.add(id));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const processed = processClaudeEvent(event);
|
|
126
|
+
claudeEvents.push(processed);
|
|
127
|
+
|
|
128
|
+
// Trim old events
|
|
129
|
+
if (claudeEvents.length > MAX_EVENTS) {
|
|
130
|
+
claudeEvents.splice(0, claudeEvents.length - MAX_EVENTS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Emit through EventBus
|
|
134
|
+
eventBus.emit(EventTypes.CLAUDE_EVENT, processed);
|
|
135
|
+
|
|
136
|
+
// Update managed session status based on event
|
|
137
|
+
if (sessionUpdateCallback) {
|
|
138
|
+
sessionUpdateCallback(processed);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Broadcast to all SSE clients
|
|
142
|
+
broadcastClaudeEvent(processed);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Broadcast a single Claude event to all SSE clients
|
|
147
|
+
* @param {Object} event - Event to broadcast
|
|
148
|
+
*/
|
|
149
|
+
function broadcastClaudeEvent(event) {
|
|
150
|
+
broadcastUpdate('claude_event', { event });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load existing events from file
|
|
155
|
+
*/
|
|
156
|
+
function loadEventsFromFile() {
|
|
157
|
+
if (!fs.existsSync(EVENTS_FILE)) return;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const content = fs.readFileSync(EVENTS_FILE, 'utf-8');
|
|
161
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
try {
|
|
165
|
+
const event = JSON.parse(line);
|
|
166
|
+
processClaudeEvent(event);
|
|
167
|
+
claudeEvents.push(event);
|
|
168
|
+
seenEventIds.add(event.id);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// Skip invalid lines
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
lastEventsFileSize = content.length;
|
|
175
|
+
console.log(` Loaded ${claudeEvents.length} Claude events from: ${EVENTS_FILE}`);
|
|
176
|
+
|
|
177
|
+
// Discover sessions from loaded events (process unique session IDs)
|
|
178
|
+
if (sessionUpdateCallback) {
|
|
179
|
+
const sessionIds = new Set();
|
|
180
|
+
for (const event of claudeEvents) {
|
|
181
|
+
if (event.sessionId && !sessionIds.has(event.sessionId)) {
|
|
182
|
+
sessionIds.add(event.sessionId);
|
|
183
|
+
sessionUpdateCallback(event);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error('Error loading events:', e.message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Watch events file for changes
|
|
194
|
+
*/
|
|
195
|
+
function watchEventsFile() {
|
|
196
|
+
// Ensure directory exists
|
|
197
|
+
if (!fs.existsSync(EVENTS_DIR)) {
|
|
198
|
+
fs.mkdirSync(EVENTS_DIR, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Create file if it doesn't exist
|
|
202
|
+
if (!fs.existsSync(EVENTS_FILE)) {
|
|
203
|
+
fs.writeFileSync(EVENTS_FILE, '');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Use polling for reliability (works across all platforms)
|
|
207
|
+
fs.watchFile(EVENTS_FILE, { interval: 100 }, (curr, prev) => {
|
|
208
|
+
if (curr.size <= prev.size) return; // File hasn't grown
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const content = fs.readFileSync(EVENTS_FILE, 'utf-8');
|
|
212
|
+
|
|
213
|
+
// Only process new content
|
|
214
|
+
if (content.length > lastEventsFileSize) {
|
|
215
|
+
const newContent = content.slice(lastEventsFileSize);
|
|
216
|
+
const newLines = newContent.trim().split('\n').filter(Boolean);
|
|
217
|
+
|
|
218
|
+
for (const line of newLines) {
|
|
219
|
+
try {
|
|
220
|
+
const event = JSON.parse(line);
|
|
221
|
+
addClaudeEvent(event);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// Skip invalid lines
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lastEventsFileSize = content.length;
|
|
228
|
+
}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// Ignore read errors
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
console.log(` Watching for Claude events in: ${EVENTS_FILE}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get all events
|
|
239
|
+
* @returns {Array} All events in memory
|
|
240
|
+
*/
|
|
241
|
+
function getEvents() {
|
|
242
|
+
return claudeEvents;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get events filtered by session
|
|
247
|
+
* @param {string} sessionId - Session ID to filter by
|
|
248
|
+
* @returns {Array} Filtered events
|
|
249
|
+
*/
|
|
250
|
+
function getEventsBySession(sessionId) {
|
|
251
|
+
return claudeEvents.filter(e => e.sessionId === sessionId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get event statistics
|
|
256
|
+
* @returns {Object} Event stats
|
|
257
|
+
*/
|
|
258
|
+
function getEventStats() {
|
|
259
|
+
const toolCounts = {};
|
|
260
|
+
const typeCounts = {};
|
|
261
|
+
const sessionCounts = {};
|
|
262
|
+
|
|
263
|
+
for (const event of claudeEvents) {
|
|
264
|
+
// Count by tool
|
|
265
|
+
if (event.tool) {
|
|
266
|
+
toolCounts[event.tool] = (toolCounts[event.tool] || 0) + 1;
|
|
267
|
+
}
|
|
268
|
+
// Count by type
|
|
269
|
+
typeCounts[event.type] = (typeCounts[event.type] || 0) + 1;
|
|
270
|
+
// Count by session
|
|
271
|
+
if (event.sessionId) {
|
|
272
|
+
sessionCounts[event.sessionId] = (sessionCounts[event.sessionId] || 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
totalEvents: claudeEvents.length,
|
|
278
|
+
toolCounts,
|
|
279
|
+
typeCounts,
|
|
280
|
+
sessionCounts
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Initialize the events module
|
|
286
|
+
*/
|
|
287
|
+
function init() {
|
|
288
|
+
loadEventsFromFile();
|
|
289
|
+
watchEventsFile();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
// Core functions
|
|
294
|
+
init,
|
|
295
|
+
addClaudeEvent,
|
|
296
|
+
getEvents,
|
|
297
|
+
getEventsBySession,
|
|
298
|
+
getEventStats,
|
|
299
|
+
processClaudeEvent,
|
|
300
|
+
broadcastClaudeEvent,
|
|
301
|
+
|
|
302
|
+
// Callbacks for dependency injection
|
|
303
|
+
setSessionUpdateCallback,
|
|
304
|
+
setConversationFileCallback,
|
|
305
|
+
|
|
306
|
+
// Direct access (for SSE history, etc.)
|
|
307
|
+
claudeEvents
|
|
308
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration - Centralized config for Claude HQ
|
|
3
|
+
*
|
|
4
|
+
* All paths, constants, and environment configuration in one place.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
// Base directories
|
|
11
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
12
|
+
|
|
13
|
+
// Data directories
|
|
14
|
+
const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
|
|
15
|
+
const TODOS_DIR = path.join(CLAUDE_DIR, 'todos');
|
|
16
|
+
const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
|
|
17
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
18
|
+
const EVENTS_DIR = path.join(CLAUDE_DIR, 'tasks-board');
|
|
19
|
+
|
|
20
|
+
// Data files
|
|
21
|
+
const CUSTOM_NAMES_FILE = path.join(TASKS_DIR, 'session-names.json');
|
|
22
|
+
const HIDDEN_SESSIONS_FILE = path.join(EVENTS_DIR, 'hidden-sessions.json');
|
|
23
|
+
const SESSIONS_FILE = path.join(EVENTS_DIR, 'sessions.json');
|
|
24
|
+
const EVENTS_FILE = path.join(EVENTS_DIR, 'events.json');
|
|
25
|
+
|
|
26
|
+
// Server configuration
|
|
27
|
+
const PORT = process.env.PORT || 3456;
|
|
28
|
+
const TMUX_SESSION = process.env.TMUX_SESSION || 'main';
|
|
29
|
+
|
|
30
|
+
// Session status constants
|
|
31
|
+
const SESSION_STATUS = {
|
|
32
|
+
IDLE: 'idle',
|
|
33
|
+
WORKING: 'working',
|
|
34
|
+
WAITING: 'waiting',
|
|
35
|
+
OFFLINE: 'offline'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Timeouts and intervals
|
|
39
|
+
const HEALTH_CHECK_INTERVAL = 5000; // 5 seconds
|
|
40
|
+
const WORKING_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
41
|
+
const PERMISSION_POLL_INTERVAL = 1000; // 1 second
|
|
42
|
+
const EVENT_DEBOUNCE_DELAY = 100; // 100ms
|
|
43
|
+
|
|
44
|
+
// Limits
|
|
45
|
+
const MAX_EVENTS_IN_MEMORY = 1000;
|
|
46
|
+
const MAX_EVENTS_TO_BROADCAST = 500;
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
// Directories
|
|
50
|
+
CLAUDE_DIR,
|
|
51
|
+
TASKS_DIR,
|
|
52
|
+
TODOS_DIR,
|
|
53
|
+
PLANS_DIR,
|
|
54
|
+
PROJECTS_DIR,
|
|
55
|
+
EVENTS_DIR,
|
|
56
|
+
|
|
57
|
+
// Files
|
|
58
|
+
CUSTOM_NAMES_FILE,
|
|
59
|
+
HIDDEN_SESSIONS_FILE,
|
|
60
|
+
SESSIONS_FILE,
|
|
61
|
+
EVENTS_FILE,
|
|
62
|
+
|
|
63
|
+
// Server
|
|
64
|
+
PORT,
|
|
65
|
+
TMUX_SESSION,
|
|
66
|
+
|
|
67
|
+
// Status
|
|
68
|
+
SESSION_STATUS,
|
|
69
|
+
|
|
70
|
+
// Timing
|
|
71
|
+
HEALTH_CHECK_INTERVAL,
|
|
72
|
+
WORKING_TIMEOUT,
|
|
73
|
+
PERMISSION_POLL_INTERVAL,
|
|
74
|
+
EVENT_DEBOUNCE_DELAY,
|
|
75
|
+
|
|
76
|
+
// Limits
|
|
77
|
+
MAX_EVENTS_IN_MEMORY,
|
|
78
|
+
MAX_EVENTS_TO_BROADCAST
|
|
79
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBus - Decoupled event handling for Claude HQ
|
|
3
|
+
*
|
|
4
|
+
* Provides pub/sub pattern for communication between modules without tight coupling.
|
|
5
|
+
* Used for broadcasting updates, logging, and inter-module communication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EventTypes = {
|
|
9
|
+
// Task events
|
|
10
|
+
TASK_CREATED: 'task:created',
|
|
11
|
+
TASK_UPDATED: 'task:updated',
|
|
12
|
+
TASK_STATUS_CHANGED: 'task:status_changed',
|
|
13
|
+
|
|
14
|
+
// Todo events
|
|
15
|
+
TODO_CREATED: 'todo:created',
|
|
16
|
+
TODO_UPDATED: 'todo:updated',
|
|
17
|
+
TODO_STATUS_CHANGED: 'todo:status_changed',
|
|
18
|
+
TODOS_CHANGED: 'todos:changed',
|
|
19
|
+
|
|
20
|
+
// Plan events
|
|
21
|
+
PLAN_CREATED: 'plan:created',
|
|
22
|
+
PLAN_UPDATED: 'plan:updated',
|
|
23
|
+
PLANS_CHANGED: 'plans:changed',
|
|
24
|
+
|
|
25
|
+
// Session events
|
|
26
|
+
SESSION_RENAMED: 'session:renamed',
|
|
27
|
+
SESSION_SELECTED: 'session:selected',
|
|
28
|
+
|
|
29
|
+
// Prompt events
|
|
30
|
+
PROMPT_SENT: 'prompt:sent',
|
|
31
|
+
PROMPT_SUCCEEDED: 'prompt:succeeded',
|
|
32
|
+
PROMPT_FAILED: 'prompt:failed',
|
|
33
|
+
|
|
34
|
+
// Claude Code events (from hooks)
|
|
35
|
+
CLAUDE_EVENT: 'claude:event',
|
|
36
|
+
PRE_TOOL_USE: 'claude:pre_tool_use',
|
|
37
|
+
POST_TOOL_USE: 'claude:post_tool_use',
|
|
38
|
+
STOP: 'claude:stop',
|
|
39
|
+
USER_PROMPT_SUBMIT: 'claude:user_prompt_submit',
|
|
40
|
+
SUBAGENT_SPAWN: 'claude:subagent_spawn',
|
|
41
|
+
SUBAGENT_COMPLETE: 'claude:subagent_complete',
|
|
42
|
+
|
|
43
|
+
// File system events
|
|
44
|
+
FILE_CHANGED: 'file:changed',
|
|
45
|
+
|
|
46
|
+
// Attention events
|
|
47
|
+
ATTENTION_NEEDED: 'attention:needed',
|
|
48
|
+
ATTENTION_CLEARED: 'attention:cleared',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
class EventBus {
|
|
52
|
+
constructor() {
|
|
53
|
+
this.handlers = new Map();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Subscribe to an event type
|
|
58
|
+
* @param {string} type - Event type from EventTypes
|
|
59
|
+
* @param {Function} handler - Handler function
|
|
60
|
+
* @returns {Function} Unsubscribe function
|
|
61
|
+
*/
|
|
62
|
+
on(type, handler) {
|
|
63
|
+
if (!this.handlers.has(type)) {
|
|
64
|
+
this.handlers.set(type, new Set());
|
|
65
|
+
}
|
|
66
|
+
this.handlers.get(type).add(handler);
|
|
67
|
+
// Return unsubscribe function
|
|
68
|
+
return () => this.handlers.get(type)?.delete(handler);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Emit an event to all subscribers
|
|
73
|
+
* @param {string} type - Event type
|
|
74
|
+
* @param {Object} payload - Event data
|
|
75
|
+
*/
|
|
76
|
+
emit(type, payload = {}) {
|
|
77
|
+
const handlers = this.handlers.get(type);
|
|
78
|
+
if (!handlers) return;
|
|
79
|
+
for (const handler of handlers) {
|
|
80
|
+
try {
|
|
81
|
+
handler(payload);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`[EventBus] Error in handler for ${type}:`, error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove all handlers for an event type
|
|
90
|
+
* @param {string} type - Event type
|
|
91
|
+
*/
|
|
92
|
+
off(type) {
|
|
93
|
+
this.handlers.delete(type);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Clear all handlers
|
|
98
|
+
*/
|
|
99
|
+
clear() {
|
|
100
|
+
this.handlers.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get handler count for debugging
|
|
105
|
+
* @param {string} [type] - Optional event type
|
|
106
|
+
* @returns {number} Handler count
|
|
107
|
+
*/
|
|
108
|
+
getHandlerCount(type) {
|
|
109
|
+
if (type) {
|
|
110
|
+
return this.handlers.get(type)?.size ?? 0;
|
|
111
|
+
}
|
|
112
|
+
let total = 0;
|
|
113
|
+
for (const handlers of this.handlers.values()) {
|
|
114
|
+
total += handlers.size;
|
|
115
|
+
}
|
|
116
|
+
return total;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Singleton instance
|
|
121
|
+
const eventBus = new EventBus();
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
EventBus,
|
|
125
|
+
EventTypes,
|
|
126
|
+
eventBus
|
|
127
|
+
};
|
package/lib/core/sse.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) - Real-time client communication
|
|
3
|
+
*
|
|
4
|
+
* Manages SSE connections and broadcasts updates to all connected clients.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Connected SSE clients
|
|
8
|
+
const sseClients = new Set();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Add a new SSE client
|
|
12
|
+
* @param {http.ServerResponse} client - Response object to write SSE events to
|
|
13
|
+
*/
|
|
14
|
+
function addClient(client) {
|
|
15
|
+
sseClients.add(client);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remove an SSE client
|
|
20
|
+
* @param {http.ServerResponse} client - Client to remove
|
|
21
|
+
*/
|
|
22
|
+
function removeClient(client) {
|
|
23
|
+
sseClients.delete(client);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get current client count
|
|
28
|
+
* @returns {number} Number of connected clients
|
|
29
|
+
*/
|
|
30
|
+
function getClientCount() {
|
|
31
|
+
return sseClients.size;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Broadcast an update to all connected clients
|
|
36
|
+
* @param {string} eventType - Type of event (e.g., 'update', 'task_created')
|
|
37
|
+
* @param {Object} payload - Event data
|
|
38
|
+
*/
|
|
39
|
+
function broadcastUpdate(eventType = 'update', payload = {}) {
|
|
40
|
+
const data = JSON.stringify({
|
|
41
|
+
type: eventType,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
...payload
|
|
44
|
+
});
|
|
45
|
+
for (const client of sseClients) {
|
|
46
|
+
try {
|
|
47
|
+
client.write(`data: ${data}\n\n`);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Client disconnected, remove it
|
|
50
|
+
sseClients.delete(client);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Broadcast raw data to all clients
|
|
57
|
+
* @param {string} data - Pre-formatted SSE data
|
|
58
|
+
*/
|
|
59
|
+
function broadcastRaw(data) {
|
|
60
|
+
for (const client of sseClients) {
|
|
61
|
+
try {
|
|
62
|
+
client.write(data);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
sseClients.delete(client);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Send data to a specific client
|
|
71
|
+
* @param {http.ServerResponse} client - Target client
|
|
72
|
+
* @param {string} eventType - Type of event
|
|
73
|
+
* @param {Object} payload - Event data
|
|
74
|
+
*/
|
|
75
|
+
function sendToClient(client, eventType, payload = {}) {
|
|
76
|
+
const data = JSON.stringify({
|
|
77
|
+
type: eventType,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
...payload
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
client.write(`data: ${data}\n\n`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
sseClients.delete(client);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
sseClients,
|
|
90
|
+
addClient,
|
|
91
|
+
removeClient,
|
|
92
|
+
getClientCount,
|
|
93
|
+
broadcastUpdate,
|
|
94
|
+
broadcastRaw,
|
|
95
|
+
sendToClient
|
|
96
|
+
};
|