claude-multi-session 1.0.0 → 2.2.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.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * TeamHub - Layer 1 Chat System for claude-multi-session
3
+ *
4
+ * This class manages team-based messaging between Claude sessions.
5
+ * It handles:
6
+ * - Team roster (who's in the team)
7
+ * - Direct messaging between members
8
+ * - Broadcast messages to all members
9
+ * - Ask/reply system for synchronous questions
10
+ * - Inbox management and compaction
11
+ *
12
+ * @class TeamHub
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const crypto = require('crypto');
19
+
20
+ // Import our atomic file operations
21
+ const { atomicWriteJson, readJsonSafe, appendJsonl } = require('./atomic-io');
22
+
23
+ /**
24
+ * TeamHub class - manages team communication and roster
25
+ */
26
+ class TeamHub {
27
+ /**
28
+ * Creates a new TeamHub instance
29
+ *
30
+ * @param {string} teamName - Name of the team (default: 'default')
31
+ */
32
+ constructor(teamName = 'default') {
33
+ // Set the team name
34
+ this.teamName = teamName;
35
+
36
+ // Create the base directory path: ~/.claude-multi-session
37
+ const baseDir = path.join(os.homedir(), '.claude-multi-session');
38
+
39
+ // Create the team directory path: ~/.claude-multi-session/team/{teamName}
40
+ this.teamDir = path.join(baseDir, 'team', teamName);
41
+
42
+ // Create subdirectories for inbox and asks if they don't exist
43
+ const inboxDir = path.join(this.teamDir, 'inbox');
44
+ const asksDir = path.join(this.teamDir, 'asks');
45
+
46
+ // Make sure all directories exist
47
+ fs.mkdirSync(inboxDir, { recursive: true });
48
+ fs.mkdirSync(asksDir, { recursive: true });
49
+
50
+ // Rate limiting: track message counts per sender
51
+ // Map structure: { senderName: { count: number, windowStart: timestamp } }
52
+ this.rateLimitMap = new Map();
53
+
54
+ // Loop detection: track exchanges between pairs
55
+ // Map structure: { 'sender:receiver': [ timestamps ] }
56
+ this.loopDetectionMap = new Map();
57
+ }
58
+
59
+ // ========== ROSTER METHODS ==========
60
+
61
+ /**
62
+ * Get the current team roster
63
+ *
64
+ * @returns {Array} Array of team member objects
65
+ */
66
+ getRoster() {
67
+ const rosterPath = path.join(this.teamDir, 'roster.json');
68
+ // readJsonSafe returns empty array if file doesn't exist
69
+ return readJsonSafe(rosterPath, []);
70
+ }
71
+
72
+ /**
73
+ * Add a new member to the team
74
+ *
75
+ * @param {string} name - Member name (unique identifier)
76
+ * @param {Object} options - Member details
77
+ * @param {string} options.role - Member's role in the team
78
+ * @param {string} options.task - Member's current task
79
+ * @param {string} options.model - AI model being used (e.g., 'sonnet', 'opus')
80
+ */
81
+ joinTeam(name, { role, task, model }) {
82
+ // Get current roster
83
+ const roster = this.getRoster();
84
+
85
+ // Create new member entry with all required fields
86
+ const newMember = {
87
+ name,
88
+ role,
89
+ task,
90
+ model,
91
+ status: 'active',
92
+ joinedAt: new Date().toISOString(),
93
+ lastSeen: new Date().toISOString()
94
+ };
95
+
96
+ // Remove existing entry if member is rejoining
97
+ const filtered = roster.filter(m => m.name !== name);
98
+
99
+ // Add the new member
100
+ filtered.push(newMember);
101
+
102
+ // Save the updated roster
103
+ const rosterPath = path.join(this.teamDir, 'roster.json');
104
+ atomicWriteJson(rosterPath, filtered);
105
+ }
106
+
107
+ /**
108
+ * Remove a member from the team
109
+ *
110
+ * @param {string} name - Name of member to remove
111
+ */
112
+ leaveTeam(name) {
113
+ // Get current roster
114
+ const roster = this.getRoster();
115
+
116
+ // Filter out the member
117
+ const filtered = roster.filter(m => m.name !== name);
118
+
119
+ // Save the updated roster
120
+ const rosterPath = path.join(this.teamDir, 'roster.json');
121
+ atomicWriteJson(rosterPath, filtered);
122
+ }
123
+
124
+ /**
125
+ * Update a member's information
126
+ *
127
+ * @param {string} name - Name of member to update
128
+ * @param {Object} updates - Fields to update (status, task, role, etc.)
129
+ */
130
+ updateMember(name, updates) {
131
+ // Get current roster
132
+ const roster = this.getRoster();
133
+
134
+ // Find and update the member
135
+ const updated = roster.map(member => {
136
+ if (member.name === name) {
137
+ // Merge updates into existing member object
138
+ return { ...member, ...updates };
139
+ }
140
+ return member;
141
+ });
142
+
143
+ // Save the updated roster
144
+ const rosterPath = path.join(this.teamDir, 'roster.json');
145
+ atomicWriteJson(rosterPath, updated);
146
+ }
147
+
148
+ /**
149
+ * Update a member's lastSeen timestamp to now
150
+ *
151
+ * @param {string} name - Name of member
152
+ */
153
+ touchLastSeen(name) {
154
+ this.updateMember(name, { lastSeen: new Date().toISOString() });
155
+ }
156
+
157
+ // ========== MESSAGING METHODS ==========
158
+
159
+ /**
160
+ * Check rate limits for a sender
161
+ * Max 10 messages per minute
162
+ *
163
+ * @param {string} from - Sender name
164
+ * @throws {Error} If rate limit exceeded
165
+ */
166
+ _checkRateLimit(from) {
167
+ const now = Date.now();
168
+ const windowSize = 60000; // 60 seconds in milliseconds
169
+ const maxMessages = 10;
170
+
171
+ // Get or create rate limit entry for this sender
172
+ let rateLimitEntry = this.rateLimitMap.get(from);
173
+
174
+ if (!rateLimitEntry) {
175
+ // First message from this sender
176
+ rateLimitEntry = { count: 0, windowStart: now };
177
+ this.rateLimitMap.set(from, rateLimitEntry);
178
+ }
179
+
180
+ // Check if we need to reset the window
181
+ if (now - rateLimitEntry.windowStart > windowSize) {
182
+ // Window expired, reset
183
+ rateLimitEntry.count = 0;
184
+ rateLimitEntry.windowStart = now;
185
+ }
186
+
187
+ // Check if limit exceeded
188
+ if (rateLimitEntry.count >= maxMessages) {
189
+ throw new Error(`Rate limit exceeded for ${from}: max ${maxMessages} messages per minute`);
190
+ }
191
+
192
+ // Increment counter
193
+ rateLimitEntry.count++;
194
+ }
195
+
196
+ /**
197
+ * Check for messaging loops between two parties
198
+ * Max 5 messages between same pair in 60 seconds
199
+ *
200
+ * @param {string} from - Sender name
201
+ * @param {string} to - Receiver name
202
+ * @throws {Error} If loop detected
203
+ */
204
+ _checkLoopDetection(from, to) {
205
+ const now = Date.now();
206
+ const windowSize = 60000; // 60 seconds
207
+ const maxExchanges = 5;
208
+
209
+ // Create a consistent key for this pair (alphabetically sorted)
210
+ const pair = [from, to].sort().join(':');
211
+
212
+ // Get or create exchange history for this pair
213
+ let exchanges = this.loopDetectionMap.get(pair);
214
+
215
+ if (!exchanges) {
216
+ exchanges = [];
217
+ this.loopDetectionMap.set(pair, exchanges);
218
+ }
219
+
220
+ // Remove old timestamps outside the window
221
+ exchanges = exchanges.filter(timestamp => now - timestamp < windowSize);
222
+ this.loopDetectionMap.set(pair, exchanges);
223
+
224
+ // Check if too many exchanges
225
+ if (exchanges.length >= maxExchanges) {
226
+ throw new Error(`Loop detected: too many exchanges between ${from} and ${to} (max ${maxExchanges} per minute)`);
227
+ }
228
+
229
+ // Add this exchange
230
+ exchanges.push(now);
231
+ }
232
+
233
+ /**
234
+ * Send a direct message to a specific team member
235
+ *
236
+ * @param {string} from - Sender name
237
+ * @param {string} to - Recipient name
238
+ * @param {string} content - Message content
239
+ * @param {Object} options - Message options
240
+ * @param {string} options.priority - Priority level ('normal' or 'urgent')
241
+ * @returns {Object} The created message object
242
+ */
243
+ sendDirect(from, to, content, { priority = 'normal' } = {}) {
244
+ // Check rate limits and loop detection
245
+ this._checkRateLimit(from);
246
+ this._checkLoopDetection(from, to);
247
+
248
+ // Create the message object
249
+ const message = {
250
+ id: crypto.randomUUID(),
251
+ from,
252
+ to,
253
+ type: 'direct',
254
+ content,
255
+ timestamp: new Date().toISOString(),
256
+ status: 'pending',
257
+ priority
258
+ };
259
+
260
+ // Append to recipient's inbox
261
+ const inboxPath = path.join(this.teamDir, 'inbox', `${to}.jsonl`);
262
+ appendJsonl(inboxPath, message);
263
+
264
+ return message;
265
+ }
266
+
267
+ /**
268
+ * Send a broadcast message to all team members
269
+ *
270
+ * @param {string} from - Sender name
271
+ * @param {string} content - Message content
272
+ * @param {Object} options - Message options
273
+ * @param {string} options.priority - Priority level ('normal' or 'urgent')
274
+ * @returns {Object} The created message object
275
+ */
276
+ sendBroadcast(from, content, { priority = 'normal' } = {}) {
277
+ // Check rate limits (loop detection doesn't apply to broadcasts)
278
+ this._checkRateLimit(from);
279
+
280
+ // Create the message object
281
+ const message = {
282
+ id: crypto.randomUUID(),
283
+ from,
284
+ to: 'all',
285
+ type: 'broadcast',
286
+ content,
287
+ timestamp: new Date().toISOString(),
288
+ status: 'pending',
289
+ priority
290
+ };
291
+
292
+ // Get all team members
293
+ const roster = this.getRoster();
294
+
295
+ // Append to every member's inbox (including sender for record-keeping)
296
+ for (const member of roster) {
297
+ const inboxPath = path.join(this.teamDir, 'inbox', `${member.name}.jsonl`);
298
+ appendJsonl(inboxPath, message);
299
+ }
300
+
301
+ return message;
302
+ }
303
+
304
+ // ========== INBOX METHODS ==========
305
+
306
+ /**
307
+ * Get messages from a member's inbox
308
+ *
309
+ * @param {string} name - Member name
310
+ * @param {Object} options - Inbox options
311
+ * @param {boolean} options.markRead - Whether to mark messages as read (default: false)
312
+ * @param {number} options.limit - Max number of messages to return (default: 20)
313
+ * @returns {Array} Array of message objects
314
+ */
315
+ getInbox(name, { markRead = false, limit = 20 } = {}) {
316
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
317
+
318
+ // Read the inbox file
319
+ let allMessages = [];
320
+
321
+ // Check if file exists
322
+ if (fs.existsSync(inboxPath)) {
323
+ // Read file and parse each line as JSON
324
+ const content = fs.readFileSync(inboxPath, 'utf-8');
325
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
326
+
327
+ allMessages = lines.map(line => JSON.parse(line));
328
+
329
+ // Auto-compaction if total messages exceed 1000
330
+ if (allMessages.length > 1000) {
331
+ this.compactInbox(name);
332
+ // Re-read after compaction
333
+ const newContent = fs.readFileSync(inboxPath, 'utf-8');
334
+ const newLines = newContent.trim().split('\n').filter(line => line.length > 0);
335
+ allMessages = newLines.map(line => JSON.parse(line));
336
+ }
337
+ }
338
+
339
+ // Filter to only pending (unread) messages
340
+ const unreadMessages = allMessages.filter(msg => msg.status === 'pending');
341
+
342
+ // Sort by priority (urgent first), then timestamp
343
+ unreadMessages.sort((a, b) => {
344
+ // Priority comparison (urgent comes before normal)
345
+ if (a.priority === 'urgent' && b.priority !== 'urgent') return -1;
346
+ if (a.priority !== 'urgent' && b.priority === 'urgent') return 1;
347
+
348
+ // If same priority, sort by timestamp (oldest first)
349
+ return new Date(a.timestamp) - new Date(b.timestamp);
350
+ });
351
+
352
+ // Limit the results
353
+ const limitedMessages = unreadMessages.slice(0, limit);
354
+
355
+ // Mark as read if requested
356
+ if (markRead && limitedMessages.length > 0) {
357
+ // Create a set of IDs to mark as read
358
+ const idsToMark = new Set(limitedMessages.map(msg => msg.id));
359
+
360
+ // Update all messages
361
+ const updatedMessages = allMessages.map(msg => {
362
+ if (idsToMark.has(msg.id)) {
363
+ return { ...msg, status: 'read' };
364
+ }
365
+ return msg;
366
+ });
367
+
368
+ // Rewrite the file
369
+ const newContent = updatedMessages.map(msg => JSON.stringify(msg)).join('\n') + '\n';
370
+ fs.writeFileSync(inboxPath, newContent, 'utf-8');
371
+ }
372
+
373
+ return limitedMessages;
374
+ }
375
+
376
+ /**
377
+ * Get count of unread messages in inbox
378
+ *
379
+ * @param {string} name - Member name
380
+ * @returns {number} Count of unread messages
381
+ */
382
+ getUnreadCount(name) {
383
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
384
+
385
+ // If file doesn't exist, no unread messages
386
+ if (!fs.existsSync(inboxPath)) {
387
+ return 0;
388
+ }
389
+
390
+ // Read and count pending messages
391
+ const content = fs.readFileSync(inboxPath, 'utf-8');
392
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
393
+
394
+ let count = 0;
395
+ for (const line of lines) {
396
+ const msg = JSON.parse(line);
397
+ if (msg.status === 'pending') {
398
+ count++;
399
+ }
400
+ }
401
+
402
+ return count;
403
+ }
404
+
405
+ /**
406
+ * Compact an inbox by removing old read messages
407
+ * Keeps all unread messages and the last N read messages
408
+ *
409
+ * @param {string} name - Member name
410
+ * @param {Object} options - Compaction options
411
+ * @param {number} options.keepLast - Number of read messages to keep (default: 200)
412
+ * @returns {Object} Object with removed and kept counts
413
+ */
414
+ compactInbox(name, { keepLast = 200 } = {}) {
415
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
416
+
417
+ // If file doesn't exist, nothing to compact
418
+ if (!fs.existsSync(inboxPath)) {
419
+ return { removed: 0, kept: 0 };
420
+ }
421
+
422
+ // Read all messages
423
+ const content = fs.readFileSync(inboxPath, 'utf-8');
424
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
425
+ const allMessages = lines.map(line => JSON.parse(line));
426
+
427
+ // Separate unread and read messages
428
+ const unreadMessages = allMessages.filter(msg => msg.status === 'pending');
429
+ const readMessages = allMessages.filter(msg => msg.status === 'read');
430
+
431
+ // Keep only the last N read messages
432
+ const keptReadMessages = readMessages.slice(-keepLast);
433
+
434
+ // Combine: all unread + last N read
435
+ const keptMessages = [...unreadMessages, ...keptReadMessages];
436
+
437
+ // Write back to file
438
+ const newContent = keptMessages.map(msg => JSON.stringify(msg)).join('\n') + '\n';
439
+ fs.writeFileSync(inboxPath, newContent, 'utf-8');
440
+
441
+ return {
442
+ removed: allMessages.length - keptMessages.length,
443
+ kept: keptMessages.length
444
+ };
445
+ }
446
+
447
+ // ========== ASK/REPLY METHODS ==========
448
+
449
+ /**
450
+ * Create an ask (synchronous question) to another member
451
+ *
452
+ * @param {string} from - Asker name
453
+ * @param {string} to - Recipient name
454
+ * @param {string} question - The question text
455
+ * @param {number} timeout - Timeout in milliseconds (default: 30000)
456
+ * @returns {Object} Object with askId
457
+ */
458
+ createAsk(from, to, question, timeout = 30000) {
459
+ // Generate unique ID
460
+ const id = crypto.randomUUID();
461
+
462
+ // Create ask object
463
+ const ask = {
464
+ id,
465
+ from,
466
+ to,
467
+ question,
468
+ status: 'pending',
469
+ createdAt: new Date().toISOString(),
470
+ timeout
471
+ };
472
+
473
+ // Write ask file
474
+ const askPath = path.join(this.teamDir, 'asks', `ask_${id}.json`);
475
+ atomicWriteJson(askPath, ask);
476
+
477
+ // Also send as a message to recipient's inbox
478
+ const message = {
479
+ id: crypto.randomUUID(),
480
+ from,
481
+ to,
482
+ type: 'ask',
483
+ content: question,
484
+ askId: id,
485
+ timestamp: new Date().toISOString(),
486
+ status: 'pending',
487
+ priority: 'urgent' // Asks are urgent
488
+ };
489
+
490
+ const inboxPath = path.join(this.teamDir, 'inbox', `${to}.jsonl`);
491
+ appendJsonl(inboxPath, message);
492
+
493
+ return { askId: id };
494
+ }
495
+
496
+ /**
497
+ * Poll for a reply to an ask
498
+ * Uses exponential backoff: 1s, 2s, 4s, 8s, then 10s cap
499
+ *
500
+ * @param {string} askId - The ask ID to poll for
501
+ * @param {number} timeout - Total timeout in milliseconds (default: 30000)
502
+ * @returns {Promise<Object>} Promise that resolves with reply or timeout
503
+ */
504
+ pollForReply(askId, timeout = 30000) {
505
+ return new Promise((resolve) => {
506
+ const askPath = path.join(this.teamDir, 'asks', `ask_${askId}.json`);
507
+ const startTime = Date.now();
508
+
509
+ // Exponential backoff intervals: 1s, 2s, 4s, 8s, 10s cap
510
+ let backoffDelay = 1000; // Start with 1 second
511
+
512
+ const poll = () => {
513
+ // Check if timeout exceeded
514
+ if (Date.now() - startTime > timeout) {
515
+ resolve({
516
+ timeout: true,
517
+ suggestion: 'Session may be busy. Check inbox later.'
518
+ });
519
+ return;
520
+ }
521
+
522
+ // Try to read the ask file
523
+ const ask = readJsonSafe(askPath, null);
524
+
525
+ if (!ask) {
526
+ // File doesn't exist or can't be read
527
+ resolve({
528
+ timeout: true,
529
+ suggestion: 'Ask not found or was deleted.'
530
+ });
531
+ return;
532
+ }
533
+
534
+ // Check if replied
535
+ if (ask.status === 'replied') {
536
+ resolve({
537
+ reply: ask.answer,
538
+ from: ask.repliedBy
539
+ });
540
+ return;
541
+ }
542
+
543
+ // Schedule next poll with exponential backoff
544
+ setTimeout(poll, backoffDelay);
545
+
546
+ // Increase backoff delay (double it, max 10 seconds)
547
+ backoffDelay = Math.min(backoffDelay * 2, 10000);
548
+ };
549
+
550
+ // Start polling
551
+ poll();
552
+ });
553
+ }
554
+
555
+ /**
556
+ * Submit a reply to an ask
557
+ *
558
+ * @param {string} from - Replier name
559
+ * @param {string} askId - The ask ID to reply to
560
+ * @param {string} answer - The answer/reply
561
+ */
562
+ submitReply(from, askId, answer) {
563
+ const askPath = path.join(this.teamDir, 'asks', `ask_${askId}.json`);
564
+
565
+ // Read the ask
566
+ const ask = readJsonSafe(askPath, null);
567
+
568
+ if (!ask) {
569
+ throw new Error(`Ask ${askId} not found`);
570
+ }
571
+
572
+ // Update ask with reply
573
+ ask.status = 'replied';
574
+ ask.answer = answer;
575
+ ask.repliedAt = new Date().toISOString();
576
+ ask.repliedBy = from;
577
+
578
+ // Save updated ask
579
+ atomicWriteJson(askPath, ask);
580
+ }
581
+ }
582
+
583
+ // Export the TeamHub class
584
+ module.exports = TeamHub;