@dolusoft/claude-collab 0.1.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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2064 -0
- package/dist/cli.js.map +1 -0
- package/dist/hub-main.d.ts +1 -0
- package/dist/hub-main.js +1497 -0
- package/dist/hub-main.js.map +1 -0
- package/dist/mcp-main.d.ts +1 -0
- package/dist/mcp-main.js +684 -0
- package/dist/mcp-main.js.map +1 -0
- package/package.json +80 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2064 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import WebSocket2, { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { v4 } from 'uuid';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { createConnection } from 'net';
|
|
10
|
+
|
|
11
|
+
// src/domain/entities/member.entity.ts
|
|
12
|
+
var Member = class _Member {
|
|
13
|
+
_id;
|
|
14
|
+
_teamId;
|
|
15
|
+
_displayName;
|
|
16
|
+
_connectedAt;
|
|
17
|
+
_status;
|
|
18
|
+
_lastActivityAt;
|
|
19
|
+
constructor(props) {
|
|
20
|
+
this._id = props.id;
|
|
21
|
+
this._teamId = props.teamId;
|
|
22
|
+
this._displayName = props.displayName;
|
|
23
|
+
this._connectedAt = props.connectedAt;
|
|
24
|
+
this._status = props.status;
|
|
25
|
+
this._lastActivityAt = props.connectedAt;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new Member instance
|
|
29
|
+
*/
|
|
30
|
+
static create(props) {
|
|
31
|
+
if (!props.displayName.trim()) {
|
|
32
|
+
throw new Error("Display name cannot be empty");
|
|
33
|
+
}
|
|
34
|
+
return new _Member(props);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reconstitutes a Member from persistence
|
|
38
|
+
*/
|
|
39
|
+
static reconstitute(props) {
|
|
40
|
+
const member = new _Member(props);
|
|
41
|
+
member._lastActivityAt = props.lastActivityAt;
|
|
42
|
+
return member;
|
|
43
|
+
}
|
|
44
|
+
// Getters
|
|
45
|
+
get id() {
|
|
46
|
+
return this._id;
|
|
47
|
+
}
|
|
48
|
+
get teamId() {
|
|
49
|
+
return this._teamId;
|
|
50
|
+
}
|
|
51
|
+
get displayName() {
|
|
52
|
+
return this._displayName;
|
|
53
|
+
}
|
|
54
|
+
get connectedAt() {
|
|
55
|
+
return this._connectedAt;
|
|
56
|
+
}
|
|
57
|
+
get status() {
|
|
58
|
+
return this._status;
|
|
59
|
+
}
|
|
60
|
+
get lastActivityAt() {
|
|
61
|
+
return this._lastActivityAt;
|
|
62
|
+
}
|
|
63
|
+
get isOnline() {
|
|
64
|
+
return this._status === "ONLINE" /* ONLINE */ || this._status === "IDLE" /* IDLE */;
|
|
65
|
+
}
|
|
66
|
+
// Behaviors
|
|
67
|
+
/**
|
|
68
|
+
* Marks the member as online
|
|
69
|
+
*/
|
|
70
|
+
goOnline() {
|
|
71
|
+
this._status = "ONLINE" /* ONLINE */;
|
|
72
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Marks the member as idle
|
|
76
|
+
*/
|
|
77
|
+
goIdle() {
|
|
78
|
+
this._status = "IDLE" /* IDLE */;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Marks the member as offline
|
|
82
|
+
*/
|
|
83
|
+
goOffline() {
|
|
84
|
+
this._status = "OFFLINE" /* OFFLINE */;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Records activity from this member
|
|
88
|
+
*/
|
|
89
|
+
recordActivity() {
|
|
90
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
91
|
+
if (this._status === "IDLE" /* IDLE */) {
|
|
92
|
+
this._status = "ONLINE" /* ONLINE */;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Converts entity to plain object for serialization
|
|
97
|
+
*/
|
|
98
|
+
toJSON() {
|
|
99
|
+
return {
|
|
100
|
+
id: this._id,
|
|
101
|
+
teamId: this._teamId,
|
|
102
|
+
displayName: this._displayName,
|
|
103
|
+
connectedAt: this._connectedAt,
|
|
104
|
+
status: this._status,
|
|
105
|
+
lastActivityAt: this._lastActivityAt
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var BaseDomainEvent = class {
|
|
110
|
+
eventId;
|
|
111
|
+
timestamp;
|
|
112
|
+
constructor() {
|
|
113
|
+
this.eventId = v4();
|
|
114
|
+
this.timestamp = /* @__PURE__ */ new Date();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Converts event to JSON
|
|
118
|
+
*/
|
|
119
|
+
toJSON() {
|
|
120
|
+
return {
|
|
121
|
+
eventId: this.eventId,
|
|
122
|
+
eventType: this.eventType,
|
|
123
|
+
timestamp: this.timestamp,
|
|
124
|
+
payload: this.payload
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/domain/events/member-joined.event.ts
|
|
130
|
+
var MemberJoinedEvent = class _MemberJoinedEvent extends BaseDomainEvent {
|
|
131
|
+
constructor(memberId, teamId, displayName) {
|
|
132
|
+
super();
|
|
133
|
+
this.memberId = memberId;
|
|
134
|
+
this.teamId = teamId;
|
|
135
|
+
this.displayName = displayName;
|
|
136
|
+
}
|
|
137
|
+
static EVENT_TYPE = "MEMBER_JOINED";
|
|
138
|
+
get eventType() {
|
|
139
|
+
return _MemberJoinedEvent.EVENT_TYPE;
|
|
140
|
+
}
|
|
141
|
+
get payload() {
|
|
142
|
+
return {
|
|
143
|
+
memberId: this.memberId,
|
|
144
|
+
teamId: this.teamId,
|
|
145
|
+
displayName: this.displayName
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/shared/types/branded-types.ts
|
|
151
|
+
var MemberId = {
|
|
152
|
+
create: (id) => id,
|
|
153
|
+
isValid: (id) => id.length > 0
|
|
154
|
+
};
|
|
155
|
+
var TeamId = {
|
|
156
|
+
create: (id) => id.toLowerCase().trim(),
|
|
157
|
+
isValid: (id) => /^[a-z][a-z0-9-]*$/.test(id.toLowerCase().trim())
|
|
158
|
+
};
|
|
159
|
+
var QuestionId = {
|
|
160
|
+
create: (id) => id,
|
|
161
|
+
isValid: (id) => id.length > 0
|
|
162
|
+
};
|
|
163
|
+
var AnswerId = {
|
|
164
|
+
create: (id) => id,
|
|
165
|
+
isValid: (id) => id.length > 0
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// src/shared/utils/id-generator.ts
|
|
169
|
+
function generateMemberId() {
|
|
170
|
+
return MemberId.create(v4());
|
|
171
|
+
}
|
|
172
|
+
function createTeamId(name) {
|
|
173
|
+
return TeamId.create(name);
|
|
174
|
+
}
|
|
175
|
+
function generateQuestionId() {
|
|
176
|
+
return QuestionId.create(`q_${v4()}`);
|
|
177
|
+
}
|
|
178
|
+
function generateAnswerId() {
|
|
179
|
+
return AnswerId.create(`a_${v4()}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/shared/errors/domain-errors.ts
|
|
183
|
+
var DomainError = class extends Error {
|
|
184
|
+
code;
|
|
185
|
+
timestamp;
|
|
186
|
+
constructor(message, code) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.name = this.constructor.name;
|
|
189
|
+
this.code = code;
|
|
190
|
+
this.timestamp = /* @__PURE__ */ new Date();
|
|
191
|
+
Error.captureStackTrace(this, this.constructor);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
var TeamNotFoundError = class extends DomainError {
|
|
195
|
+
constructor(teamId) {
|
|
196
|
+
super(`Team '${teamId}' not found`, "TEAM_NOT_FOUND");
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var MemberNotFoundError = class extends DomainError {
|
|
200
|
+
constructor(memberId) {
|
|
201
|
+
super(`Member '${memberId}' not found`, "MEMBER_NOT_FOUND");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
var QuestionNotFoundError = class extends DomainError {
|
|
205
|
+
constructor(questionId) {
|
|
206
|
+
super(`Question '${questionId}' not found`, "QUESTION_NOT_FOUND");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var QuestionAlreadyAnsweredError = class extends DomainError {
|
|
210
|
+
constructor(questionId) {
|
|
211
|
+
super(`Question '${questionId}' has already been answered`, "QUESTION_ALREADY_ANSWERED");
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
var ValidationError = class extends DomainError {
|
|
215
|
+
field;
|
|
216
|
+
constructor(field, message) {
|
|
217
|
+
super(message, "VALIDATION_ERROR");
|
|
218
|
+
this.field = field;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// src/application/use-cases/join-team.use-case.ts
|
|
223
|
+
var JoinTeamUseCase = class {
|
|
224
|
+
constructor(deps) {
|
|
225
|
+
this.deps = deps;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Executes the use case
|
|
229
|
+
*/
|
|
230
|
+
async execute(input) {
|
|
231
|
+
if (!input.teamName.trim()) {
|
|
232
|
+
throw new ValidationError("teamName", "Team name cannot be empty");
|
|
233
|
+
}
|
|
234
|
+
if (!input.displayName.trim()) {
|
|
235
|
+
throw new ValidationError("displayName", "Display name cannot be empty");
|
|
236
|
+
}
|
|
237
|
+
const team = await this.deps.teamRepository.getOrCreate(input.teamName);
|
|
238
|
+
const memberId = generateMemberId();
|
|
239
|
+
const member = Member.create({
|
|
240
|
+
id: memberId,
|
|
241
|
+
teamId: team.id,
|
|
242
|
+
displayName: input.displayName.trim(),
|
|
243
|
+
connectedAt: /* @__PURE__ */ new Date(),
|
|
244
|
+
status: "ONLINE" /* ONLINE */
|
|
245
|
+
});
|
|
246
|
+
await this.deps.memberRepository.save(member);
|
|
247
|
+
team.addMember(memberId);
|
|
248
|
+
await this.deps.teamRepository.save(team);
|
|
249
|
+
if (this.deps.onMemberJoined) {
|
|
250
|
+
const event = new MemberJoinedEvent(memberId, team.id, member.displayName);
|
|
251
|
+
await this.deps.onMemberJoined(event);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
memberId,
|
|
255
|
+
teamId: team.id,
|
|
256
|
+
teamName: team.name,
|
|
257
|
+
displayName: member.displayName,
|
|
258
|
+
status: member.status,
|
|
259
|
+
memberCount: team.memberCount
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// src/domain/entities/question.entity.ts
|
|
265
|
+
var Question = class _Question {
|
|
266
|
+
_id;
|
|
267
|
+
_fromMemberId;
|
|
268
|
+
_toTeamId;
|
|
269
|
+
_content;
|
|
270
|
+
_createdAt;
|
|
271
|
+
_status;
|
|
272
|
+
_answeredAt;
|
|
273
|
+
_answeredByMemberId;
|
|
274
|
+
constructor(props) {
|
|
275
|
+
this._id = props.id;
|
|
276
|
+
this._fromMemberId = props.fromMemberId;
|
|
277
|
+
this._toTeamId = props.toTeamId;
|
|
278
|
+
this._content = props.content;
|
|
279
|
+
this._createdAt = props.createdAt;
|
|
280
|
+
this._status = props.status;
|
|
281
|
+
this._answeredAt = props.answeredAt;
|
|
282
|
+
this._answeredByMemberId = props.answeredByMemberId;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Creates a new Question instance
|
|
286
|
+
*/
|
|
287
|
+
static create(props) {
|
|
288
|
+
return new _Question({
|
|
289
|
+
...props,
|
|
290
|
+
status: "PENDING" /* PENDING */
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Reconstitutes a Question from persistence
|
|
295
|
+
*/
|
|
296
|
+
static reconstitute(props) {
|
|
297
|
+
return new _Question(props);
|
|
298
|
+
}
|
|
299
|
+
// Getters
|
|
300
|
+
get id() {
|
|
301
|
+
return this._id;
|
|
302
|
+
}
|
|
303
|
+
get fromMemberId() {
|
|
304
|
+
return this._fromMemberId;
|
|
305
|
+
}
|
|
306
|
+
get toTeamId() {
|
|
307
|
+
return this._toTeamId;
|
|
308
|
+
}
|
|
309
|
+
get content() {
|
|
310
|
+
return this._content;
|
|
311
|
+
}
|
|
312
|
+
get createdAt() {
|
|
313
|
+
return this._createdAt;
|
|
314
|
+
}
|
|
315
|
+
get status() {
|
|
316
|
+
return this._status;
|
|
317
|
+
}
|
|
318
|
+
get answeredAt() {
|
|
319
|
+
return this._answeredAt;
|
|
320
|
+
}
|
|
321
|
+
get answeredByMemberId() {
|
|
322
|
+
return this._answeredByMemberId;
|
|
323
|
+
}
|
|
324
|
+
get isPending() {
|
|
325
|
+
return this._status === "PENDING" /* PENDING */;
|
|
326
|
+
}
|
|
327
|
+
get isAnswered() {
|
|
328
|
+
return this._status === "ANSWERED" /* ANSWERED */;
|
|
329
|
+
}
|
|
330
|
+
get isTimedOut() {
|
|
331
|
+
return this._status === "TIMEOUT" /* TIMEOUT */;
|
|
332
|
+
}
|
|
333
|
+
get isCancelled() {
|
|
334
|
+
return this._status === "CANCELLED" /* CANCELLED */;
|
|
335
|
+
}
|
|
336
|
+
get canBeAnswered() {
|
|
337
|
+
return this._status === "PENDING" /* PENDING */;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Calculates the age of the question in milliseconds
|
|
341
|
+
*/
|
|
342
|
+
get ageMs() {
|
|
343
|
+
return Date.now() - this._createdAt.getTime();
|
|
344
|
+
}
|
|
345
|
+
// Behaviors
|
|
346
|
+
/**
|
|
347
|
+
* Marks the question as answered
|
|
348
|
+
* @throws QuestionAlreadyAnsweredError if already answered
|
|
349
|
+
*/
|
|
350
|
+
markAsAnswered(answeredByMemberId) {
|
|
351
|
+
if (!this.canBeAnswered) {
|
|
352
|
+
throw new QuestionAlreadyAnsweredError(this._id);
|
|
353
|
+
}
|
|
354
|
+
this._status = "ANSWERED" /* ANSWERED */;
|
|
355
|
+
this._answeredAt = /* @__PURE__ */ new Date();
|
|
356
|
+
this._answeredByMemberId = answeredByMemberId;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Marks the question as timed out
|
|
360
|
+
*/
|
|
361
|
+
markAsTimedOut() {
|
|
362
|
+
if (this._status === "PENDING" /* PENDING */) {
|
|
363
|
+
this._status = "TIMEOUT" /* TIMEOUT */;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Marks the question as cancelled
|
|
368
|
+
*/
|
|
369
|
+
markAsCancelled() {
|
|
370
|
+
if (this._status === "PENDING" /* PENDING */) {
|
|
371
|
+
this._status = "CANCELLED" /* CANCELLED */;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Converts entity to plain object for serialization
|
|
376
|
+
*/
|
|
377
|
+
toJSON() {
|
|
378
|
+
return {
|
|
379
|
+
id: this._id,
|
|
380
|
+
fromMemberId: this._fromMemberId,
|
|
381
|
+
toTeamId: this._toTeamId,
|
|
382
|
+
content: this._content,
|
|
383
|
+
createdAt: this._createdAt,
|
|
384
|
+
status: this._status,
|
|
385
|
+
answeredAt: this._answeredAt,
|
|
386
|
+
answeredByMemberId: this._answeredByMemberId
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// src/config/index.ts
|
|
392
|
+
var config = {
|
|
393
|
+
/**
|
|
394
|
+
* WebSocket Hub configuration
|
|
395
|
+
*/
|
|
396
|
+
hub: {
|
|
397
|
+
/**
|
|
398
|
+
* Default port for the Hub server
|
|
399
|
+
*/
|
|
400
|
+
port: parseInt(process.env["CLAUDE_COLLAB_PORT"] ?? "9999", 10),
|
|
401
|
+
/**
|
|
402
|
+
* Host to bind the Hub server to
|
|
403
|
+
*/
|
|
404
|
+
host: process.env["CLAUDE_COLLAB_HOST"] ?? "localhost",
|
|
405
|
+
/**
|
|
406
|
+
* Heartbeat interval in milliseconds
|
|
407
|
+
*/
|
|
408
|
+
heartbeatInterval: 3e4,
|
|
409
|
+
/**
|
|
410
|
+
* Client timeout in milliseconds (no heartbeat received)
|
|
411
|
+
*/
|
|
412
|
+
clientTimeout: 6e4
|
|
413
|
+
},
|
|
414
|
+
/**
|
|
415
|
+
* Communication configuration
|
|
416
|
+
*/
|
|
417
|
+
communication: {
|
|
418
|
+
/**
|
|
419
|
+
* Default timeout for waiting for an answer (in milliseconds)
|
|
420
|
+
*/
|
|
421
|
+
defaultTimeout: 3e4,
|
|
422
|
+
/**
|
|
423
|
+
* Maximum message content length
|
|
424
|
+
*/
|
|
425
|
+
maxMessageLength: 5e4
|
|
426
|
+
},
|
|
427
|
+
/**
|
|
428
|
+
* Auto-start configuration
|
|
429
|
+
*/
|
|
430
|
+
autoStart: {
|
|
431
|
+
/**
|
|
432
|
+
* Maximum retries when connecting to hub
|
|
433
|
+
*/
|
|
434
|
+
maxRetries: 3,
|
|
435
|
+
/**
|
|
436
|
+
* Delay between retries in milliseconds
|
|
437
|
+
*/
|
|
438
|
+
retryDelay: 1e3
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// src/domain/value-objects/message-content.vo.ts
|
|
443
|
+
var MessageContent = class _MessageContent {
|
|
444
|
+
_text;
|
|
445
|
+
_format;
|
|
446
|
+
constructor(text, format) {
|
|
447
|
+
this._text = text;
|
|
448
|
+
this._format = format;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Creates a new MessageContent
|
|
452
|
+
* @throws ValidationError if content is invalid
|
|
453
|
+
*/
|
|
454
|
+
static create(text, format = "markdown") {
|
|
455
|
+
const trimmedText = text.trim();
|
|
456
|
+
if (!trimmedText) {
|
|
457
|
+
throw new ValidationError("text", "Message content cannot be empty");
|
|
458
|
+
}
|
|
459
|
+
if (trimmedText.length > config.communication.maxMessageLength) {
|
|
460
|
+
throw new ValidationError(
|
|
461
|
+
"text",
|
|
462
|
+
`Message content exceeds maximum length of ${config.communication.maxMessageLength} characters`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return new _MessageContent(trimmedText, format);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Creates a plain text message
|
|
469
|
+
*/
|
|
470
|
+
static plain(text) {
|
|
471
|
+
return _MessageContent.create(text, "plain");
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Creates a markdown message
|
|
475
|
+
*/
|
|
476
|
+
static markdown(text) {
|
|
477
|
+
return _MessageContent.create(text, "markdown");
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Reconstitutes from persistence
|
|
481
|
+
*/
|
|
482
|
+
static reconstitute(props) {
|
|
483
|
+
return new _MessageContent(props.text, props.format);
|
|
484
|
+
}
|
|
485
|
+
// Getters
|
|
486
|
+
get text() {
|
|
487
|
+
return this._text;
|
|
488
|
+
}
|
|
489
|
+
get format() {
|
|
490
|
+
return this._format;
|
|
491
|
+
}
|
|
492
|
+
get length() {
|
|
493
|
+
return this._text.length;
|
|
494
|
+
}
|
|
495
|
+
get isMarkdown() {
|
|
496
|
+
return this._format === "markdown";
|
|
497
|
+
}
|
|
498
|
+
get isPlain() {
|
|
499
|
+
return this._format === "plain";
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Returns a preview of the content (first 100 chars)
|
|
503
|
+
*/
|
|
504
|
+
get preview() {
|
|
505
|
+
if (this._text.length <= 100) {
|
|
506
|
+
return this._text;
|
|
507
|
+
}
|
|
508
|
+
return `${this._text.substring(0, 97)}...`;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Checks equality with another MessageContent
|
|
512
|
+
*/
|
|
513
|
+
equals(other) {
|
|
514
|
+
return this._text === other._text && this._format === other._format;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Converts to plain object for serialization
|
|
518
|
+
*/
|
|
519
|
+
toJSON() {
|
|
520
|
+
return {
|
|
521
|
+
text: this._text,
|
|
522
|
+
format: this._format
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* String representation
|
|
527
|
+
*/
|
|
528
|
+
toString() {
|
|
529
|
+
return this._text;
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// src/domain/events/question-asked.event.ts
|
|
534
|
+
var QuestionAskedEvent = class _QuestionAskedEvent extends BaseDomainEvent {
|
|
535
|
+
constructor(questionId, fromMemberId, toTeamId, contentPreview) {
|
|
536
|
+
super();
|
|
537
|
+
this.questionId = questionId;
|
|
538
|
+
this.fromMemberId = fromMemberId;
|
|
539
|
+
this.toTeamId = toTeamId;
|
|
540
|
+
this.contentPreview = contentPreview;
|
|
541
|
+
}
|
|
542
|
+
static EVENT_TYPE = "QUESTION_ASKED";
|
|
543
|
+
get eventType() {
|
|
544
|
+
return _QuestionAskedEvent.EVENT_TYPE;
|
|
545
|
+
}
|
|
546
|
+
get payload() {
|
|
547
|
+
return {
|
|
548
|
+
questionId: this.questionId,
|
|
549
|
+
fromMemberId: this.fromMemberId,
|
|
550
|
+
toTeamId: this.toTeamId,
|
|
551
|
+
contentPreview: this.contentPreview
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// src/application/use-cases/ask-question.use-case.ts
|
|
557
|
+
var AskQuestionUseCase = class {
|
|
558
|
+
constructor(deps) {
|
|
559
|
+
this.deps = deps;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Executes the use case
|
|
563
|
+
*/
|
|
564
|
+
async execute(input) {
|
|
565
|
+
const member = await this.deps.memberRepository.findById(input.fromMemberId);
|
|
566
|
+
if (!member) {
|
|
567
|
+
throw new MemberNotFoundError(input.fromMemberId);
|
|
568
|
+
}
|
|
569
|
+
const targetTeamId = createTeamId(input.toTeamName);
|
|
570
|
+
const targetTeam = await this.deps.teamRepository.findById(targetTeamId);
|
|
571
|
+
if (!targetTeam) {
|
|
572
|
+
throw new TeamNotFoundError(input.toTeamName);
|
|
573
|
+
}
|
|
574
|
+
if (member.teamId === targetTeamId) {
|
|
575
|
+
throw new ValidationError("toTeamName", "Cannot ask question to your own team");
|
|
576
|
+
}
|
|
577
|
+
const content = MessageContent.create(input.content, input.format ?? "markdown");
|
|
578
|
+
const questionId = generateQuestionId();
|
|
579
|
+
const question = Question.create({
|
|
580
|
+
id: questionId,
|
|
581
|
+
fromMemberId: input.fromMemberId,
|
|
582
|
+
toTeamId: targetTeamId,
|
|
583
|
+
content,
|
|
584
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
585
|
+
});
|
|
586
|
+
await this.deps.questionRepository.save(question);
|
|
587
|
+
member.recordActivity();
|
|
588
|
+
await this.deps.memberRepository.save(member);
|
|
589
|
+
if (this.deps.onQuestionAsked) {
|
|
590
|
+
const event = new QuestionAskedEvent(
|
|
591
|
+
questionId,
|
|
592
|
+
input.fromMemberId,
|
|
593
|
+
targetTeamId,
|
|
594
|
+
content.preview
|
|
595
|
+
);
|
|
596
|
+
await this.deps.onQuestionAsked(event);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
questionId,
|
|
600
|
+
toTeamId: targetTeamId,
|
|
601
|
+
status: question.status,
|
|
602
|
+
createdAt: question.createdAt
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// src/application/use-cases/get-inbox.use-case.ts
|
|
608
|
+
var GetInboxUseCase = class {
|
|
609
|
+
constructor(deps) {
|
|
610
|
+
this.deps = deps;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Executes the use case
|
|
614
|
+
*/
|
|
615
|
+
async execute(input) {
|
|
616
|
+
const member = await this.deps.memberRepository.findById(input.memberId);
|
|
617
|
+
if (!member) {
|
|
618
|
+
throw new MemberNotFoundError(input.memberId);
|
|
619
|
+
}
|
|
620
|
+
const team = await this.deps.teamRepository.findById(input.teamId);
|
|
621
|
+
if (!team) {
|
|
622
|
+
throw new TeamNotFoundError(input.teamId);
|
|
623
|
+
}
|
|
624
|
+
const allQuestions = await this.deps.questionRepository.findPendingByTeamId(input.teamId);
|
|
625
|
+
const questions = input.includeAnswered ? allQuestions : allQuestions.filter((q) => q.isPending);
|
|
626
|
+
const questionItems = [];
|
|
627
|
+
for (const question of questions) {
|
|
628
|
+
const fromMember = await this.deps.memberRepository.findById(question.fromMemberId);
|
|
629
|
+
const fromTeam = fromMember ? await this.deps.teamRepository.findById(fromMember.teamId) : null;
|
|
630
|
+
questionItems.push({
|
|
631
|
+
questionId: question.id,
|
|
632
|
+
fromMemberId: question.fromMemberId,
|
|
633
|
+
fromDisplayName: fromMember?.displayName ?? "Unknown",
|
|
634
|
+
fromTeamName: fromTeam?.name ?? "Unknown",
|
|
635
|
+
content: question.content.text,
|
|
636
|
+
format: question.content.format,
|
|
637
|
+
status: question.status,
|
|
638
|
+
createdAt: question.createdAt,
|
|
639
|
+
ageMs: question.ageMs
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
questionItems.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
643
|
+
const pendingCount = questionItems.filter((q) => q.status === "PENDING" /* PENDING */).length;
|
|
644
|
+
return {
|
|
645
|
+
teamId: team.id,
|
|
646
|
+
teamName: team.name,
|
|
647
|
+
questions: questionItems,
|
|
648
|
+
totalCount: questionItems.length,
|
|
649
|
+
pendingCount
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// src/domain/entities/answer.entity.ts
|
|
655
|
+
var Answer = class _Answer {
|
|
656
|
+
_id;
|
|
657
|
+
_questionId;
|
|
658
|
+
_fromMemberId;
|
|
659
|
+
_content;
|
|
660
|
+
_createdAt;
|
|
661
|
+
constructor(props) {
|
|
662
|
+
this._id = props.id;
|
|
663
|
+
this._questionId = props.questionId;
|
|
664
|
+
this._fromMemberId = props.fromMemberId;
|
|
665
|
+
this._content = props.content;
|
|
666
|
+
this._createdAt = props.createdAt;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Creates a new Answer instance
|
|
670
|
+
*/
|
|
671
|
+
static create(props) {
|
|
672
|
+
return new _Answer(props);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Reconstitutes an Answer from persistence
|
|
676
|
+
*/
|
|
677
|
+
static reconstitute(props) {
|
|
678
|
+
return new _Answer(props);
|
|
679
|
+
}
|
|
680
|
+
// Getters
|
|
681
|
+
get id() {
|
|
682
|
+
return this._id;
|
|
683
|
+
}
|
|
684
|
+
get questionId() {
|
|
685
|
+
return this._questionId;
|
|
686
|
+
}
|
|
687
|
+
get fromMemberId() {
|
|
688
|
+
return this._fromMemberId;
|
|
689
|
+
}
|
|
690
|
+
get content() {
|
|
691
|
+
return this._content;
|
|
692
|
+
}
|
|
693
|
+
get createdAt() {
|
|
694
|
+
return this._createdAt;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Converts entity to plain object for serialization
|
|
698
|
+
*/
|
|
699
|
+
toJSON() {
|
|
700
|
+
return {
|
|
701
|
+
id: this._id,
|
|
702
|
+
questionId: this._questionId,
|
|
703
|
+
fromMemberId: this._fromMemberId,
|
|
704
|
+
content: this._content,
|
|
705
|
+
createdAt: this._createdAt
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/domain/events/question-answered.event.ts
|
|
711
|
+
var QuestionAnsweredEvent = class _QuestionAnsweredEvent extends BaseDomainEvent {
|
|
712
|
+
constructor(questionId, answerId, answeredByMemberId, contentPreview) {
|
|
713
|
+
super();
|
|
714
|
+
this.questionId = questionId;
|
|
715
|
+
this.answerId = answerId;
|
|
716
|
+
this.answeredByMemberId = answeredByMemberId;
|
|
717
|
+
this.contentPreview = contentPreview;
|
|
718
|
+
}
|
|
719
|
+
static EVENT_TYPE = "QUESTION_ANSWERED";
|
|
720
|
+
get eventType() {
|
|
721
|
+
return _QuestionAnsweredEvent.EVENT_TYPE;
|
|
722
|
+
}
|
|
723
|
+
get payload() {
|
|
724
|
+
return {
|
|
725
|
+
questionId: this.questionId,
|
|
726
|
+
answerId: this.answerId,
|
|
727
|
+
answeredByMemberId: this.answeredByMemberId,
|
|
728
|
+
contentPreview: this.contentPreview
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// src/application/use-cases/reply-question.use-case.ts
|
|
734
|
+
var ReplyQuestionUseCase = class {
|
|
735
|
+
constructor(deps) {
|
|
736
|
+
this.deps = deps;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Executes the use case
|
|
740
|
+
*/
|
|
741
|
+
async execute(input) {
|
|
742
|
+
const member = await this.deps.memberRepository.findById(input.fromMemberId);
|
|
743
|
+
if (!member) {
|
|
744
|
+
throw new MemberNotFoundError(input.fromMemberId);
|
|
745
|
+
}
|
|
746
|
+
const question = await this.deps.questionRepository.findById(input.questionId);
|
|
747
|
+
if (!question) {
|
|
748
|
+
throw new QuestionNotFoundError(input.questionId);
|
|
749
|
+
}
|
|
750
|
+
if (!question.canBeAnswered) {
|
|
751
|
+
throw new QuestionAlreadyAnsweredError(input.questionId);
|
|
752
|
+
}
|
|
753
|
+
const content = MessageContent.create(input.content, input.format ?? "markdown");
|
|
754
|
+
const answerId = generateAnswerId();
|
|
755
|
+
const answer = Answer.create({
|
|
756
|
+
id: answerId,
|
|
757
|
+
questionId: input.questionId,
|
|
758
|
+
fromMemberId: input.fromMemberId,
|
|
759
|
+
content,
|
|
760
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
761
|
+
});
|
|
762
|
+
question.markAsAnswered(input.fromMemberId);
|
|
763
|
+
await this.deps.answerRepository.save(answer);
|
|
764
|
+
await this.deps.questionRepository.save(question);
|
|
765
|
+
member.recordActivity();
|
|
766
|
+
await this.deps.memberRepository.save(member);
|
|
767
|
+
if (this.deps.onQuestionAnswered) {
|
|
768
|
+
const event = new QuestionAnsweredEvent(
|
|
769
|
+
input.questionId,
|
|
770
|
+
answerId,
|
|
771
|
+
input.fromMemberId,
|
|
772
|
+
content.preview
|
|
773
|
+
);
|
|
774
|
+
await this.deps.onQuestionAnswered(event);
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
answerId,
|
|
778
|
+
questionId: input.questionId,
|
|
779
|
+
deliveredToMemberId: question.fromMemberId,
|
|
780
|
+
createdAt: answer.createdAt
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// src/infrastructure/repositories/in-memory-member.repository.ts
|
|
786
|
+
var InMemoryMemberRepository = class {
|
|
787
|
+
members = /* @__PURE__ */ new Map();
|
|
788
|
+
async save(member) {
|
|
789
|
+
this.members.set(member.id, member);
|
|
790
|
+
}
|
|
791
|
+
async findById(id) {
|
|
792
|
+
return this.members.get(id) ?? null;
|
|
793
|
+
}
|
|
794
|
+
async findByTeamId(teamId) {
|
|
795
|
+
return [...this.members.values()].filter((m) => m.teamId === teamId);
|
|
796
|
+
}
|
|
797
|
+
async findOnlineByTeamId(teamId) {
|
|
798
|
+
return [...this.members.values()].filter((m) => m.teamId === teamId && m.isOnline);
|
|
799
|
+
}
|
|
800
|
+
async delete(id) {
|
|
801
|
+
return this.members.delete(id);
|
|
802
|
+
}
|
|
803
|
+
async exists(id) {
|
|
804
|
+
return this.members.has(id);
|
|
805
|
+
}
|
|
806
|
+
async findAll() {
|
|
807
|
+
return [...this.members.values()];
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Clears all data (useful for testing)
|
|
811
|
+
*/
|
|
812
|
+
clear() {
|
|
813
|
+
this.members.clear();
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Gets the count of members
|
|
817
|
+
*/
|
|
818
|
+
get count() {
|
|
819
|
+
return this.members.size;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// src/domain/entities/team.entity.ts
|
|
824
|
+
var Team = class _Team {
|
|
825
|
+
_id;
|
|
826
|
+
_name;
|
|
827
|
+
_createdAt;
|
|
828
|
+
_memberIds;
|
|
829
|
+
constructor(props) {
|
|
830
|
+
this._id = props.id;
|
|
831
|
+
this._name = props.name;
|
|
832
|
+
this._createdAt = props.createdAt;
|
|
833
|
+
this._memberIds = /* @__PURE__ */ new Set();
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Creates a new Team instance
|
|
837
|
+
*/
|
|
838
|
+
static create(props) {
|
|
839
|
+
if (!props.name.trim()) {
|
|
840
|
+
throw new Error("Team name cannot be empty");
|
|
841
|
+
}
|
|
842
|
+
return new _Team(props);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Reconstitutes a Team from persistence
|
|
846
|
+
*/
|
|
847
|
+
static reconstitute(props) {
|
|
848
|
+
const team = new _Team(props);
|
|
849
|
+
for (const memberId of props.memberIds) {
|
|
850
|
+
team._memberIds.add(memberId);
|
|
851
|
+
}
|
|
852
|
+
return team;
|
|
853
|
+
}
|
|
854
|
+
// Getters
|
|
855
|
+
get id() {
|
|
856
|
+
return this._id;
|
|
857
|
+
}
|
|
858
|
+
get name() {
|
|
859
|
+
return this._name;
|
|
860
|
+
}
|
|
861
|
+
get createdAt() {
|
|
862
|
+
return this._createdAt;
|
|
863
|
+
}
|
|
864
|
+
get memberIds() {
|
|
865
|
+
return this._memberIds;
|
|
866
|
+
}
|
|
867
|
+
get memberCount() {
|
|
868
|
+
return this._memberIds.size;
|
|
869
|
+
}
|
|
870
|
+
get isEmpty() {
|
|
871
|
+
return this._memberIds.size === 0;
|
|
872
|
+
}
|
|
873
|
+
// Behaviors
|
|
874
|
+
/**
|
|
875
|
+
* Adds a member to the team
|
|
876
|
+
* @returns true if the member was added, false if already present
|
|
877
|
+
*/
|
|
878
|
+
addMember(memberId) {
|
|
879
|
+
if (this._memberIds.has(memberId)) {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
this._memberIds.add(memberId);
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Removes a member from the team
|
|
887
|
+
* @returns true if the member was removed, false if not present
|
|
888
|
+
*/
|
|
889
|
+
removeMember(memberId) {
|
|
890
|
+
return this._memberIds.delete(memberId);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Checks if a member is in the team
|
|
894
|
+
*/
|
|
895
|
+
hasMember(memberId) {
|
|
896
|
+
return this._memberIds.has(memberId);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Gets all member IDs except the specified one
|
|
900
|
+
* Useful for broadcasting to other team members
|
|
901
|
+
*/
|
|
902
|
+
getOtherMemberIds(excludeMemberId) {
|
|
903
|
+
return [...this._memberIds].filter((id) => id !== excludeMemberId);
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Converts entity to plain object for serialization
|
|
907
|
+
*/
|
|
908
|
+
toJSON() {
|
|
909
|
+
return {
|
|
910
|
+
id: this._id,
|
|
911
|
+
name: this._name,
|
|
912
|
+
createdAt: this._createdAt,
|
|
913
|
+
memberIds: [...this._memberIds]
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
// src/infrastructure/repositories/in-memory-team.repository.ts
|
|
919
|
+
var InMemoryTeamRepository = class {
|
|
920
|
+
teams = /* @__PURE__ */ new Map();
|
|
921
|
+
async save(team) {
|
|
922
|
+
this.teams.set(team.id, team);
|
|
923
|
+
}
|
|
924
|
+
async findById(id) {
|
|
925
|
+
return this.teams.get(id) ?? null;
|
|
926
|
+
}
|
|
927
|
+
async findByName(name) {
|
|
928
|
+
const teamId = createTeamId(name);
|
|
929
|
+
return this.teams.get(teamId) ?? null;
|
|
930
|
+
}
|
|
931
|
+
async getOrCreate(name) {
|
|
932
|
+
const existing = await this.findByName(name);
|
|
933
|
+
if (existing) {
|
|
934
|
+
return existing;
|
|
935
|
+
}
|
|
936
|
+
const teamId = createTeamId(name);
|
|
937
|
+
const team = Team.create({
|
|
938
|
+
id: teamId,
|
|
939
|
+
name: name.trim(),
|
|
940
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
941
|
+
});
|
|
942
|
+
await this.save(team);
|
|
943
|
+
return team;
|
|
944
|
+
}
|
|
945
|
+
async delete(id) {
|
|
946
|
+
return this.teams.delete(id);
|
|
947
|
+
}
|
|
948
|
+
async exists(id) {
|
|
949
|
+
return this.teams.has(id);
|
|
950
|
+
}
|
|
951
|
+
async findAll() {
|
|
952
|
+
return [...this.teams.values()];
|
|
953
|
+
}
|
|
954
|
+
async findNonEmpty() {
|
|
955
|
+
return [...this.teams.values()].filter((t) => !t.isEmpty);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Clears all data (useful for testing)
|
|
959
|
+
*/
|
|
960
|
+
clear() {
|
|
961
|
+
this.teams.clear();
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Gets the count of teams
|
|
965
|
+
*/
|
|
966
|
+
get count() {
|
|
967
|
+
return this.teams.size;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
// src/infrastructure/repositories/in-memory-question.repository.ts
|
|
972
|
+
var InMemoryQuestionRepository = class {
|
|
973
|
+
questions = /* @__PURE__ */ new Map();
|
|
974
|
+
async save(question) {
|
|
975
|
+
this.questions.set(question.id, question);
|
|
976
|
+
}
|
|
977
|
+
async findById(id) {
|
|
978
|
+
return this.questions.get(id) ?? null;
|
|
979
|
+
}
|
|
980
|
+
async findPendingByTeamId(teamId) {
|
|
981
|
+
return [...this.questions.values()].filter((q) => q.toTeamId === teamId && q.isPending);
|
|
982
|
+
}
|
|
983
|
+
async findByFromMemberId(memberId) {
|
|
984
|
+
return [...this.questions.values()].filter((q) => q.fromMemberId === memberId);
|
|
985
|
+
}
|
|
986
|
+
async findPendingByFromMemberId(memberId) {
|
|
987
|
+
return [...this.questions.values()].filter(
|
|
988
|
+
(q) => q.fromMemberId === memberId && q.isPending
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
async delete(id) {
|
|
992
|
+
return this.questions.delete(id);
|
|
993
|
+
}
|
|
994
|
+
async exists(id) {
|
|
995
|
+
return this.questions.has(id);
|
|
996
|
+
}
|
|
997
|
+
async findAll() {
|
|
998
|
+
return [...this.questions.values()];
|
|
999
|
+
}
|
|
1000
|
+
async markTimedOut(olderThanMs) {
|
|
1001
|
+
let count = 0;
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
for (const question of this.questions.values()) {
|
|
1004
|
+
if (question.isPending && now - question.createdAt.getTime() > olderThanMs) {
|
|
1005
|
+
question.markAsTimedOut();
|
|
1006
|
+
count++;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return count;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Clears all data (useful for testing)
|
|
1013
|
+
*/
|
|
1014
|
+
clear() {
|
|
1015
|
+
this.questions.clear();
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Gets the count of questions
|
|
1019
|
+
*/
|
|
1020
|
+
get count() {
|
|
1021
|
+
return this.questions.size;
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
// src/infrastructure/repositories/in-memory-answer.repository.ts
|
|
1026
|
+
var InMemoryAnswerRepository = class {
|
|
1027
|
+
answers = /* @__PURE__ */ new Map();
|
|
1028
|
+
async save(answer) {
|
|
1029
|
+
this.answers.set(answer.id, answer);
|
|
1030
|
+
}
|
|
1031
|
+
async findById(id) {
|
|
1032
|
+
return this.answers.get(id) ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
async findByQuestionId(questionId) {
|
|
1035
|
+
for (const answer of this.answers.values()) {
|
|
1036
|
+
if (answer.questionId === questionId) {
|
|
1037
|
+
return answer;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
async findAll() {
|
|
1043
|
+
return [...this.answers.values()];
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Clears all data (useful for testing)
|
|
1047
|
+
*/
|
|
1048
|
+
clear() {
|
|
1049
|
+
this.answers.clear();
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Gets the count of answers
|
|
1053
|
+
*/
|
|
1054
|
+
get count() {
|
|
1055
|
+
return this.answers.size;
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
// src/infrastructure/websocket/message-protocol.ts
|
|
1060
|
+
function serializeMessage(message) {
|
|
1061
|
+
return JSON.stringify(message);
|
|
1062
|
+
}
|
|
1063
|
+
function parseClientMessage(data) {
|
|
1064
|
+
const parsed = JSON.parse(data);
|
|
1065
|
+
validateClientMessage(parsed);
|
|
1066
|
+
return parsed;
|
|
1067
|
+
}
|
|
1068
|
+
function parseHubMessage(data) {
|
|
1069
|
+
return JSON.parse(data);
|
|
1070
|
+
}
|
|
1071
|
+
function validateClientMessage(message) {
|
|
1072
|
+
if (!message.type) {
|
|
1073
|
+
throw new Error("Message must have a type");
|
|
1074
|
+
}
|
|
1075
|
+
const validTypes = ["JOIN", "LEAVE", "ASK", "REPLY", "PING", "GET_INBOX"];
|
|
1076
|
+
if (!validTypes.includes(message.type)) {
|
|
1077
|
+
throw new Error(`Invalid message type: ${message.type}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function createErrorMessage(code, message, requestId) {
|
|
1081
|
+
return {
|
|
1082
|
+
type: "ERROR",
|
|
1083
|
+
code,
|
|
1084
|
+
message,
|
|
1085
|
+
requestId
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// src/infrastructure/websocket/hub-server.ts
|
|
1090
|
+
var HubServer = class {
|
|
1091
|
+
constructor(options = {}) {
|
|
1092
|
+
this.options = options;
|
|
1093
|
+
this.initializeUseCases();
|
|
1094
|
+
}
|
|
1095
|
+
wss = null;
|
|
1096
|
+
clients = /* @__PURE__ */ new Map();
|
|
1097
|
+
memberToWs = /* @__PURE__ */ new Map();
|
|
1098
|
+
// Repositories
|
|
1099
|
+
memberRepository = new InMemoryMemberRepository();
|
|
1100
|
+
teamRepository = new InMemoryTeamRepository();
|
|
1101
|
+
questionRepository = new InMemoryQuestionRepository();
|
|
1102
|
+
answerRepository = new InMemoryAnswerRepository();
|
|
1103
|
+
// Use cases
|
|
1104
|
+
joinTeamUseCase;
|
|
1105
|
+
askQuestionUseCase;
|
|
1106
|
+
getInboxUseCase;
|
|
1107
|
+
replyQuestionUseCase;
|
|
1108
|
+
heartbeatInterval = null;
|
|
1109
|
+
timeoutCheckInterval = null;
|
|
1110
|
+
initializeUseCases() {
|
|
1111
|
+
this.joinTeamUseCase = new JoinTeamUseCase({
|
|
1112
|
+
memberRepository: this.memberRepository,
|
|
1113
|
+
teamRepository: this.teamRepository,
|
|
1114
|
+
onMemberJoined: async (event) => {
|
|
1115
|
+
await this.broadcastToTeam(event.teamId, event.memberId, {
|
|
1116
|
+
type: "MEMBER_JOINED",
|
|
1117
|
+
member: await this.getMemberInfo(event.memberId)
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
this.askQuestionUseCase = new AskQuestionUseCase({
|
|
1122
|
+
memberRepository: this.memberRepository,
|
|
1123
|
+
teamRepository: this.teamRepository,
|
|
1124
|
+
questionRepository: this.questionRepository,
|
|
1125
|
+
onQuestionAsked: async (event) => {
|
|
1126
|
+
const question = await this.questionRepository.findById(event.questionId);
|
|
1127
|
+
if (question) {
|
|
1128
|
+
await this.deliverQuestion(question);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
this.getInboxUseCase = new GetInboxUseCase({
|
|
1133
|
+
memberRepository: this.memberRepository,
|
|
1134
|
+
teamRepository: this.teamRepository,
|
|
1135
|
+
questionRepository: this.questionRepository
|
|
1136
|
+
});
|
|
1137
|
+
this.replyQuestionUseCase = new ReplyQuestionUseCase({
|
|
1138
|
+
memberRepository: this.memberRepository,
|
|
1139
|
+
questionRepository: this.questionRepository,
|
|
1140
|
+
answerRepository: this.answerRepository,
|
|
1141
|
+
onQuestionAnswered: async (event) => {
|
|
1142
|
+
const question = await this.questionRepository.findById(event.questionId);
|
|
1143
|
+
const answer = await this.answerRepository.findByQuestionId(event.questionId);
|
|
1144
|
+
if (question && answer) {
|
|
1145
|
+
await this.deliverAnswer(question, answer, event.answeredByMemberId);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Starts the hub server
|
|
1152
|
+
*/
|
|
1153
|
+
async start() {
|
|
1154
|
+
const port = this.options.port ?? config.hub.port;
|
|
1155
|
+
const host = this.options.host ?? config.hub.host;
|
|
1156
|
+
return new Promise((resolve, reject) => {
|
|
1157
|
+
try {
|
|
1158
|
+
this.wss = new WebSocketServer({ port, host });
|
|
1159
|
+
this.wss.on("connection", (ws) => {
|
|
1160
|
+
this.handleConnection(ws);
|
|
1161
|
+
});
|
|
1162
|
+
this.wss.on("error", (error) => {
|
|
1163
|
+
console.error("Hub server error:", error);
|
|
1164
|
+
reject(error);
|
|
1165
|
+
});
|
|
1166
|
+
this.wss.on("listening", () => {
|
|
1167
|
+
console.log(`Hub server listening on ${host}:${port}`);
|
|
1168
|
+
this.startHeartbeat();
|
|
1169
|
+
this.startTimeoutCheck();
|
|
1170
|
+
resolve();
|
|
1171
|
+
});
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
reject(error);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Stops the hub server
|
|
1179
|
+
*/
|
|
1180
|
+
async stop() {
|
|
1181
|
+
if (this.heartbeatInterval) {
|
|
1182
|
+
clearInterval(this.heartbeatInterval);
|
|
1183
|
+
this.heartbeatInterval = null;
|
|
1184
|
+
}
|
|
1185
|
+
if (this.timeoutCheckInterval) {
|
|
1186
|
+
clearInterval(this.timeoutCheckInterval);
|
|
1187
|
+
this.timeoutCheckInterval = null;
|
|
1188
|
+
}
|
|
1189
|
+
return new Promise((resolve) => {
|
|
1190
|
+
if (this.wss) {
|
|
1191
|
+
for (const [ws] of this.clients) {
|
|
1192
|
+
ws.close();
|
|
1193
|
+
}
|
|
1194
|
+
this.clients.clear();
|
|
1195
|
+
this.memberToWs.clear();
|
|
1196
|
+
this.wss.close(() => {
|
|
1197
|
+
this.wss = null;
|
|
1198
|
+
console.log("Hub server stopped");
|
|
1199
|
+
resolve();
|
|
1200
|
+
});
|
|
1201
|
+
} else {
|
|
1202
|
+
resolve();
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
handleConnection(ws) {
|
|
1207
|
+
const connection = {
|
|
1208
|
+
ws,
|
|
1209
|
+
lastPing: /* @__PURE__ */ new Date()
|
|
1210
|
+
};
|
|
1211
|
+
this.clients.set(ws, connection);
|
|
1212
|
+
ws.on("message", async (data) => {
|
|
1213
|
+
await this.handleMessage(ws, data.toString());
|
|
1214
|
+
});
|
|
1215
|
+
ws.on("close", async () => {
|
|
1216
|
+
await this.handleDisconnect(ws);
|
|
1217
|
+
});
|
|
1218
|
+
ws.on("error", (error) => {
|
|
1219
|
+
console.error("Client connection error:", error);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
async handleMessage(ws, data) {
|
|
1223
|
+
const connection = this.clients.get(ws);
|
|
1224
|
+
if (!connection) return;
|
|
1225
|
+
try {
|
|
1226
|
+
const message = parseClientMessage(data);
|
|
1227
|
+
connection.lastPing = /* @__PURE__ */ new Date();
|
|
1228
|
+
switch (message.type) {
|
|
1229
|
+
case "JOIN":
|
|
1230
|
+
await this.handleJoin(ws, connection, message.teamName, message.displayName);
|
|
1231
|
+
break;
|
|
1232
|
+
case "LEAVE":
|
|
1233
|
+
await this.handleLeave(ws, connection);
|
|
1234
|
+
break;
|
|
1235
|
+
case "ASK":
|
|
1236
|
+
await this.handleAsk(ws, connection, message);
|
|
1237
|
+
break;
|
|
1238
|
+
case "REPLY":
|
|
1239
|
+
await this.handleReply(ws, connection, message);
|
|
1240
|
+
break;
|
|
1241
|
+
case "GET_INBOX":
|
|
1242
|
+
await this.handleGetInbox(ws, connection, message.requestId);
|
|
1243
|
+
break;
|
|
1244
|
+
case "PING":
|
|
1245
|
+
this.send(ws, { type: "PONG", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1246
|
+
break;
|
|
1247
|
+
}
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1250
|
+
this.send(ws, createErrorMessage("INVALID_MESSAGE", errorMessage));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async handleJoin(ws, connection, teamName, displayName) {
|
|
1254
|
+
try {
|
|
1255
|
+
const result = await this.joinTeamUseCase.execute({ teamName, displayName });
|
|
1256
|
+
connection.memberId = result.memberId;
|
|
1257
|
+
connection.teamId = result.teamId;
|
|
1258
|
+
this.memberToWs.set(result.memberId, ws);
|
|
1259
|
+
const memberInfo = await this.getMemberInfo(result.memberId);
|
|
1260
|
+
this.send(ws, {
|
|
1261
|
+
type: "JOINED",
|
|
1262
|
+
member: memberInfo,
|
|
1263
|
+
memberCount: result.memberCount
|
|
1264
|
+
});
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
const errorMessage = error instanceof Error ? error.message : "Join failed";
|
|
1267
|
+
this.send(ws, createErrorMessage("JOIN_FAILED", errorMessage));
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
async handleLeave(ws, connection) {
|
|
1271
|
+
if (connection.memberId && connection.teamId) {
|
|
1272
|
+
await this.removeMember(connection.memberId, connection.teamId);
|
|
1273
|
+
connection.memberId = void 0;
|
|
1274
|
+
connection.teamId = void 0;
|
|
1275
|
+
}
|
|
1276
|
+
this.send(ws, { type: "LEFT", memberId: connection.memberId });
|
|
1277
|
+
}
|
|
1278
|
+
async handleAsk(ws, connection, message) {
|
|
1279
|
+
if (!connection.memberId) {
|
|
1280
|
+
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first", message.requestId));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
const result = await this.askQuestionUseCase.execute({
|
|
1285
|
+
fromMemberId: connection.memberId,
|
|
1286
|
+
toTeamName: message.toTeam,
|
|
1287
|
+
content: message.content,
|
|
1288
|
+
format: message.format
|
|
1289
|
+
});
|
|
1290
|
+
this.send(ws, {
|
|
1291
|
+
type: "QUESTION_SENT",
|
|
1292
|
+
questionId: result.questionId,
|
|
1293
|
+
toTeamId: result.toTeamId,
|
|
1294
|
+
status: result.status,
|
|
1295
|
+
requestId: message.requestId
|
|
1296
|
+
});
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
const errorMessage = error instanceof Error ? error.message : "Ask failed";
|
|
1299
|
+
this.send(ws, createErrorMessage("ASK_FAILED", errorMessage, message.requestId));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
async handleReply(ws, connection, message) {
|
|
1303
|
+
if (!connection.memberId) {
|
|
1304
|
+
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first"));
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
await this.replyQuestionUseCase.execute({
|
|
1309
|
+
fromMemberId: connection.memberId,
|
|
1310
|
+
questionId: message.questionId,
|
|
1311
|
+
content: message.content,
|
|
1312
|
+
format: message.format
|
|
1313
|
+
});
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
const errorMessage = error instanceof Error ? error.message : "Reply failed";
|
|
1316
|
+
this.send(ws, createErrorMessage("REPLY_FAILED", errorMessage));
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async handleGetInbox(ws, connection, requestId) {
|
|
1320
|
+
if (!connection.memberId || !connection.teamId) {
|
|
1321
|
+
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first", requestId));
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
const result = await this.getInboxUseCase.execute({
|
|
1326
|
+
memberId: connection.memberId,
|
|
1327
|
+
teamId: connection.teamId
|
|
1328
|
+
});
|
|
1329
|
+
const questions = await Promise.all(
|
|
1330
|
+
result.questions.map(async (q) => ({
|
|
1331
|
+
questionId: q.questionId,
|
|
1332
|
+
from: await this.getMemberInfo(q.fromMemberId),
|
|
1333
|
+
content: q.content,
|
|
1334
|
+
format: q.format,
|
|
1335
|
+
status: q.status,
|
|
1336
|
+
createdAt: q.createdAt.toISOString(),
|
|
1337
|
+
ageMs: q.ageMs
|
|
1338
|
+
}))
|
|
1339
|
+
);
|
|
1340
|
+
this.send(ws, {
|
|
1341
|
+
type: "INBOX",
|
|
1342
|
+
questions,
|
|
1343
|
+
totalCount: result.totalCount,
|
|
1344
|
+
pendingCount: result.pendingCount,
|
|
1345
|
+
requestId
|
|
1346
|
+
});
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
const errorMessage = error instanceof Error ? error.message : "Get inbox failed";
|
|
1349
|
+
this.send(ws, createErrorMessage("INBOX_FAILED", errorMessage, requestId));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async handleDisconnect(ws) {
|
|
1353
|
+
const connection = this.clients.get(ws);
|
|
1354
|
+
if (connection?.memberId && connection.teamId) {
|
|
1355
|
+
await this.removeMember(connection.memberId, connection.teamId);
|
|
1356
|
+
this.memberToWs.delete(connection.memberId);
|
|
1357
|
+
}
|
|
1358
|
+
this.clients.delete(ws);
|
|
1359
|
+
}
|
|
1360
|
+
async removeMember(memberId, teamId) {
|
|
1361
|
+
const member = await this.memberRepository.findById(memberId);
|
|
1362
|
+
if (member) {
|
|
1363
|
+
member.goOffline();
|
|
1364
|
+
await this.memberRepository.save(member);
|
|
1365
|
+
}
|
|
1366
|
+
const team = await this.teamRepository.findById(teamId);
|
|
1367
|
+
if (team) {
|
|
1368
|
+
team.removeMember(memberId);
|
|
1369
|
+
await this.teamRepository.save(team);
|
|
1370
|
+
await this.broadcastToTeam(teamId, memberId, {
|
|
1371
|
+
type: "MEMBER_LEFT",
|
|
1372
|
+
memberId,
|
|
1373
|
+
teamId
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async deliverQuestion(question) {
|
|
1378
|
+
const team = await this.teamRepository.findById(question.toTeamId);
|
|
1379
|
+
if (!team) return;
|
|
1380
|
+
const fromMember = await this.memberRepository.findById(question.fromMemberId);
|
|
1381
|
+
if (!fromMember) return;
|
|
1382
|
+
const memberInfo = await this.getMemberInfo(question.fromMemberId);
|
|
1383
|
+
for (const memberId of team.memberIds) {
|
|
1384
|
+
const ws = this.memberToWs.get(memberId);
|
|
1385
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1386
|
+
this.send(ws, {
|
|
1387
|
+
type: "QUESTION",
|
|
1388
|
+
questionId: question.id,
|
|
1389
|
+
from: memberInfo,
|
|
1390
|
+
content: question.content.text,
|
|
1391
|
+
format: question.content.format,
|
|
1392
|
+
createdAt: question.createdAt.toISOString()
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
async deliverAnswer(question, answer, answeredByMemberId) {
|
|
1398
|
+
const ws = this.memberToWs.get(question.fromMemberId);
|
|
1399
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1400
|
+
const memberInfo = await this.getMemberInfo(answeredByMemberId);
|
|
1401
|
+
this.send(ws, {
|
|
1402
|
+
type: "ANSWER",
|
|
1403
|
+
questionId: question.id,
|
|
1404
|
+
from: memberInfo,
|
|
1405
|
+
content: answer.content.text,
|
|
1406
|
+
format: answer.content.format,
|
|
1407
|
+
answeredAt: answer.createdAt.toISOString()
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
async broadcastToTeam(teamId, excludeMemberId, message) {
|
|
1411
|
+
const team = await this.teamRepository.findById(teamId);
|
|
1412
|
+
if (!team) return;
|
|
1413
|
+
for (const memberId of team.getOtherMemberIds(excludeMemberId)) {
|
|
1414
|
+
const ws = this.memberToWs.get(memberId);
|
|
1415
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1416
|
+
this.send(ws, message);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
async getMemberInfo(memberId) {
|
|
1421
|
+
const member = await this.memberRepository.findById(memberId);
|
|
1422
|
+
const team = member ? await this.teamRepository.findById(member.teamId) : null;
|
|
1423
|
+
return {
|
|
1424
|
+
memberId,
|
|
1425
|
+
teamId: member?.teamId ?? "",
|
|
1426
|
+
teamName: team?.name ?? "Unknown",
|
|
1427
|
+
displayName: member?.displayName ?? "Unknown",
|
|
1428
|
+
status: member?.status ?? "OFFLINE" /* OFFLINE */
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
send(ws, message) {
|
|
1432
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1433
|
+
ws.send(serializeMessage(message));
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
startHeartbeat() {
|
|
1437
|
+
this.heartbeatInterval = setInterval(() => {
|
|
1438
|
+
const now = /* @__PURE__ */ new Date();
|
|
1439
|
+
for (const [ws, connection] of this.clients) {
|
|
1440
|
+
const timeSinceLastPing = now.getTime() - connection.lastPing.getTime();
|
|
1441
|
+
if (timeSinceLastPing > config.hub.clientTimeout) {
|
|
1442
|
+
ws.terminate();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}, config.hub.heartbeatInterval);
|
|
1446
|
+
}
|
|
1447
|
+
startTimeoutCheck() {
|
|
1448
|
+
this.timeoutCheckInterval = setInterval(async () => {
|
|
1449
|
+
await this.questionRepository.markTimedOut(config.communication.defaultTimeout);
|
|
1450
|
+
}, 5e3);
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Gets the number of connected clients
|
|
1454
|
+
*/
|
|
1455
|
+
get clientCount() {
|
|
1456
|
+
return this.clients.size;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Checks if the server is running
|
|
1460
|
+
*/
|
|
1461
|
+
get isRunning() {
|
|
1462
|
+
return this.wss !== null;
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
var HubClient = class {
|
|
1466
|
+
constructor(options = {}, events = {}) {
|
|
1467
|
+
this.options = options;
|
|
1468
|
+
this.events = events;
|
|
1469
|
+
}
|
|
1470
|
+
ws = null;
|
|
1471
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
1472
|
+
reconnectAttempts = 0;
|
|
1473
|
+
isClosing = false;
|
|
1474
|
+
memberId;
|
|
1475
|
+
teamId;
|
|
1476
|
+
teamName;
|
|
1477
|
+
displayName;
|
|
1478
|
+
/**
|
|
1479
|
+
* Connects to the Hub server
|
|
1480
|
+
*/
|
|
1481
|
+
async connect() {
|
|
1482
|
+
const host = this.options.host ?? config.hub.host;
|
|
1483
|
+
const port = this.options.port ?? config.hub.port;
|
|
1484
|
+
const url = `ws://${host}:${port}`;
|
|
1485
|
+
return new Promise((resolve, reject) => {
|
|
1486
|
+
try {
|
|
1487
|
+
this.ws = new WebSocket2(url);
|
|
1488
|
+
this.ws.on("open", () => {
|
|
1489
|
+
this.reconnectAttempts = 0;
|
|
1490
|
+
this.startPingInterval();
|
|
1491
|
+
this.events.onConnected?.();
|
|
1492
|
+
resolve();
|
|
1493
|
+
});
|
|
1494
|
+
this.ws.on("message", (data) => {
|
|
1495
|
+
this.handleMessage(data.toString());
|
|
1496
|
+
});
|
|
1497
|
+
this.ws.on("close", () => {
|
|
1498
|
+
this.handleDisconnect();
|
|
1499
|
+
});
|
|
1500
|
+
this.ws.on("error", (error) => {
|
|
1501
|
+
this.events.onError?.(error);
|
|
1502
|
+
if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) {
|
|
1503
|
+
reject(error);
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
reject(error);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Disconnects from the Hub server
|
|
1513
|
+
*/
|
|
1514
|
+
async disconnect() {
|
|
1515
|
+
this.isClosing = true;
|
|
1516
|
+
if (this.memberId) {
|
|
1517
|
+
this.send({ type: "LEAVE" });
|
|
1518
|
+
}
|
|
1519
|
+
return new Promise((resolve) => {
|
|
1520
|
+
if (this.ws) {
|
|
1521
|
+
this.ws.close();
|
|
1522
|
+
this.ws = null;
|
|
1523
|
+
}
|
|
1524
|
+
this.isClosing = false;
|
|
1525
|
+
resolve();
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Joins a team
|
|
1530
|
+
*/
|
|
1531
|
+
async join(teamName, displayName) {
|
|
1532
|
+
v4();
|
|
1533
|
+
this.send({
|
|
1534
|
+
type: "JOIN",
|
|
1535
|
+
teamName,
|
|
1536
|
+
displayName
|
|
1537
|
+
});
|
|
1538
|
+
const response = await this.waitForResponse(
|
|
1539
|
+
(msg) => msg.type === "JOINED",
|
|
1540
|
+
3e4
|
|
1541
|
+
);
|
|
1542
|
+
this.memberId = response.member.memberId;
|
|
1543
|
+
this.teamId = response.member.teamId;
|
|
1544
|
+
this.teamName = teamName;
|
|
1545
|
+
this.displayName = displayName;
|
|
1546
|
+
return response.member;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Asks a question to another team
|
|
1550
|
+
*/
|
|
1551
|
+
async ask(toTeam, content, format = "markdown", timeoutMs = config.communication.defaultTimeout) {
|
|
1552
|
+
const requestId = v4();
|
|
1553
|
+
this.send({
|
|
1554
|
+
type: "ASK",
|
|
1555
|
+
toTeam,
|
|
1556
|
+
content,
|
|
1557
|
+
format,
|
|
1558
|
+
requestId
|
|
1559
|
+
});
|
|
1560
|
+
await this.waitForResponse(
|
|
1561
|
+
(msg) => msg.type === "QUESTION_SENT" && "requestId" in msg && msg.requestId === requestId,
|
|
1562
|
+
5e3
|
|
1563
|
+
);
|
|
1564
|
+
const answer = await this.waitForResponse(
|
|
1565
|
+
(msg) => msg.type === "ANSWER",
|
|
1566
|
+
timeoutMs
|
|
1567
|
+
);
|
|
1568
|
+
return answer;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Gets the inbox (pending questions)
|
|
1572
|
+
*/
|
|
1573
|
+
async getInbox() {
|
|
1574
|
+
const requestId = v4();
|
|
1575
|
+
this.send({
|
|
1576
|
+
type: "GET_INBOX",
|
|
1577
|
+
requestId
|
|
1578
|
+
});
|
|
1579
|
+
return this.waitForResponse(
|
|
1580
|
+
(msg) => msg.type === "INBOX" && msg.requestId === requestId,
|
|
1581
|
+
5e3
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Replies to a question
|
|
1586
|
+
*/
|
|
1587
|
+
async reply(questionId, content, format = "markdown") {
|
|
1588
|
+
this.send({
|
|
1589
|
+
type: "REPLY",
|
|
1590
|
+
questionId,
|
|
1591
|
+
content,
|
|
1592
|
+
format
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Checks if connected
|
|
1597
|
+
*/
|
|
1598
|
+
get isConnected() {
|
|
1599
|
+
return this.ws !== null && this.ws.readyState === WebSocket2.OPEN;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Gets the current member ID
|
|
1603
|
+
*/
|
|
1604
|
+
get currentMemberId() {
|
|
1605
|
+
return this.memberId;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Gets the current team ID
|
|
1609
|
+
*/
|
|
1610
|
+
get currentTeamId() {
|
|
1611
|
+
return this.teamId;
|
|
1612
|
+
}
|
|
1613
|
+
send(message) {
|
|
1614
|
+
if (this.ws && this.ws.readyState === WebSocket2.OPEN) {
|
|
1615
|
+
this.ws.send(serializeMessage(message));
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
handleMessage(data) {
|
|
1619
|
+
try {
|
|
1620
|
+
const message = parseHubMessage(data);
|
|
1621
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
1622
|
+
}
|
|
1623
|
+
switch (message.type) {
|
|
1624
|
+
case "QUESTION":
|
|
1625
|
+
this.events.onQuestion?.(message);
|
|
1626
|
+
break;
|
|
1627
|
+
case "ANSWER":
|
|
1628
|
+
this.events.onAnswer?.(message);
|
|
1629
|
+
break;
|
|
1630
|
+
case "MEMBER_JOINED":
|
|
1631
|
+
this.events.onMemberJoined?.(message.member);
|
|
1632
|
+
break;
|
|
1633
|
+
case "MEMBER_LEFT":
|
|
1634
|
+
this.events.onMemberLeft?.(message.memberId, message.teamId);
|
|
1635
|
+
break;
|
|
1636
|
+
case "ERROR":
|
|
1637
|
+
this.events.onError?.(new Error(`${message.code}: ${message.message}`));
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1640
|
+
this.resolvePendingRequest(message);
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
console.error("Failed to parse message:", error);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
resolvePendingRequest(message) {
|
|
1646
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
1647
|
+
this.pendingRequests.delete(requestId);
|
|
1648
|
+
clearTimeout(pending.timeout);
|
|
1649
|
+
pending.resolve(message);
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
waitForResponse(filter, timeoutMs) {
|
|
1654
|
+
return new Promise((resolve, reject) => {
|
|
1655
|
+
const requestId = v4();
|
|
1656
|
+
const timeout = setTimeout(() => {
|
|
1657
|
+
this.pendingRequests.delete(requestId);
|
|
1658
|
+
reject(new Error("Request timed out"));
|
|
1659
|
+
}, timeoutMs);
|
|
1660
|
+
const pending = {
|
|
1661
|
+
resolve: (msg) => {
|
|
1662
|
+
if (filter(msg)) {
|
|
1663
|
+
resolve(msg);
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
reject,
|
|
1667
|
+
timeout,
|
|
1668
|
+
filter
|
|
1669
|
+
};
|
|
1670
|
+
this.pendingRequests.set(requestId, pending);
|
|
1671
|
+
const originalHandler = this.handleMessage.bind(this);
|
|
1672
|
+
const checkFilter = (data) => {
|
|
1673
|
+
try {
|
|
1674
|
+
const message = parseHubMessage(data);
|
|
1675
|
+
if (filter(message)) {
|
|
1676
|
+
this.pendingRequests.delete(requestId);
|
|
1677
|
+
clearTimeout(timeout);
|
|
1678
|
+
resolve(message);
|
|
1679
|
+
}
|
|
1680
|
+
} catch {
|
|
1681
|
+
}
|
|
1682
|
+
originalHandler(data);
|
|
1683
|
+
};
|
|
1684
|
+
if (this.ws) {
|
|
1685
|
+
this.ws.removeAllListeners("message");
|
|
1686
|
+
this.ws.on("message", (data) => checkFilter(data.toString()));
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
handleDisconnect() {
|
|
1691
|
+
this.events.onDisconnected?.();
|
|
1692
|
+
if (this.isClosing) return;
|
|
1693
|
+
const shouldReconnect = this.options.reconnect ?? true;
|
|
1694
|
+
const maxAttempts = this.options.maxReconnectAttempts ?? config.autoStart.maxRetries;
|
|
1695
|
+
if (shouldReconnect && this.reconnectAttempts < maxAttempts) {
|
|
1696
|
+
this.reconnectAttempts++;
|
|
1697
|
+
const delay = this.options.reconnectDelay ?? config.autoStart.retryDelay;
|
|
1698
|
+
setTimeout(() => {
|
|
1699
|
+
this.connect().then(() => {
|
|
1700
|
+
if (this.teamName && this.displayName) {
|
|
1701
|
+
return this.join(this.teamName, this.displayName);
|
|
1702
|
+
}
|
|
1703
|
+
}).catch((error) => {
|
|
1704
|
+
this.events.onError?.(error);
|
|
1705
|
+
});
|
|
1706
|
+
}, delay);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
pingInterval = null;
|
|
1710
|
+
startPingInterval() {
|
|
1711
|
+
this.pingInterval = setInterval(() => {
|
|
1712
|
+
this.send({ type: "PING" });
|
|
1713
|
+
}, config.hub.heartbeatInterval);
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
var joinSchema = {
|
|
1717
|
+
team: z.string().describe('Team name to join (e.g., "frontend", "backend", "devops")'),
|
|
1718
|
+
displayName: z.string().optional().describe('Display name for this terminal (default: team + " Claude")')
|
|
1719
|
+
};
|
|
1720
|
+
function registerJoinTool(server, hubClient) {
|
|
1721
|
+
server.tool("join", joinSchema, async (args) => {
|
|
1722
|
+
const teamName = args.team;
|
|
1723
|
+
const displayName = args.displayName ?? `${teamName} Claude`;
|
|
1724
|
+
try {
|
|
1725
|
+
if (!hubClient.isConnected) {
|
|
1726
|
+
await hubClient.connect();
|
|
1727
|
+
}
|
|
1728
|
+
const member = await hubClient.join(teamName, displayName);
|
|
1729
|
+
return {
|
|
1730
|
+
content: [
|
|
1731
|
+
{
|
|
1732
|
+
type: "text",
|
|
1733
|
+
text: `Successfully joined team "${member.teamName}" as "${member.displayName}".
|
|
1734
|
+
|
|
1735
|
+
Your member ID: ${member.memberId}
|
|
1736
|
+
Team ID: ${member.teamId}
|
|
1737
|
+
Status: ${member.status}`
|
|
1738
|
+
}
|
|
1739
|
+
]
|
|
1740
|
+
};
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1743
|
+
return {
|
|
1744
|
+
content: [
|
|
1745
|
+
{
|
|
1746
|
+
type: "text",
|
|
1747
|
+
text: `Failed to join team: ${errorMessage}`
|
|
1748
|
+
}
|
|
1749
|
+
],
|
|
1750
|
+
isError: true
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
var askSchema = {
|
|
1756
|
+
team: z.string().describe('Target team name to ask (e.g., "backend", "frontend")'),
|
|
1757
|
+
question: z.string().describe("The question to ask (supports markdown)"),
|
|
1758
|
+
timeout: z.number().optional().describe(`Timeout in seconds to wait for answer (default: ${config.communication.defaultTimeout / 1e3}s)`)
|
|
1759
|
+
};
|
|
1760
|
+
function registerAskTool(server, hubClient) {
|
|
1761
|
+
server.tool("ask", askSchema, async (args) => {
|
|
1762
|
+
const targetTeam = args.team;
|
|
1763
|
+
const question = args.question;
|
|
1764
|
+
const timeoutMs = (args.timeout ?? config.communication.defaultTimeout / 1e3) * 1e3;
|
|
1765
|
+
try {
|
|
1766
|
+
if (!hubClient.currentTeamId) {
|
|
1767
|
+
return {
|
|
1768
|
+
content: [
|
|
1769
|
+
{
|
|
1770
|
+
type: "text",
|
|
1771
|
+
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
1772
|
+
}
|
|
1773
|
+
],
|
|
1774
|
+
isError: true
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
const answer = await hubClient.ask(targetTeam, question, "markdown", timeoutMs);
|
|
1778
|
+
return {
|
|
1779
|
+
content: [
|
|
1780
|
+
{
|
|
1781
|
+
type: "text",
|
|
1782
|
+
text: `**Answer from ${answer.from.displayName} (${answer.from.teamName}):**
|
|
1783
|
+
|
|
1784
|
+
${answer.content}`
|
|
1785
|
+
}
|
|
1786
|
+
]
|
|
1787
|
+
};
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1790
|
+
if (errorMessage.includes("timed out")) {
|
|
1791
|
+
return {
|
|
1792
|
+
content: [
|
|
1793
|
+
{
|
|
1794
|
+
type: "text",
|
|
1795
|
+
text: `No response received from team "${targetTeam}" within ${timeoutMs / 1e3} seconds. The question has been delivered but no one answered yet.`
|
|
1796
|
+
}
|
|
1797
|
+
]
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
content: [
|
|
1802
|
+
{
|
|
1803
|
+
type: "text",
|
|
1804
|
+
text: `Failed to ask question: ${errorMessage}`
|
|
1805
|
+
}
|
|
1806
|
+
],
|
|
1807
|
+
isError: true
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// src/presentation/mcp/tools/inbox.tool.ts
|
|
1814
|
+
var inboxSchema = {};
|
|
1815
|
+
function registerInboxTool(server, hubClient) {
|
|
1816
|
+
server.tool("inbox", inboxSchema, async () => {
|
|
1817
|
+
try {
|
|
1818
|
+
if (!hubClient.currentTeamId) {
|
|
1819
|
+
return {
|
|
1820
|
+
content: [
|
|
1821
|
+
{
|
|
1822
|
+
type: "text",
|
|
1823
|
+
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
1824
|
+
}
|
|
1825
|
+
],
|
|
1826
|
+
isError: true
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
const inbox = await hubClient.getInbox();
|
|
1830
|
+
if (inbox.questions.length === 0) {
|
|
1831
|
+
return {
|
|
1832
|
+
content: [
|
|
1833
|
+
{
|
|
1834
|
+
type: "text",
|
|
1835
|
+
text: "No pending questions in your inbox."
|
|
1836
|
+
}
|
|
1837
|
+
]
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
const questionsList = inbox.questions.map((q, i) => {
|
|
1841
|
+
const ageSeconds = Math.floor(q.ageMs / 1e3);
|
|
1842
|
+
const ageStr = ageSeconds < 60 ? `${ageSeconds}s ago` : `${Math.floor(ageSeconds / 60)}m ago`;
|
|
1843
|
+
return `### ${i + 1}. Question from ${q.from.displayName} (${q.from.teamName}) - ${ageStr}
|
|
1844
|
+
**ID:** \`${q.questionId}\`
|
|
1845
|
+
**Status:** ${q.status}
|
|
1846
|
+
|
|
1847
|
+
${q.content}
|
|
1848
|
+
|
|
1849
|
+
---`;
|
|
1850
|
+
}).join("\n\n");
|
|
1851
|
+
return {
|
|
1852
|
+
content: [
|
|
1853
|
+
{
|
|
1854
|
+
type: "text",
|
|
1855
|
+
text: `# Inbox (${inbox.pendingCount} pending, ${inbox.totalCount} total)
|
|
1856
|
+
|
|
1857
|
+
${questionsList}
|
|
1858
|
+
|
|
1859
|
+
Use the "reply" tool with the question ID to answer a question.`
|
|
1860
|
+
}
|
|
1861
|
+
]
|
|
1862
|
+
};
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1865
|
+
return {
|
|
1866
|
+
content: [
|
|
1867
|
+
{
|
|
1868
|
+
type: "text",
|
|
1869
|
+
text: `Failed to get inbox: ${errorMessage}`
|
|
1870
|
+
}
|
|
1871
|
+
],
|
|
1872
|
+
isError: true
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
var replySchema = {
|
|
1878
|
+
questionId: z.string().describe("The ID of the question to reply to (from inbox)"),
|
|
1879
|
+
answer: z.string().describe("Your answer to the question (supports markdown)")
|
|
1880
|
+
};
|
|
1881
|
+
function registerReplyTool(server, hubClient) {
|
|
1882
|
+
server.tool("reply", replySchema, async (args) => {
|
|
1883
|
+
const questionId = args.questionId;
|
|
1884
|
+
const answer = args.answer;
|
|
1885
|
+
try {
|
|
1886
|
+
if (!hubClient.currentTeamId) {
|
|
1887
|
+
return {
|
|
1888
|
+
content: [
|
|
1889
|
+
{
|
|
1890
|
+
type: "text",
|
|
1891
|
+
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
1892
|
+
}
|
|
1893
|
+
],
|
|
1894
|
+
isError: true
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
await hubClient.reply(questionId, answer, "markdown");
|
|
1898
|
+
return {
|
|
1899
|
+
content: [
|
|
1900
|
+
{
|
|
1901
|
+
type: "text",
|
|
1902
|
+
text: `Reply sent successfully to question \`${questionId}\`.`
|
|
1903
|
+
}
|
|
1904
|
+
]
|
|
1905
|
+
};
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1908
|
+
return {
|
|
1909
|
+
content: [
|
|
1910
|
+
{
|
|
1911
|
+
type: "text",
|
|
1912
|
+
text: `Failed to send reply: ${errorMessage}`
|
|
1913
|
+
}
|
|
1914
|
+
],
|
|
1915
|
+
isError: true
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// src/presentation/mcp/server.ts
|
|
1922
|
+
function createMcpServer(options) {
|
|
1923
|
+
const server = new McpServer({
|
|
1924
|
+
name: "claude-collab",
|
|
1925
|
+
version: "0.1.0"
|
|
1926
|
+
});
|
|
1927
|
+
registerJoinTool(server, options.hubClient);
|
|
1928
|
+
registerAskTool(server, options.hubClient);
|
|
1929
|
+
registerInboxTool(server, options.hubClient);
|
|
1930
|
+
registerReplyTool(server, options.hubClient);
|
|
1931
|
+
return server;
|
|
1932
|
+
}
|
|
1933
|
+
async function startMcpServer(options) {
|
|
1934
|
+
const server = createMcpServer(options);
|
|
1935
|
+
const transport = new StdioServerTransport();
|
|
1936
|
+
await server.connect(transport);
|
|
1937
|
+
}
|
|
1938
|
+
async function isHubRunning(host = config.hub.host, port = config.hub.port) {
|
|
1939
|
+
return new Promise((resolve) => {
|
|
1940
|
+
const socket = createConnection({ host, port }, () => {
|
|
1941
|
+
socket.end();
|
|
1942
|
+
resolve(true);
|
|
1943
|
+
});
|
|
1944
|
+
socket.on("error", () => {
|
|
1945
|
+
resolve(false);
|
|
1946
|
+
});
|
|
1947
|
+
socket.setTimeout(1e3, () => {
|
|
1948
|
+
socket.destroy();
|
|
1949
|
+
resolve(false);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
async function waitForHub(host = config.hub.host, port = config.hub.port, maxRetries = config.autoStart.maxRetries, retryDelay = config.autoStart.retryDelay) {
|
|
1954
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
1955
|
+
if (await isHubRunning(host, port)) {
|
|
1956
|
+
return true;
|
|
1957
|
+
}
|
|
1958
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
1959
|
+
}
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
function startHubProcess(options = {}) {
|
|
1963
|
+
const host = options.host ?? config.hub.host;
|
|
1964
|
+
const port = options.port ?? config.hub.port;
|
|
1965
|
+
const hubProcess = spawn(
|
|
1966
|
+
process.execPath,
|
|
1967
|
+
[
|
|
1968
|
+
"--experimental-specifier-resolution=node",
|
|
1969
|
+
new URL("../../hub-main.js", import.meta.url).pathname,
|
|
1970
|
+
"--host",
|
|
1971
|
+
host,
|
|
1972
|
+
"--port",
|
|
1973
|
+
port.toString()
|
|
1974
|
+
],
|
|
1975
|
+
{
|
|
1976
|
+
detached: true,
|
|
1977
|
+
stdio: "ignore"
|
|
1978
|
+
}
|
|
1979
|
+
);
|
|
1980
|
+
hubProcess.unref();
|
|
1981
|
+
return hubProcess;
|
|
1982
|
+
}
|
|
1983
|
+
async function ensureHubRunning(options = {}) {
|
|
1984
|
+
const host = options.host ?? config.hub.host;
|
|
1985
|
+
const port = options.port ?? config.hub.port;
|
|
1986
|
+
const maxRetries = options.maxRetries ?? config.autoStart.maxRetries;
|
|
1987
|
+
const retryDelay = options.retryDelay ?? config.autoStart.retryDelay;
|
|
1988
|
+
if (await isHubRunning(host, port)) {
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
console.log(`Hub not running. Starting hub on ${host}:${port}...`);
|
|
1992
|
+
startHubProcess({ host, port });
|
|
1993
|
+
const isRunning = await waitForHub(host, port, maxRetries, retryDelay);
|
|
1994
|
+
if (isRunning) {
|
|
1995
|
+
console.log("Hub started successfully");
|
|
1996
|
+
} else {
|
|
1997
|
+
console.error("Failed to start hub");
|
|
1998
|
+
}
|
|
1999
|
+
return isRunning;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// src/cli.ts
|
|
2003
|
+
var program = new Command();
|
|
2004
|
+
program.name("claude-collab").description("Real-time team collaboration between Claude Code terminals").version("0.1.0");
|
|
2005
|
+
var hubCmd = program.command("hub").description("Hub server commands");
|
|
2006
|
+
hubCmd.command("start").description("Start the Hub server").option("-p, --port <port>", "Port to listen on", String(config.hub.port)).option("--host <host>", "Host to bind to", config.hub.host).action(async (options) => {
|
|
2007
|
+
const port = parseInt(options.port, 10);
|
|
2008
|
+
const host = options.host;
|
|
2009
|
+
const server = new HubServer({ host, port });
|
|
2010
|
+
const shutdown = async () => {
|
|
2011
|
+
console.log("\nShutting down hub server...");
|
|
2012
|
+
await server.stop();
|
|
2013
|
+
process.exit(0);
|
|
2014
|
+
};
|
|
2015
|
+
process.on("SIGINT", shutdown);
|
|
2016
|
+
process.on("SIGTERM", shutdown);
|
|
2017
|
+
try {
|
|
2018
|
+
await server.start();
|
|
2019
|
+
console.log(`Claude Collab Hub Server running on ${host}:${port}`);
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
console.error("Failed to start hub server:", error);
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
program.command("client").description("Start MCP client (connects to Claude Code)").option("-t, --team <team>", "Team to auto-join (e.g., frontend, backend)").option("--auto-hub", "Auto-start hub if not running", false).option("-p, --port <port>", "Hub port to connect to", String(config.hub.port)).option("--host <host>", "Hub host to connect to", config.hub.host).action(
|
|
2026
|
+
async (options) => {
|
|
2027
|
+
const port = parseInt(options.port, 10);
|
|
2028
|
+
const host = options.host;
|
|
2029
|
+
if (options.autoHub) {
|
|
2030
|
+
const hubRunning = await ensureHubRunning({ host, port });
|
|
2031
|
+
if (!hubRunning) {
|
|
2032
|
+
console.error("Failed to start hub server. Exiting.");
|
|
2033
|
+
process.exit(1);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
const hubClient = new HubClient(
|
|
2037
|
+
{ host, port, reconnect: true },
|
|
2038
|
+
{
|
|
2039
|
+
onError: (error) => {
|
|
2040
|
+
console.error("Hub client error:", error.message);
|
|
2041
|
+
},
|
|
2042
|
+
onQuestion: (question) => {
|
|
2043
|
+
console.error(`[Question received from ${question.from.displayName}]`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
);
|
|
2047
|
+
try {
|
|
2048
|
+
await hubClient.connect();
|
|
2049
|
+
if (options.team) {
|
|
2050
|
+
await hubClient.join(options.team, `${options.team} Claude`);
|
|
2051
|
+
console.error(`Auto-joined team: ${options.team}`);
|
|
2052
|
+
}
|
|
2053
|
+
} catch (error) {
|
|
2054
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2055
|
+
console.error(`Failed to connect to hub: ${errorMessage}`);
|
|
2056
|
+
console.error("Make sure the hub server is running or use --auto-hub flag.");
|
|
2057
|
+
process.exit(1);
|
|
2058
|
+
}
|
|
2059
|
+
await startMcpServer({ hubClient });
|
|
2060
|
+
}
|
|
2061
|
+
);
|
|
2062
|
+
program.parse();
|
|
2063
|
+
//# sourceMappingURL=cli.js.map
|
|
2064
|
+
//# sourceMappingURL=cli.js.map
|