@dolusoft/claude-collab 0.1.3 → 1.3.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/dist/cli.js +776 -1885
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +797 -411
- package/dist/mcp-main.js.map +1 -1
- package/package.json +6 -4
- package/dist/hub-main.d.ts +0 -1
- package/dist/hub-main.js +0 -1586
- package/dist/hub-main.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,1822 +1,773 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { createServer } from 'net';
|
|
4
5
|
import { v4 } from 'uuid';
|
|
6
|
+
import multicastDns from 'multicast-dns';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
import { unlinkSync } from 'fs';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
import { join } from 'path';
|
|
5
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
-
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
14
|
import { z } from 'zod';
|
|
9
|
-
import { spawn } from 'child_process';
|
|
10
|
-
import { createConnection } from 'net';
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this._displayName = props.displayName;
|
|
24
|
-
this._connectedAt = props.connectedAt;
|
|
25
|
-
this._status = props.status;
|
|
26
|
-
this._lastActivityAt = props.connectedAt;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Creates a new Member instance
|
|
30
|
-
*/
|
|
31
|
-
static create(props) {
|
|
32
|
-
if (!props.displayName.trim()) {
|
|
33
|
-
throw new Error("Display name cannot be empty");
|
|
34
|
-
}
|
|
35
|
-
return new _Member(props);
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Reconstitutes a Member from persistence
|
|
39
|
-
*/
|
|
40
|
-
static reconstitute(props) {
|
|
41
|
-
const member = new _Member(props);
|
|
42
|
-
member._lastActivityAt = props.lastActivityAt;
|
|
43
|
-
return member;
|
|
44
|
-
}
|
|
45
|
-
// Getters
|
|
46
|
-
get id() {
|
|
47
|
-
return this._id;
|
|
48
|
-
}
|
|
49
|
-
get teamId() {
|
|
50
|
-
return this._teamId;
|
|
51
|
-
}
|
|
52
|
-
get displayName() {
|
|
53
|
-
return this._displayName;
|
|
54
|
-
}
|
|
55
|
-
get connectedAt() {
|
|
56
|
-
return this._connectedAt;
|
|
57
|
-
}
|
|
58
|
-
get status() {
|
|
59
|
-
return this._status;
|
|
60
|
-
}
|
|
61
|
-
get lastActivityAt() {
|
|
62
|
-
return this._lastActivityAt;
|
|
63
|
-
}
|
|
64
|
-
get isOnline() {
|
|
65
|
-
return this._status === "ONLINE" /* ONLINE */ || this._status === "IDLE" /* IDLE */;
|
|
66
|
-
}
|
|
67
|
-
// Behaviors
|
|
68
|
-
/**
|
|
69
|
-
* Marks the member as online
|
|
70
|
-
*/
|
|
71
|
-
goOnline() {
|
|
72
|
-
this._status = "ONLINE" /* ONLINE */;
|
|
73
|
-
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Marks the member as idle
|
|
77
|
-
*/
|
|
78
|
-
goIdle() {
|
|
79
|
-
this._status = "IDLE" /* IDLE */;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Marks the member as offline
|
|
83
|
-
*/
|
|
84
|
-
goOffline() {
|
|
85
|
-
this._status = "OFFLINE" /* OFFLINE */;
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Records activity from this member
|
|
89
|
-
*/
|
|
90
|
-
recordActivity() {
|
|
91
|
-
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
92
|
-
if (this._status === "IDLE" /* IDLE */) {
|
|
93
|
-
this._status = "ONLINE" /* ONLINE */;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Converts entity to plain object for serialization
|
|
98
|
-
*/
|
|
99
|
-
toJSON() {
|
|
100
|
-
return {
|
|
101
|
-
id: this._id,
|
|
102
|
-
teamId: this._teamId,
|
|
103
|
-
displayName: this._displayName,
|
|
104
|
-
connectedAt: this._connectedAt,
|
|
105
|
-
status: this._status,
|
|
106
|
-
lastActivityAt: this._lastActivityAt
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
var BaseDomainEvent = class {
|
|
111
|
-
eventId;
|
|
112
|
-
timestamp;
|
|
16
|
+
var SERVICE_TYPE = "_claude-collab._tcp.local";
|
|
17
|
+
var MdnsDiscovery = class {
|
|
18
|
+
mdns;
|
|
19
|
+
announced = false;
|
|
20
|
+
port = 0;
|
|
21
|
+
teamName = "";
|
|
22
|
+
memberId = "";
|
|
23
|
+
peersByTeam = /* @__PURE__ */ new Map();
|
|
24
|
+
peersByMemberId = /* @__PURE__ */ new Map();
|
|
25
|
+
onPeerFoundCb;
|
|
26
|
+
onPeerLostCb;
|
|
113
27
|
constructor() {
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Converts event to JSON
|
|
119
|
-
*/
|
|
120
|
-
toJSON() {
|
|
121
|
-
return {
|
|
122
|
-
eventId: this.eventId,
|
|
123
|
-
eventType: this.eventType,
|
|
124
|
-
timestamp: this.timestamp,
|
|
125
|
-
payload: this.payload
|
|
126
|
-
};
|
|
28
|
+
this.mdns = multicastDns();
|
|
29
|
+
this.setupHandlers();
|
|
127
30
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// src/domain/events/member-joined.event.ts
|
|
131
|
-
var MemberJoinedEvent = class _MemberJoinedEvent extends BaseDomainEvent {
|
|
132
|
-
constructor(memberId, teamId, displayName) {
|
|
133
|
-
super();
|
|
134
|
-
this.memberId = memberId;
|
|
135
|
-
this.teamId = teamId;
|
|
136
|
-
this.displayName = displayName;
|
|
137
|
-
}
|
|
138
|
-
static EVENT_TYPE = "MEMBER_JOINED";
|
|
139
|
-
get eventType() {
|
|
140
|
-
return _MemberJoinedEvent.EVENT_TYPE;
|
|
31
|
+
get serviceName() {
|
|
32
|
+
return `${this.memberId}.${SERVICE_TYPE}`;
|
|
141
33
|
}
|
|
142
|
-
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// src/shared/errors/domain-errors.ts
|
|
184
|
-
var DomainError = class extends Error {
|
|
185
|
-
code;
|
|
186
|
-
timestamp;
|
|
187
|
-
constructor(message, code) {
|
|
188
|
-
super(message);
|
|
189
|
-
this.name = this.constructor.name;
|
|
190
|
-
this.code = code;
|
|
191
|
-
this.timestamp = /* @__PURE__ */ new Date();
|
|
192
|
-
Error.captureStackTrace(this, this.constructor);
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
var TeamNotFoundError = class extends DomainError {
|
|
196
|
-
constructor(teamId) {
|
|
197
|
-
super(`Team '${teamId}' not found`, "TEAM_NOT_FOUND");
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
var MemberNotFoundError = class extends DomainError {
|
|
201
|
-
constructor(memberId) {
|
|
202
|
-
super(`Member '${memberId}' not found`, "MEMBER_NOT_FOUND");
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
var QuestionNotFoundError = class extends DomainError {
|
|
206
|
-
constructor(questionId) {
|
|
207
|
-
super(`Question '${questionId}' not found`, "QUESTION_NOT_FOUND");
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
var QuestionAlreadyAnsweredError = class extends DomainError {
|
|
211
|
-
constructor(questionId) {
|
|
212
|
-
super(`Question '${questionId}' has already been answered`, "QUESTION_ALREADY_ANSWERED");
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
var ValidationError = class extends DomainError {
|
|
216
|
-
field;
|
|
217
|
-
constructor(field, message) {
|
|
218
|
-
super(message, "VALIDATION_ERROR");
|
|
219
|
-
this.field = field;
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// src/application/use-cases/join-team.use-case.ts
|
|
224
|
-
var JoinTeamUseCase = class {
|
|
225
|
-
constructor(deps) {
|
|
226
|
-
this.deps = deps;
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Executes the use case
|
|
230
|
-
*/
|
|
231
|
-
async execute(input) {
|
|
232
|
-
if (!input.teamName.trim()) {
|
|
233
|
-
throw new ValidationError("teamName", "Team name cannot be empty");
|
|
234
|
-
}
|
|
235
|
-
if (!input.displayName.trim()) {
|
|
236
|
-
throw new ValidationError("displayName", "Display name cannot be empty");
|
|
237
|
-
}
|
|
238
|
-
const team = await this.deps.teamRepository.getOrCreate(input.teamName);
|
|
239
|
-
const memberId = generateMemberId();
|
|
240
|
-
const member = Member.create({
|
|
241
|
-
id: memberId,
|
|
242
|
-
teamId: team.id,
|
|
243
|
-
displayName: input.displayName.trim(),
|
|
244
|
-
connectedAt: /* @__PURE__ */ new Date(),
|
|
245
|
-
status: "ONLINE" /* ONLINE */
|
|
34
|
+
buildAnswers() {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
name: SERVICE_TYPE,
|
|
38
|
+
type: "PTR",
|
|
39
|
+
ttl: 300,
|
|
40
|
+
data: this.serviceName
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: this.serviceName,
|
|
44
|
+
type: "SRV",
|
|
45
|
+
ttl: 300,
|
|
46
|
+
data: {
|
|
47
|
+
priority: 0,
|
|
48
|
+
weight: 0,
|
|
49
|
+
port: this.port,
|
|
50
|
+
target: "localhost"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: this.serviceName,
|
|
55
|
+
type: "TXT",
|
|
56
|
+
ttl: 300,
|
|
57
|
+
data: [
|
|
58
|
+
Buffer.from(`team=${this.teamName}`),
|
|
59
|
+
Buffer.from(`memberId=${this.memberId}`),
|
|
60
|
+
Buffer.from("ver=1")
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
setupHandlers() {
|
|
66
|
+
this.mdns.on("query", (query) => {
|
|
67
|
+
if (!this.announced) return;
|
|
68
|
+
const questions = query.questions ?? [];
|
|
69
|
+
const ptrQuery = questions.find(
|
|
70
|
+
(q) => q.type === "PTR" && q.name === SERVICE_TYPE
|
|
71
|
+
);
|
|
72
|
+
if (!ptrQuery) return;
|
|
73
|
+
this.mdns.respond({ answers: this.buildAnswers() });
|
|
246
74
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await this.deps.teamRepository.save(team);
|
|
250
|
-
if (this.deps.onMemberJoined) {
|
|
251
|
-
const event = new MemberJoinedEvent(memberId, team.id, member.displayName);
|
|
252
|
-
await this.deps.onMemberJoined(event);
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
memberId,
|
|
256
|
-
teamId: team.id,
|
|
257
|
-
teamName: team.name,
|
|
258
|
-
displayName: member.displayName,
|
|
259
|
-
status: member.status,
|
|
260
|
-
memberCount: team.memberCount
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
// src/domain/entities/question.entity.ts
|
|
266
|
-
var Question = class _Question {
|
|
267
|
-
_id;
|
|
268
|
-
_fromMemberId;
|
|
269
|
-
_toTeamId;
|
|
270
|
-
_content;
|
|
271
|
-
_createdAt;
|
|
272
|
-
_status;
|
|
273
|
-
_answeredAt;
|
|
274
|
-
_answeredByMemberId;
|
|
275
|
-
constructor(props) {
|
|
276
|
-
this._id = props.id;
|
|
277
|
-
this._fromMemberId = props.fromMemberId;
|
|
278
|
-
this._toTeamId = props.toTeamId;
|
|
279
|
-
this._content = props.content;
|
|
280
|
-
this._createdAt = props.createdAt;
|
|
281
|
-
this._status = props.status;
|
|
282
|
-
this._answeredAt = props.answeredAt;
|
|
283
|
-
this._answeredByMemberId = props.answeredByMemberId;
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Creates a new Question instance
|
|
287
|
-
*/
|
|
288
|
-
static create(props) {
|
|
289
|
-
return new _Question({
|
|
290
|
-
...props,
|
|
291
|
-
status: "PENDING" /* PENDING */
|
|
75
|
+
this.mdns.on("response", (response) => {
|
|
76
|
+
this.parseResponse(response);
|
|
292
77
|
});
|
|
293
78
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
get toTeamId() {
|
|
308
|
-
return this._toTeamId;
|
|
309
|
-
}
|
|
310
|
-
get content() {
|
|
311
|
-
return this._content;
|
|
312
|
-
}
|
|
313
|
-
get createdAt() {
|
|
314
|
-
return this._createdAt;
|
|
315
|
-
}
|
|
316
|
-
get status() {
|
|
317
|
-
return this._status;
|
|
318
|
-
}
|
|
319
|
-
get answeredAt() {
|
|
320
|
-
return this._answeredAt;
|
|
321
|
-
}
|
|
322
|
-
get answeredByMemberId() {
|
|
323
|
-
return this._answeredByMemberId;
|
|
324
|
-
}
|
|
325
|
-
get isPending() {
|
|
326
|
-
return this._status === "PENDING" /* PENDING */;
|
|
327
|
-
}
|
|
328
|
-
get isAnswered() {
|
|
329
|
-
return this._status === "ANSWERED" /* ANSWERED */;
|
|
330
|
-
}
|
|
331
|
-
get isTimedOut() {
|
|
332
|
-
return this._status === "TIMEOUT" /* TIMEOUT */;
|
|
333
|
-
}
|
|
334
|
-
get isCancelled() {
|
|
335
|
-
return this._status === "CANCELLED" /* CANCELLED */;
|
|
336
|
-
}
|
|
337
|
-
get canBeAnswered() {
|
|
338
|
-
return this._status === "PENDING" /* PENDING */;
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Calculates the age of the question in milliseconds
|
|
342
|
-
*/
|
|
343
|
-
get ageMs() {
|
|
344
|
-
return Date.now() - this._createdAt.getTime();
|
|
345
|
-
}
|
|
346
|
-
// Behaviors
|
|
347
|
-
/**
|
|
348
|
-
* Marks the question as answered
|
|
349
|
-
* @throws QuestionAlreadyAnsweredError if already answered
|
|
350
|
-
*/
|
|
351
|
-
markAsAnswered(answeredByMemberId) {
|
|
352
|
-
if (!this.canBeAnswered) {
|
|
353
|
-
throw new QuestionAlreadyAnsweredError(this._id);
|
|
354
|
-
}
|
|
355
|
-
this._status = "ANSWERED" /* ANSWERED */;
|
|
356
|
-
this._answeredAt = /* @__PURE__ */ new Date();
|
|
357
|
-
this._answeredByMemberId = answeredByMemberId;
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Marks the question as timed out
|
|
361
|
-
*/
|
|
362
|
-
markAsTimedOut() {
|
|
363
|
-
if (this._status === "PENDING" /* PENDING */) {
|
|
364
|
-
this._status = "TIMEOUT" /* TIMEOUT */;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Marks the question as cancelled
|
|
369
|
-
*/
|
|
370
|
-
markAsCancelled() {
|
|
371
|
-
if (this._status === "PENDING" /* PENDING */) {
|
|
372
|
-
this._status = "CANCELLED" /* CANCELLED */;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Converts entity to plain object for serialization
|
|
377
|
-
*/
|
|
378
|
-
toJSON() {
|
|
379
|
-
return {
|
|
380
|
-
id: this._id,
|
|
381
|
-
fromMemberId: this._fromMemberId,
|
|
382
|
-
toTeamId: this._toTeamId,
|
|
383
|
-
content: this._content,
|
|
384
|
-
createdAt: this._createdAt,
|
|
385
|
-
status: this._status,
|
|
386
|
-
answeredAt: this._answeredAt,
|
|
387
|
-
answeredByMemberId: this._answeredByMemberId
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
// src/config/index.ts
|
|
393
|
-
var config = {
|
|
394
|
-
/**
|
|
395
|
-
* WebSocket Hub configuration
|
|
396
|
-
*/
|
|
397
|
-
hub: {
|
|
398
|
-
/**
|
|
399
|
-
* Default port for the Hub server
|
|
400
|
-
*/
|
|
401
|
-
port: parseInt(process.env["CLAUDE_COLLAB_PORT"] ?? "9999", 10),
|
|
402
|
-
/**
|
|
403
|
-
* Host to bind the Hub server to
|
|
404
|
-
*/
|
|
405
|
-
host: process.env["CLAUDE_COLLAB_HOST"] ?? "localhost",
|
|
406
|
-
/**
|
|
407
|
-
* Heartbeat interval in milliseconds
|
|
408
|
-
*/
|
|
409
|
-
heartbeatInterval: 3e4,
|
|
410
|
-
/**
|
|
411
|
-
* Client timeout in milliseconds (no heartbeat received)
|
|
412
|
-
*/
|
|
413
|
-
clientTimeout: 6e4
|
|
414
|
-
},
|
|
415
|
-
/**
|
|
416
|
-
* Communication configuration
|
|
417
|
-
*/
|
|
418
|
-
communication: {
|
|
419
|
-
/**
|
|
420
|
-
* Default timeout for waiting for an answer (in milliseconds)
|
|
421
|
-
*/
|
|
422
|
-
defaultTimeout: 3e4,
|
|
423
|
-
/**
|
|
424
|
-
* Maximum message content length
|
|
425
|
-
*/
|
|
426
|
-
maxMessageLength: 5e4
|
|
427
|
-
},
|
|
428
|
-
/**
|
|
429
|
-
* Auto-start configuration
|
|
430
|
-
*/
|
|
431
|
-
autoStart: {
|
|
432
|
-
/**
|
|
433
|
-
* Maximum retries when connecting to hub
|
|
434
|
-
*/
|
|
435
|
-
maxRetries: 3,
|
|
436
|
-
/**
|
|
437
|
-
* Delay between retries in milliseconds
|
|
438
|
-
*/
|
|
439
|
-
retryDelay: 1e3
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
// src/domain/value-objects/message-content.vo.ts
|
|
444
|
-
var MessageContent = class _MessageContent {
|
|
445
|
-
_text;
|
|
446
|
-
_format;
|
|
447
|
-
constructor(text, format) {
|
|
448
|
-
this._text = text;
|
|
449
|
-
this._format = format;
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Creates a new MessageContent
|
|
453
|
-
* @throws ValidationError if content is invalid
|
|
454
|
-
*/
|
|
455
|
-
static create(text, format = "markdown") {
|
|
456
|
-
const trimmedText = text.trim();
|
|
457
|
-
if (!trimmedText) {
|
|
458
|
-
throw new ValidationError("text", "Message content cannot be empty");
|
|
459
|
-
}
|
|
460
|
-
if (trimmedText.length > config.communication.maxMessageLength) {
|
|
461
|
-
throw new ValidationError(
|
|
462
|
-
"text",
|
|
463
|
-
`Message content exceeds maximum length of ${config.communication.maxMessageLength} characters`
|
|
79
|
+
parseResponse(response) {
|
|
80
|
+
const allRecords = [
|
|
81
|
+
...response.answers ?? [],
|
|
82
|
+
...response.additionals ?? []
|
|
83
|
+
];
|
|
84
|
+
const ptrRecords = allRecords.filter(
|
|
85
|
+
(r) => r.type === "PTR" && r.name === SERVICE_TYPE
|
|
86
|
+
);
|
|
87
|
+
for (const ptr of ptrRecords) {
|
|
88
|
+
const instanceName = ptr.data;
|
|
89
|
+
const srv = allRecords.find(
|
|
90
|
+
(r) => r.type === "SRV" && r.name === instanceName
|
|
464
91
|
);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Creates a plain text message
|
|
470
|
-
*/
|
|
471
|
-
static plain(text) {
|
|
472
|
-
return _MessageContent.create(text, "plain");
|
|
473
|
-
}
|
|
474
|
-
/**
|
|
475
|
-
* Creates a markdown message
|
|
476
|
-
*/
|
|
477
|
-
static markdown(text) {
|
|
478
|
-
return _MessageContent.create(text, "markdown");
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Reconstitutes from persistence
|
|
482
|
-
*/
|
|
483
|
-
static reconstitute(props) {
|
|
484
|
-
return new _MessageContent(props.text, props.format);
|
|
485
|
-
}
|
|
486
|
-
// Getters
|
|
487
|
-
get text() {
|
|
488
|
-
return this._text;
|
|
489
|
-
}
|
|
490
|
-
get format() {
|
|
491
|
-
return this._format;
|
|
492
|
-
}
|
|
493
|
-
get length() {
|
|
494
|
-
return this._text.length;
|
|
495
|
-
}
|
|
496
|
-
get isMarkdown() {
|
|
497
|
-
return this._format === "markdown";
|
|
498
|
-
}
|
|
499
|
-
get isPlain() {
|
|
500
|
-
return this._format === "plain";
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* Returns a preview of the content (first 100 chars)
|
|
504
|
-
*/
|
|
505
|
-
get preview() {
|
|
506
|
-
if (this._text.length <= 100) {
|
|
507
|
-
return this._text;
|
|
508
|
-
}
|
|
509
|
-
return `${this._text.substring(0, 97)}...`;
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Checks equality with another MessageContent
|
|
513
|
-
*/
|
|
514
|
-
equals(other) {
|
|
515
|
-
return this._text === other._text && this._format === other._format;
|
|
516
|
-
}
|
|
517
|
-
/**
|
|
518
|
-
* Converts to plain object for serialization
|
|
519
|
-
*/
|
|
520
|
-
toJSON() {
|
|
521
|
-
return {
|
|
522
|
-
text: this._text,
|
|
523
|
-
format: this._format
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* String representation
|
|
528
|
-
*/
|
|
529
|
-
toString() {
|
|
530
|
-
return this._text;
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
// src/domain/events/question-asked.event.ts
|
|
535
|
-
var QuestionAskedEvent = class _QuestionAskedEvent extends BaseDomainEvent {
|
|
536
|
-
constructor(questionId, fromMemberId, toTeamId, contentPreview) {
|
|
537
|
-
super();
|
|
538
|
-
this.questionId = questionId;
|
|
539
|
-
this.fromMemberId = fromMemberId;
|
|
540
|
-
this.toTeamId = toTeamId;
|
|
541
|
-
this.contentPreview = contentPreview;
|
|
542
|
-
}
|
|
543
|
-
static EVENT_TYPE = "QUESTION_ASKED";
|
|
544
|
-
get eventType() {
|
|
545
|
-
return _QuestionAskedEvent.EVENT_TYPE;
|
|
546
|
-
}
|
|
547
|
-
get payload() {
|
|
548
|
-
return {
|
|
549
|
-
questionId: this.questionId,
|
|
550
|
-
fromMemberId: this.fromMemberId,
|
|
551
|
-
toTeamId: this.toTeamId,
|
|
552
|
-
contentPreview: this.contentPreview
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
// src/application/use-cases/ask-question.use-case.ts
|
|
558
|
-
var AskQuestionUseCase = class {
|
|
559
|
-
constructor(deps) {
|
|
560
|
-
this.deps = deps;
|
|
561
|
-
}
|
|
562
|
-
/**
|
|
563
|
-
* Executes the use case
|
|
564
|
-
*/
|
|
565
|
-
async execute(input) {
|
|
566
|
-
const member = await this.deps.memberRepository.findById(input.fromMemberId);
|
|
567
|
-
if (!member) {
|
|
568
|
-
throw new MemberNotFoundError(input.fromMemberId);
|
|
569
|
-
}
|
|
570
|
-
const targetTeamId = createTeamId(input.toTeamName);
|
|
571
|
-
const targetTeam = await this.deps.teamRepository.findById(targetTeamId);
|
|
572
|
-
if (!targetTeam) {
|
|
573
|
-
throw new TeamNotFoundError(input.toTeamName);
|
|
574
|
-
}
|
|
575
|
-
if (member.teamId === targetTeamId) {
|
|
576
|
-
throw new ValidationError("toTeamName", "Cannot ask question to your own team");
|
|
577
|
-
}
|
|
578
|
-
const content = MessageContent.create(input.content, input.format ?? "markdown");
|
|
579
|
-
const questionId = generateQuestionId();
|
|
580
|
-
const question = Question.create({
|
|
581
|
-
id: questionId,
|
|
582
|
-
fromMemberId: input.fromMemberId,
|
|
583
|
-
toTeamId: targetTeamId,
|
|
584
|
-
content,
|
|
585
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
586
|
-
});
|
|
587
|
-
await this.deps.questionRepository.save(question);
|
|
588
|
-
member.recordActivity();
|
|
589
|
-
await this.deps.memberRepository.save(member);
|
|
590
|
-
if (this.deps.onQuestionAsked) {
|
|
591
|
-
const event = new QuestionAskedEvent(
|
|
592
|
-
questionId,
|
|
593
|
-
input.fromMemberId,
|
|
594
|
-
targetTeamId,
|
|
595
|
-
content.preview
|
|
92
|
+
const txt = allRecords.find(
|
|
93
|
+
(r) => r.type === "TXT" && r.name === instanceName
|
|
596
94
|
);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
const allQuestions = await this.deps.questionRepository.findPendingByTeamId(input.teamId);
|
|
626
|
-
const questions = input.includeAnswered ? allQuestions : allQuestions.filter((q) => q.isPending);
|
|
627
|
-
const questionItems = [];
|
|
628
|
-
for (const question of questions) {
|
|
629
|
-
const fromMember = await this.deps.memberRepository.findById(question.fromMemberId);
|
|
630
|
-
const fromTeam = fromMember ? await this.deps.teamRepository.findById(fromMember.teamId) : null;
|
|
631
|
-
questionItems.push({
|
|
632
|
-
questionId: question.id,
|
|
633
|
-
fromMemberId: question.fromMemberId,
|
|
634
|
-
fromDisplayName: fromMember?.displayName ?? "Unknown",
|
|
635
|
-
fromTeamName: fromTeam?.name ?? "Unknown",
|
|
636
|
-
content: question.content.text,
|
|
637
|
-
format: question.content.format,
|
|
638
|
-
status: question.status,
|
|
639
|
-
createdAt: question.createdAt,
|
|
640
|
-
ageMs: question.ageMs
|
|
641
|
-
});
|
|
95
|
+
if (!srv) continue;
|
|
96
|
+
const port = srv.data.port;
|
|
97
|
+
const host = "localhost";
|
|
98
|
+
let teamName = "";
|
|
99
|
+
let memberId = "";
|
|
100
|
+
if (txt) {
|
|
101
|
+
const txtData = txt.data ?? [];
|
|
102
|
+
for (const entry of txtData) {
|
|
103
|
+
const str = Buffer.isBuffer(entry) ? entry.toString() : String(entry);
|
|
104
|
+
if (str.startsWith("team=")) teamName = str.slice(5);
|
|
105
|
+
if (str.startsWith("memberId=")) memberId = str.slice(9);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!teamName || !memberId) continue;
|
|
109
|
+
if (memberId === this.memberId) continue;
|
|
110
|
+
const ptrTtl = ptr.ttl ?? 300;
|
|
111
|
+
const srvTtl = srv.ttl ?? 300;
|
|
112
|
+
if (ptrTtl === 0 || srvTtl === 0) {
|
|
113
|
+
this.peersByTeam.delete(teamName);
|
|
114
|
+
this.peersByMemberId.delete(memberId);
|
|
115
|
+
this.onPeerLostCb?.(memberId);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const peer = { host, port, teamName, memberId };
|
|
119
|
+
this.peersByTeam.set(teamName, peer);
|
|
120
|
+
this.peersByMemberId.set(memberId, peer);
|
|
121
|
+
this.onPeerFoundCb?.(peer);
|
|
642
122
|
}
|
|
643
|
-
questionItems.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
644
|
-
const pendingCount = questionItems.filter((q) => q.status === "PENDING" /* PENDING */).length;
|
|
645
|
-
return {
|
|
646
|
-
teamId: team.id,
|
|
647
|
-
teamName: team.name,
|
|
648
|
-
questions: questionItems,
|
|
649
|
-
totalCount: questionItems.length,
|
|
650
|
-
pendingCount
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
// src/domain/entities/answer.entity.ts
|
|
656
|
-
var Answer = class _Answer {
|
|
657
|
-
_id;
|
|
658
|
-
_questionId;
|
|
659
|
-
_fromMemberId;
|
|
660
|
-
_content;
|
|
661
|
-
_createdAt;
|
|
662
|
-
constructor(props) {
|
|
663
|
-
this._id = props.id;
|
|
664
|
-
this._questionId = props.questionId;
|
|
665
|
-
this._fromMemberId = props.fromMemberId;
|
|
666
|
-
this._content = props.content;
|
|
667
|
-
this._createdAt = props.createdAt;
|
|
668
|
-
}
|
|
669
|
-
/**
|
|
670
|
-
* Creates a new Answer instance
|
|
671
|
-
*/
|
|
672
|
-
static create(props) {
|
|
673
|
-
return new _Answer(props);
|
|
674
123
|
}
|
|
675
124
|
/**
|
|
676
|
-
*
|
|
125
|
+
* Announce this node's service via mDNS.
|
|
126
|
+
* Sends an unsolicited response so existing peers notice immediately.
|
|
677
127
|
*/
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
get questionId() {
|
|
686
|
-
return this._questionId;
|
|
687
|
-
}
|
|
688
|
-
get fromMemberId() {
|
|
689
|
-
return this._fromMemberId;
|
|
690
|
-
}
|
|
691
|
-
get content() {
|
|
692
|
-
return this._content;
|
|
693
|
-
}
|
|
694
|
-
get createdAt() {
|
|
695
|
-
return this._createdAt;
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Converts entity to plain object for serialization
|
|
699
|
-
*/
|
|
700
|
-
toJSON() {
|
|
701
|
-
return {
|
|
702
|
-
id: this._id,
|
|
703
|
-
questionId: this._questionId,
|
|
704
|
-
fromMemberId: this._fromMemberId,
|
|
705
|
-
content: this._content,
|
|
706
|
-
createdAt: this._createdAt
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
};
|
|
710
|
-
|
|
711
|
-
// src/domain/events/question-answered.event.ts
|
|
712
|
-
var QuestionAnsweredEvent = class _QuestionAnsweredEvent extends BaseDomainEvent {
|
|
713
|
-
constructor(questionId, answerId, answeredByMemberId, contentPreview) {
|
|
714
|
-
super();
|
|
715
|
-
this.questionId = questionId;
|
|
716
|
-
this.answerId = answerId;
|
|
717
|
-
this.answeredByMemberId = answeredByMemberId;
|
|
718
|
-
this.contentPreview = contentPreview;
|
|
719
|
-
}
|
|
720
|
-
static EVENT_TYPE = "QUESTION_ANSWERED";
|
|
721
|
-
get eventType() {
|
|
722
|
-
return _QuestionAnsweredEvent.EVENT_TYPE;
|
|
723
|
-
}
|
|
724
|
-
get payload() {
|
|
725
|
-
return {
|
|
726
|
-
questionId: this.questionId,
|
|
727
|
-
answerId: this.answerId,
|
|
728
|
-
answeredByMemberId: this.answeredByMemberId,
|
|
729
|
-
contentPreview: this.contentPreview
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
};
|
|
733
|
-
|
|
734
|
-
// src/application/use-cases/reply-question.use-case.ts
|
|
735
|
-
var ReplyQuestionUseCase = class {
|
|
736
|
-
constructor(deps) {
|
|
737
|
-
this.deps = deps;
|
|
128
|
+
announce(port, teamName, memberId) {
|
|
129
|
+
this.port = port;
|
|
130
|
+
this.teamName = teamName;
|
|
131
|
+
this.memberId = memberId;
|
|
132
|
+
this.announced = true;
|
|
133
|
+
this.mdns.respond({ answers: this.buildAnswers() });
|
|
738
134
|
}
|
|
739
135
|
/**
|
|
740
|
-
*
|
|
136
|
+
* Send a PTR query to discover existing peers.
|
|
741
137
|
*/
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
throw new MemberNotFoundError(input.fromMemberId);
|
|
746
|
-
}
|
|
747
|
-
const question = await this.deps.questionRepository.findById(input.questionId);
|
|
748
|
-
if (!question) {
|
|
749
|
-
throw new QuestionNotFoundError(input.questionId);
|
|
750
|
-
}
|
|
751
|
-
if (!question.canBeAnswered) {
|
|
752
|
-
throw new QuestionAlreadyAnsweredError(input.questionId);
|
|
753
|
-
}
|
|
754
|
-
const content = MessageContent.create(input.content, input.format ?? "markdown");
|
|
755
|
-
const answerId = generateAnswerId();
|
|
756
|
-
const answer = Answer.create({
|
|
757
|
-
id: answerId,
|
|
758
|
-
questionId: input.questionId,
|
|
759
|
-
fromMemberId: input.fromMemberId,
|
|
760
|
-
content,
|
|
761
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
138
|
+
discover() {
|
|
139
|
+
this.mdns.query({
|
|
140
|
+
questions: [{ name: SERVICE_TYPE, type: "PTR" }]
|
|
762
141
|
});
|
|
763
|
-
question.markAsAnswered(input.fromMemberId);
|
|
764
|
-
await this.deps.answerRepository.save(answer);
|
|
765
|
-
await this.deps.questionRepository.save(question);
|
|
766
|
-
member.recordActivity();
|
|
767
|
-
await this.deps.memberRepository.save(member);
|
|
768
|
-
if (this.deps.onQuestionAnswered) {
|
|
769
|
-
const event = new QuestionAnsweredEvent(
|
|
770
|
-
input.questionId,
|
|
771
|
-
answerId,
|
|
772
|
-
input.fromMemberId,
|
|
773
|
-
content.preview
|
|
774
|
-
);
|
|
775
|
-
await this.deps.onQuestionAnswered(event);
|
|
776
|
-
}
|
|
777
|
-
return {
|
|
778
|
-
answerId,
|
|
779
|
-
questionId: input.questionId,
|
|
780
|
-
deliveredToMemberId: question.fromMemberId,
|
|
781
|
-
createdAt: answer.createdAt
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
// src/infrastructure/repositories/in-memory-member.repository.ts
|
|
787
|
-
var InMemoryMemberRepository = class {
|
|
788
|
-
members = /* @__PURE__ */ new Map();
|
|
789
|
-
async save(member) {
|
|
790
|
-
this.members.set(member.id, member);
|
|
791
|
-
}
|
|
792
|
-
async findById(id) {
|
|
793
|
-
return this.members.get(id) ?? null;
|
|
794
|
-
}
|
|
795
|
-
async findByTeamId(teamId) {
|
|
796
|
-
return [...this.members.values()].filter((m) => m.teamId === teamId);
|
|
797
142
|
}
|
|
798
|
-
|
|
799
|
-
return
|
|
143
|
+
getPeerByTeam(teamName) {
|
|
144
|
+
return this.peersByTeam.get(teamName);
|
|
800
145
|
}
|
|
801
|
-
|
|
802
|
-
|
|
146
|
+
onPeerFound(cb) {
|
|
147
|
+
this.onPeerFoundCb = cb;
|
|
803
148
|
}
|
|
804
|
-
|
|
805
|
-
|
|
149
|
+
onPeerLost(cb) {
|
|
150
|
+
this.onPeerLostCb = cb;
|
|
806
151
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
/**
|
|
811
|
-
* Clears all data (useful for testing)
|
|
812
|
-
*/
|
|
813
|
-
clear() {
|
|
814
|
-
this.members.clear();
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* Gets the count of members
|
|
818
|
-
*/
|
|
819
|
-
get count() {
|
|
820
|
-
return this.members.size;
|
|
152
|
+
destroy() {
|
|
153
|
+
this.mdns.destroy();
|
|
821
154
|
}
|
|
822
155
|
};
|
|
823
156
|
|
|
824
|
-
// src/
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Creates a new Team instance
|
|
838
|
-
*/
|
|
839
|
-
static create(props) {
|
|
840
|
-
if (!props.name.trim()) {
|
|
841
|
-
throw new Error("Team name cannot be empty");
|
|
842
|
-
}
|
|
843
|
-
return new _Team(props);
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* Reconstitutes a Team from persistence
|
|
847
|
-
*/
|
|
848
|
-
static reconstitute(props) {
|
|
849
|
-
const team = new _Team(props);
|
|
850
|
-
for (const memberId of props.memberIds) {
|
|
851
|
-
team._memberIds.add(memberId);
|
|
852
|
-
}
|
|
853
|
-
return team;
|
|
854
|
-
}
|
|
855
|
-
// Getters
|
|
856
|
-
get id() {
|
|
857
|
-
return this._id;
|
|
858
|
-
}
|
|
859
|
-
get name() {
|
|
860
|
-
return this._name;
|
|
861
|
-
}
|
|
862
|
-
get createdAt() {
|
|
863
|
-
return this._createdAt;
|
|
864
|
-
}
|
|
865
|
-
get memberIds() {
|
|
866
|
-
return this._memberIds;
|
|
867
|
-
}
|
|
868
|
-
get memberCount() {
|
|
869
|
-
return this._memberIds.size;
|
|
870
|
-
}
|
|
871
|
-
get isEmpty() {
|
|
872
|
-
return this._memberIds.size === 0;
|
|
873
|
-
}
|
|
874
|
-
// Behaviors
|
|
875
|
-
/**
|
|
876
|
-
* Adds a member to the team
|
|
877
|
-
* @returns true if the member was added, false if already present
|
|
878
|
-
*/
|
|
879
|
-
addMember(memberId) {
|
|
880
|
-
if (this._memberIds.has(memberId)) {
|
|
881
|
-
return false;
|
|
882
|
-
}
|
|
883
|
-
this._memberIds.add(memberId);
|
|
884
|
-
return true;
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Removes a member from the team
|
|
888
|
-
* @returns true if the member was removed, false if not present
|
|
889
|
-
*/
|
|
890
|
-
removeMember(memberId) {
|
|
891
|
-
return this._memberIds.delete(memberId);
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* Checks if a member is in the team
|
|
895
|
-
*/
|
|
896
|
-
hasMember(memberId) {
|
|
897
|
-
return this._memberIds.has(memberId);
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* Gets all member IDs except the specified one
|
|
901
|
-
* Useful for broadcasting to other team members
|
|
902
|
-
*/
|
|
903
|
-
getOtherMemberIds(excludeMemberId) {
|
|
904
|
-
return [...this._memberIds].filter((id) => id !== excludeMemberId);
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* Converts entity to plain object for serialization
|
|
908
|
-
*/
|
|
909
|
-
toJSON() {
|
|
910
|
-
return {
|
|
911
|
-
id: this._id,
|
|
912
|
-
name: this._name,
|
|
913
|
-
createdAt: this._createdAt,
|
|
914
|
-
memberIds: [...this._memberIds]
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
};
|
|
157
|
+
// src/infrastructure/p2p/p2p-message-protocol.ts
|
|
158
|
+
function serializeP2PMsg(msg) {
|
|
159
|
+
return JSON.stringify(msg);
|
|
160
|
+
}
|
|
161
|
+
function parseP2PMsg(data) {
|
|
162
|
+
return JSON.parse(data);
|
|
163
|
+
}
|
|
164
|
+
var CS_CONINJECT = `
|
|
165
|
+
using System;
|
|
166
|
+
using System.Collections.Generic;
|
|
167
|
+
using System.Runtime.InteropServices;
|
|
918
168
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
async getOrCreate(name) {
|
|
933
|
-
const existing = await this.findByName(name);
|
|
934
|
-
if (existing) {
|
|
935
|
-
return existing;
|
|
936
|
-
}
|
|
937
|
-
const teamId = createTeamId(name);
|
|
938
|
-
const team = Team.create({
|
|
939
|
-
id: teamId,
|
|
940
|
-
name: name.trim(),
|
|
941
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
942
|
-
});
|
|
943
|
-
await this.save(team);
|
|
944
|
-
return team;
|
|
945
|
-
}
|
|
946
|
-
async delete(id) {
|
|
947
|
-
return this.teams.delete(id);
|
|
948
|
-
}
|
|
949
|
-
async exists(id) {
|
|
950
|
-
return this.teams.has(id);
|
|
951
|
-
}
|
|
952
|
-
async findAll() {
|
|
953
|
-
return [...this.teams.values()];
|
|
954
|
-
}
|
|
955
|
-
async findNonEmpty() {
|
|
956
|
-
return [...this.teams.values()].filter((t) => !t.isEmpty);
|
|
957
|
-
}
|
|
958
|
-
/**
|
|
959
|
-
* Clears all data (useful for testing)
|
|
960
|
-
*/
|
|
961
|
-
clear() {
|
|
962
|
-
this.teams.clear();
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* Gets the count of teams
|
|
966
|
-
*/
|
|
967
|
-
get count() {
|
|
968
|
-
return this.teams.size;
|
|
969
|
-
}
|
|
970
|
-
};
|
|
169
|
+
public class ConInject {
|
|
170
|
+
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
|
|
171
|
+
[DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
|
|
172
|
+
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
|
|
173
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
|
|
174
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
175
|
+
public static extern IntPtr CreateFile(
|
|
176
|
+
string lpFileName, uint dwDesiredAccess, uint dwShareMode,
|
|
177
|
+
IntPtr lpSecurityAttributes, uint dwCreationDisposition,
|
|
178
|
+
uint dwFlagsAndAttributes, IntPtr hTemplateFile);
|
|
179
|
+
[DllImport("kernel32.dll")] public static extern bool WriteConsoleInput(
|
|
180
|
+
IntPtr hIn, INPUT_RECORD[] buf, uint len, out uint written);
|
|
181
|
+
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
|
971
182
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
async findPendingByTeamId(teamId) {
|
|
982
|
-
return [...this.questions.values()].filter((q) => q.toTeamId === teamId && q.isPending);
|
|
983
|
-
}
|
|
984
|
-
async findByFromMemberId(memberId) {
|
|
985
|
-
return [...this.questions.values()].filter((q) => q.fromMemberId === memberId);
|
|
986
|
-
}
|
|
987
|
-
async findPendingByFromMemberId(memberId) {
|
|
988
|
-
return [...this.questions.values()].filter(
|
|
989
|
-
(q) => q.fromMemberId === memberId && q.isPending
|
|
990
|
-
);
|
|
991
|
-
}
|
|
992
|
-
async delete(id) {
|
|
993
|
-
return this.questions.delete(id);
|
|
994
|
-
}
|
|
995
|
-
async exists(id) {
|
|
996
|
-
return this.questions.has(id);
|
|
997
|
-
}
|
|
998
|
-
async findAll() {
|
|
999
|
-
return [...this.questions.values()];
|
|
1000
|
-
}
|
|
1001
|
-
async markTimedOut(olderThanMs) {
|
|
1002
|
-
let count = 0;
|
|
1003
|
-
const now = Date.now();
|
|
1004
|
-
for (const question of this.questions.values()) {
|
|
1005
|
-
if (question.isPending && now - question.createdAt.getTime() > olderThanMs) {
|
|
1006
|
-
question.markAsTimedOut();
|
|
1007
|
-
count++;
|
|
1008
|
-
}
|
|
183
|
+
[StructLayout(LayoutKind.Explicit, Size=20)]
|
|
184
|
+
public struct INPUT_RECORD {
|
|
185
|
+
[FieldOffset(0)] public ushort EventType;
|
|
186
|
+
[FieldOffset(4)] public int bKeyDown;
|
|
187
|
+
[FieldOffset(8)] public ushort wRepeatCount;
|
|
188
|
+
[FieldOffset(10)] public ushort wVirtualKeyCode;
|
|
189
|
+
[FieldOffset(12)] public ushort wVirtualScanCode;
|
|
190
|
+
[FieldOffset(14)] public ushort UnicodeChar;
|
|
191
|
+
[FieldOffset(16)] public uint dwControlKeyState;
|
|
1009
192
|
}
|
|
1010
|
-
return count;
|
|
1011
|
-
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Clears all data (useful for testing)
|
|
1014
|
-
*/
|
|
1015
|
-
clear() {
|
|
1016
|
-
this.questions.clear();
|
|
1017
|
-
}
|
|
1018
|
-
/**
|
|
1019
|
-
* Gets the count of questions
|
|
1020
|
-
*/
|
|
1021
|
-
get count() {
|
|
1022
|
-
return this.questions.size;
|
|
1023
|
-
}
|
|
1024
|
-
};
|
|
1025
193
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
async findById(id) {
|
|
1033
|
-
return this.answers.get(id) ?? null;
|
|
1034
|
-
}
|
|
1035
|
-
async findByQuestionId(questionId) {
|
|
1036
|
-
for (const answer of this.answers.values()) {
|
|
1037
|
-
if (answer.questionId === questionId) {
|
|
1038
|
-
return answer;
|
|
1039
|
-
}
|
|
194
|
+
const uint LEFT_CTRL = 0x0008;
|
|
195
|
+
|
|
196
|
+
static IntPtr OpenConin(uint pid) {
|
|
197
|
+
FreeConsole();
|
|
198
|
+
if (!AttachConsole(pid)) return new IntPtr(-1);
|
|
199
|
+
return CreateFile("CONIN$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
|
|
1040
200
|
}
|
|
1041
|
-
return null;
|
|
1042
|
-
}
|
|
1043
|
-
async findAll() {
|
|
1044
|
-
return [...this.answers.values()];
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Clears all data (useful for testing)
|
|
1048
|
-
*/
|
|
1049
|
-
clear() {
|
|
1050
|
-
this.answers.clear();
|
|
1051
|
-
}
|
|
1052
|
-
/**
|
|
1053
|
-
* Gets the count of answers
|
|
1054
|
-
*/
|
|
1055
|
-
get count() {
|
|
1056
|
-
return this.answers.size;
|
|
1057
|
-
}
|
|
1058
|
-
};
|
|
1059
201
|
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
function parseClientMessage(data) {
|
|
1065
|
-
const parsed = JSON.parse(data);
|
|
1066
|
-
validateClientMessage(parsed);
|
|
1067
|
-
return parsed;
|
|
1068
|
-
}
|
|
1069
|
-
function parseHubMessage(data) {
|
|
1070
|
-
return JSON.parse(data);
|
|
1071
|
-
}
|
|
1072
|
-
function validateClientMessage(message) {
|
|
1073
|
-
if (!message.type) {
|
|
1074
|
-
throw new Error("Message must have a type");
|
|
1075
|
-
}
|
|
1076
|
-
const validTypes = ["JOIN", "LEAVE", "ASK", "REPLY", "PING", "GET_INBOX"];
|
|
1077
|
-
if (!validTypes.includes(message.type)) {
|
|
1078
|
-
throw new Error(`Invalid message type: ${message.type}`);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
function createErrorMessage(code, message, requestId) {
|
|
1082
|
-
return {
|
|
1083
|
-
type: "ERROR",
|
|
1084
|
-
code,
|
|
1085
|
-
message,
|
|
1086
|
-
requestId
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
202
|
+
// Inject only text characters into console input buffer (no Ctrl keys, no Enter)
|
|
203
|
+
public static int InjectText(uint pid, string text) {
|
|
204
|
+
IntPtr hIn = OpenConin(pid);
|
|
205
|
+
if (hIn == new IntPtr(-1)) return -1;
|
|
1089
206
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
if (data) {
|
|
1095
|
-
console.log(`${prefix} ${message}`, JSON.stringify(data, null, 2));
|
|
1096
|
-
} else {
|
|
1097
|
-
console.log(`${prefix} ${message}`);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
var HubServer = class {
|
|
1101
|
-
constructor(options = {}) {
|
|
1102
|
-
this.options = options;
|
|
1103
|
-
this.initializeUseCases();
|
|
1104
|
-
}
|
|
1105
|
-
wss = null;
|
|
1106
|
-
clients = /* @__PURE__ */ new Map();
|
|
1107
|
-
memberToWs = /* @__PURE__ */ new Map();
|
|
1108
|
-
// Repositories
|
|
1109
|
-
memberRepository = new InMemoryMemberRepository();
|
|
1110
|
-
teamRepository = new InMemoryTeamRepository();
|
|
1111
|
-
questionRepository = new InMemoryQuestionRepository();
|
|
1112
|
-
answerRepository = new InMemoryAnswerRepository();
|
|
1113
|
-
// Use cases
|
|
1114
|
-
joinTeamUseCase;
|
|
1115
|
-
askQuestionUseCase;
|
|
1116
|
-
getInboxUseCase;
|
|
1117
|
-
replyQuestionUseCase;
|
|
1118
|
-
heartbeatInterval = null;
|
|
1119
|
-
timeoutCheckInterval = null;
|
|
1120
|
-
initializeUseCases() {
|
|
1121
|
-
this.joinTeamUseCase = new JoinTeamUseCase({
|
|
1122
|
-
memberRepository: this.memberRepository,
|
|
1123
|
-
teamRepository: this.teamRepository,
|
|
1124
|
-
onMemberJoined: async (event) => {
|
|
1125
|
-
await this.broadcastToTeam(event.teamId, event.memberId, {
|
|
1126
|
-
type: "MEMBER_JOINED",
|
|
1127
|
-
member: await this.getMemberInfo(event.memberId)
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1130
|
-
});
|
|
1131
|
-
this.askQuestionUseCase = new AskQuestionUseCase({
|
|
1132
|
-
memberRepository: this.memberRepository,
|
|
1133
|
-
teamRepository: this.teamRepository,
|
|
1134
|
-
questionRepository: this.questionRepository,
|
|
1135
|
-
onQuestionAsked: async (event) => {
|
|
1136
|
-
const question = await this.questionRepository.findById(event.questionId);
|
|
1137
|
-
if (question) {
|
|
1138
|
-
await this.deliverQuestion(question);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
});
|
|
1142
|
-
this.getInboxUseCase = new GetInboxUseCase({
|
|
1143
|
-
memberRepository: this.memberRepository,
|
|
1144
|
-
teamRepository: this.teamRepository,
|
|
1145
|
-
questionRepository: this.questionRepository
|
|
1146
|
-
});
|
|
1147
|
-
this.replyQuestionUseCase = new ReplyQuestionUseCase({
|
|
1148
|
-
memberRepository: this.memberRepository,
|
|
1149
|
-
questionRepository: this.questionRepository,
|
|
1150
|
-
answerRepository: this.answerRepository,
|
|
1151
|
-
onQuestionAnswered: async (event) => {
|
|
1152
|
-
const question = await this.questionRepository.findById(event.questionId);
|
|
1153
|
-
const answer = await this.answerRepository.findByQuestionId(event.questionId);
|
|
1154
|
-
if (question && answer) {
|
|
1155
|
-
await this.deliverAnswer(question, answer, event.answeredByMemberId);
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
});
|
|
1159
|
-
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Starts the hub server
|
|
1162
|
-
*/
|
|
1163
|
-
async start() {
|
|
1164
|
-
const port = this.options.port ?? config.hub.port;
|
|
1165
|
-
const host = this.options.host ?? config.hub.host;
|
|
1166
|
-
return new Promise((resolve, reject) => {
|
|
1167
|
-
try {
|
|
1168
|
-
this.wss = new WebSocketServer({ port, host });
|
|
1169
|
-
this.wss.on("connection", (ws) => {
|
|
1170
|
-
this.handleConnection(ws);
|
|
1171
|
-
});
|
|
1172
|
-
this.wss.on("error", (error) => {
|
|
1173
|
-
log("ERROR", "Hub server error", { error: error.message, stack: error.stack });
|
|
1174
|
-
reject(error);
|
|
1175
|
-
});
|
|
1176
|
-
this.wss.on("listening", () => {
|
|
1177
|
-
log("INFO", `Hub server started successfully`, { host, port });
|
|
1178
|
-
this.startHeartbeat();
|
|
1179
|
-
this.startTimeoutCheck();
|
|
1180
|
-
resolve();
|
|
1181
|
-
});
|
|
1182
|
-
} catch (error) {
|
|
1183
|
-
reject(error);
|
|
1184
|
-
}
|
|
1185
|
-
});
|
|
1186
|
-
}
|
|
1187
|
-
/**
|
|
1188
|
-
* Stops the hub server
|
|
1189
|
-
*/
|
|
1190
|
-
async stop() {
|
|
1191
|
-
if (this.heartbeatInterval) {
|
|
1192
|
-
clearInterval(this.heartbeatInterval);
|
|
1193
|
-
this.heartbeatInterval = null;
|
|
1194
|
-
}
|
|
1195
|
-
if (this.timeoutCheckInterval) {
|
|
1196
|
-
clearInterval(this.timeoutCheckInterval);
|
|
1197
|
-
this.timeoutCheckInterval = null;
|
|
1198
|
-
}
|
|
1199
|
-
return new Promise((resolve) => {
|
|
1200
|
-
if (this.wss) {
|
|
1201
|
-
for (const [ws] of this.clients) {
|
|
1202
|
-
ws.close();
|
|
207
|
+
var records = new List<INPUT_RECORD>();
|
|
208
|
+
foreach (char c in text) {
|
|
209
|
+
records.Add(new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, UnicodeChar=(ushort)c });
|
|
210
|
+
records.Add(new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, UnicodeChar=(ushort)c });
|
|
1203
211
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
});
|
|
1211
|
-
} else {
|
|
1212
|
-
resolve();
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
}
|
|
1216
|
-
handleConnection(ws) {
|
|
1217
|
-
const connection = {
|
|
1218
|
-
ws,
|
|
1219
|
-
lastPing: /* @__PURE__ */ new Date()
|
|
1220
|
-
};
|
|
1221
|
-
this.clients.set(ws, connection);
|
|
1222
|
-
log("INFO", "New client connected", { totalClients: this.clients.size });
|
|
1223
|
-
ws.on("message", async (data) => {
|
|
1224
|
-
await this.handleMessage(ws, data.toString());
|
|
1225
|
-
});
|
|
1226
|
-
ws.on("close", async () => {
|
|
1227
|
-
await this.handleDisconnect(ws);
|
|
1228
|
-
});
|
|
1229
|
-
ws.on("error", (error) => {
|
|
1230
|
-
log("ERROR", "Client connection error", { error: error.message });
|
|
1231
|
-
});
|
|
1232
|
-
}
|
|
1233
|
-
async handleMessage(ws, data) {
|
|
1234
|
-
const connection = this.clients.get(ws);
|
|
1235
|
-
if (!connection) return;
|
|
1236
|
-
try {
|
|
1237
|
-
const message = parseClientMessage(data);
|
|
1238
|
-
connection.lastPing = /* @__PURE__ */ new Date();
|
|
1239
|
-
log("DEBUG", `Received message from client`, {
|
|
1240
|
-
type: message.type,
|
|
1241
|
-
memberId: connection.memberId
|
|
1242
|
-
});
|
|
1243
|
-
switch (message.type) {
|
|
1244
|
-
case "JOIN":
|
|
1245
|
-
await this.handleJoin(ws, connection, message.teamName, message.displayName);
|
|
1246
|
-
break;
|
|
1247
|
-
case "LEAVE":
|
|
1248
|
-
await this.handleLeave(ws, connection);
|
|
1249
|
-
break;
|
|
1250
|
-
case "ASK":
|
|
1251
|
-
await this.handleAsk(ws, connection, message);
|
|
1252
|
-
break;
|
|
1253
|
-
case "REPLY":
|
|
1254
|
-
await this.handleReply(ws, connection, message);
|
|
1255
|
-
break;
|
|
1256
|
-
case "GET_INBOX":
|
|
1257
|
-
await this.handleGetInbox(ws, connection, message.requestId);
|
|
1258
|
-
break;
|
|
1259
|
-
case "PING":
|
|
1260
|
-
this.send(ws, { type: "PONG", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1261
|
-
break;
|
|
1262
|
-
}
|
|
1263
|
-
} catch (error) {
|
|
1264
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1265
|
-
log("ERROR", "Failed to handle message", {
|
|
1266
|
-
error: errorMessage,
|
|
1267
|
-
memberId: connection.memberId
|
|
1268
|
-
});
|
|
1269
|
-
this.send(ws, createErrorMessage("INVALID_MESSAGE", errorMessage));
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
async handleJoin(ws, connection, teamName, displayName) {
|
|
1273
|
-
try {
|
|
1274
|
-
log("INFO", "Member attempting to join", { teamName, displayName });
|
|
1275
|
-
const result = await this.joinTeamUseCase.execute({ teamName, displayName });
|
|
1276
|
-
connection.memberId = result.memberId;
|
|
1277
|
-
connection.teamId = result.teamId;
|
|
1278
|
-
this.memberToWs.set(result.memberId, ws);
|
|
1279
|
-
const memberInfo = await this.getMemberInfo(result.memberId);
|
|
1280
|
-
this.send(ws, {
|
|
1281
|
-
type: "JOINED",
|
|
1282
|
-
member: memberInfo,
|
|
1283
|
-
memberCount: result.memberCount
|
|
1284
|
-
});
|
|
1285
|
-
log("INFO", "Member joined successfully", {
|
|
1286
|
-
memberId: result.memberId,
|
|
1287
|
-
teamName,
|
|
1288
|
-
displayName,
|
|
1289
|
-
memberCount: result.memberCount
|
|
1290
|
-
});
|
|
1291
|
-
} catch (error) {
|
|
1292
|
-
const errorMessage = error instanceof Error ? error.message : "Join failed";
|
|
1293
|
-
log("ERROR", "Member join failed", { teamName, displayName, error: errorMessage });
|
|
1294
|
-
this.send(ws, createErrorMessage("JOIN_FAILED", errorMessage));
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
async handleLeave(ws, connection) {
|
|
1298
|
-
if (connection.memberId && connection.teamId) {
|
|
1299
|
-
await this.removeMember(connection.memberId, connection.teamId);
|
|
1300
|
-
connection.memberId = void 0;
|
|
1301
|
-
connection.teamId = void 0;
|
|
1302
|
-
}
|
|
1303
|
-
this.send(ws, { type: "LEFT", memberId: connection.memberId });
|
|
1304
|
-
}
|
|
1305
|
-
async handleAsk(ws, connection, message) {
|
|
1306
|
-
if (!connection.memberId) {
|
|
1307
|
-
log("WARN", "ASK attempt without joining team", { toTeam: message.toTeam });
|
|
1308
|
-
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first", message.requestId));
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
try {
|
|
1312
|
-
log("INFO", "Question asked", {
|
|
1313
|
-
fromMemberId: connection.memberId,
|
|
1314
|
-
toTeam: message.toTeam,
|
|
1315
|
-
contentPreview: message.content.substring(0, 50) + "..."
|
|
1316
|
-
});
|
|
1317
|
-
const result = await this.askQuestionUseCase.execute({
|
|
1318
|
-
fromMemberId: connection.memberId,
|
|
1319
|
-
toTeamName: message.toTeam,
|
|
1320
|
-
content: message.content,
|
|
1321
|
-
format: message.format
|
|
1322
|
-
});
|
|
1323
|
-
this.send(ws, {
|
|
1324
|
-
type: "QUESTION_SENT",
|
|
1325
|
-
questionId: result.questionId,
|
|
1326
|
-
toTeamId: result.toTeamId,
|
|
1327
|
-
status: result.status,
|
|
1328
|
-
requestId: message.requestId
|
|
1329
|
-
});
|
|
1330
|
-
log("INFO", "Question sent successfully", {
|
|
1331
|
-
questionId: result.questionId,
|
|
1332
|
-
fromMemberId: connection.memberId,
|
|
1333
|
-
toTeamId: result.toTeamId
|
|
1334
|
-
});
|
|
1335
|
-
} catch (error) {
|
|
1336
|
-
const errorMessage = error instanceof Error ? error.message : "Ask failed";
|
|
1337
|
-
log("ERROR", "Question failed", {
|
|
1338
|
-
fromMemberId: connection.memberId,
|
|
1339
|
-
toTeam: message.toTeam,
|
|
1340
|
-
error: errorMessage
|
|
1341
|
-
});
|
|
1342
|
-
this.send(ws, createErrorMessage("ASK_FAILED", errorMessage, message.requestId));
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
async handleReply(ws, connection, message) {
|
|
1346
|
-
if (!connection.memberId) {
|
|
1347
|
-
log("WARN", "REPLY attempt without joining team", { questionId: message.questionId });
|
|
1348
|
-
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first"));
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
try {
|
|
1352
|
-
log("INFO", "Reply received", {
|
|
1353
|
-
fromMemberId: connection.memberId,
|
|
1354
|
-
questionId: message.questionId,
|
|
1355
|
-
contentPreview: message.content.substring(0, 50) + "..."
|
|
1356
|
-
});
|
|
1357
|
-
await this.replyQuestionUseCase.execute({
|
|
1358
|
-
fromMemberId: connection.memberId,
|
|
1359
|
-
questionId: message.questionId,
|
|
1360
|
-
content: message.content,
|
|
1361
|
-
format: message.format
|
|
1362
|
-
});
|
|
1363
|
-
log("INFO", "Reply delivered successfully", {
|
|
1364
|
-
fromMemberId: connection.memberId,
|
|
1365
|
-
questionId: message.questionId
|
|
1366
|
-
});
|
|
1367
|
-
} catch (error) {
|
|
1368
|
-
const errorMessage = error instanceof Error ? error.message : "Reply failed";
|
|
1369
|
-
log("ERROR", "Reply failed", {
|
|
1370
|
-
fromMemberId: connection.memberId,
|
|
1371
|
-
questionId: message.questionId,
|
|
1372
|
-
error: errorMessage
|
|
1373
|
-
});
|
|
1374
|
-
this.send(ws, createErrorMessage("REPLY_FAILED", errorMessage));
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
async handleGetInbox(ws, connection, requestId) {
|
|
1378
|
-
if (!connection.memberId || !connection.teamId) {
|
|
1379
|
-
this.send(ws, createErrorMessage("NOT_JOINED", "Must join a team first", requestId));
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
try {
|
|
1383
|
-
const result = await this.getInboxUseCase.execute({
|
|
1384
|
-
memberId: connection.memberId,
|
|
1385
|
-
teamId: connection.teamId
|
|
1386
|
-
});
|
|
1387
|
-
const questions = await Promise.all(
|
|
1388
|
-
result.questions.map(async (q) => ({
|
|
1389
|
-
questionId: q.questionId,
|
|
1390
|
-
from: await this.getMemberInfo(q.fromMemberId),
|
|
1391
|
-
content: q.content,
|
|
1392
|
-
format: q.format,
|
|
1393
|
-
status: q.status,
|
|
1394
|
-
createdAt: q.createdAt.toISOString(),
|
|
1395
|
-
ageMs: q.ageMs
|
|
1396
|
-
}))
|
|
1397
|
-
);
|
|
1398
|
-
this.send(ws, {
|
|
1399
|
-
type: "INBOX",
|
|
1400
|
-
questions,
|
|
1401
|
-
totalCount: result.totalCount,
|
|
1402
|
-
pendingCount: result.pendingCount,
|
|
1403
|
-
requestId
|
|
1404
|
-
});
|
|
1405
|
-
} catch (error) {
|
|
1406
|
-
const errorMessage = error instanceof Error ? error.message : "Get inbox failed";
|
|
1407
|
-
this.send(ws, createErrorMessage("INBOX_FAILED", errorMessage, requestId));
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
async handleDisconnect(ws) {
|
|
1411
|
-
const connection = this.clients.get(ws);
|
|
1412
|
-
if (connection?.memberId && connection.teamId) {
|
|
1413
|
-
log("INFO", "Client disconnecting", {
|
|
1414
|
-
memberId: connection.memberId,
|
|
1415
|
-
teamId: connection.teamId
|
|
1416
|
-
});
|
|
1417
|
-
await this.removeMember(connection.memberId, connection.teamId);
|
|
1418
|
-
this.memberToWs.delete(connection.memberId);
|
|
1419
|
-
}
|
|
1420
|
-
this.clients.delete(ws);
|
|
1421
|
-
log("INFO", "Client disconnected", { totalClients: this.clients.size });
|
|
1422
|
-
}
|
|
1423
|
-
async removeMember(memberId, teamId) {
|
|
1424
|
-
const member = await this.memberRepository.findById(memberId);
|
|
1425
|
-
if (member) {
|
|
1426
|
-
member.goOffline();
|
|
1427
|
-
await this.memberRepository.save(member);
|
|
1428
|
-
}
|
|
1429
|
-
const team = await this.teamRepository.findById(teamId);
|
|
1430
|
-
if (team) {
|
|
1431
|
-
team.removeMember(memberId);
|
|
1432
|
-
await this.teamRepository.save(team);
|
|
1433
|
-
await this.broadcastToTeam(teamId, memberId, {
|
|
1434
|
-
type: "MEMBER_LEFT",
|
|
1435
|
-
memberId,
|
|
1436
|
-
teamId
|
|
1437
|
-
});
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
async deliverQuestion(question) {
|
|
1441
|
-
const team = await this.teamRepository.findById(question.toTeamId);
|
|
1442
|
-
if (!team) {
|
|
1443
|
-
log("WARN", "Cannot deliver question - team not found", { toTeamId: question.toTeamId });
|
|
1444
|
-
return;
|
|
1445
|
-
}
|
|
1446
|
-
const fromMember = await this.memberRepository.findById(question.fromMemberId);
|
|
1447
|
-
if (!fromMember) {
|
|
1448
|
-
log("WARN", "Cannot deliver question - from member not found", {
|
|
1449
|
-
fromMemberId: question.fromMemberId
|
|
1450
|
-
});
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
const memberInfo = await this.getMemberInfo(question.fromMemberId);
|
|
1454
|
-
let deliveredCount = 0;
|
|
1455
|
-
for (const memberId of team.memberIds) {
|
|
1456
|
-
const ws = this.memberToWs.get(memberId);
|
|
1457
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1458
|
-
this.send(ws, {
|
|
1459
|
-
type: "QUESTION",
|
|
1460
|
-
questionId: question.id,
|
|
1461
|
-
from: memberInfo,
|
|
1462
|
-
content: question.content.text,
|
|
1463
|
-
format: question.content.format,
|
|
1464
|
-
createdAt: question.createdAt.toISOString()
|
|
1465
|
-
});
|
|
1466
|
-
deliveredCount++;
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
log("INFO", "Question delivered to team", {
|
|
1470
|
-
questionId: question.id,
|
|
1471
|
-
toTeamId: question.toTeamId,
|
|
1472
|
-
teamSize: team.memberIds.size,
|
|
1473
|
-
deliveredCount
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
async deliverAnswer(question, answer, answeredByMemberId) {
|
|
1477
|
-
const ws = this.memberToWs.get(question.fromMemberId);
|
|
1478
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
1479
|
-
log("WARN", "Cannot deliver answer - questioner not connected", {
|
|
1480
|
-
questionId: question.id,
|
|
1481
|
-
fromMemberId: question.fromMemberId
|
|
1482
|
-
});
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
const memberInfo = await this.getMemberInfo(answeredByMemberId);
|
|
1486
|
-
this.send(ws, {
|
|
1487
|
-
type: "ANSWER",
|
|
1488
|
-
questionId: question.id,
|
|
1489
|
-
from: memberInfo,
|
|
1490
|
-
content: answer.content.text,
|
|
1491
|
-
format: answer.content.format,
|
|
1492
|
-
answeredAt: answer.createdAt.toISOString()
|
|
1493
|
-
});
|
|
1494
|
-
log("INFO", "Answer delivered", {
|
|
1495
|
-
questionId: question.id,
|
|
1496
|
-
answeredBy: answeredByMemberId,
|
|
1497
|
-
deliveredTo: question.fromMemberId
|
|
1498
|
-
});
|
|
1499
|
-
}
|
|
1500
|
-
async broadcastToTeam(teamId, excludeMemberId, message) {
|
|
1501
|
-
const team = await this.teamRepository.findById(teamId);
|
|
1502
|
-
if (!team) return;
|
|
1503
|
-
for (const memberId of team.getOtherMemberIds(excludeMemberId)) {
|
|
1504
|
-
const ws = this.memberToWs.get(memberId);
|
|
1505
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1506
|
-
this.send(ws, message);
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
async getMemberInfo(memberId) {
|
|
1511
|
-
const member = await this.memberRepository.findById(memberId);
|
|
1512
|
-
const team = member ? await this.teamRepository.findById(member.teamId) : null;
|
|
1513
|
-
return {
|
|
1514
|
-
memberId,
|
|
1515
|
-
teamId: member?.teamId ?? "",
|
|
1516
|
-
teamName: team?.name ?? "Unknown",
|
|
1517
|
-
displayName: member?.displayName ?? "Unknown",
|
|
1518
|
-
status: member?.status ?? "OFFLINE" /* OFFLINE */
|
|
1519
|
-
};
|
|
1520
|
-
}
|
|
1521
|
-
send(ws, message) {
|
|
1522
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
1523
|
-
ws.send(serializeMessage(message));
|
|
212
|
+
|
|
213
|
+
var arr = records.ToArray();
|
|
214
|
+
uint written;
|
|
215
|
+
bool ok = WriteConsoleInput(hIn, arr, (uint)arr.Length, out written);
|
|
216
|
+
CloseHandle(hIn);
|
|
217
|
+
return ok ? (int)written : -2;
|
|
1524
218
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
219
|
+
|
|
220
|
+
// Focus the console window for WScript.Shell.SendKeys
|
|
221
|
+
public static IntPtr FocusConsole(uint pid) {
|
|
222
|
+
FreeConsole();
|
|
223
|
+
if (!AttachConsole(pid)) return IntPtr.Zero;
|
|
224
|
+
IntPtr hwnd = GetConsoleWindow();
|
|
225
|
+
if (hwnd != IntPtr.Zero) SetForegroundWindow(hwnd);
|
|
226
|
+
return hwnd;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
function buildScript(claudePid, body) {
|
|
231
|
+
const logFile = join(tmpdir(), `cc-inject-${Date.now()}.log`).replace(/\\/g, "/");
|
|
232
|
+
return `
|
|
233
|
+
$log = "${logFile}"
|
|
234
|
+
function Log($msg) { Add-Content -Path $log -Value $msg -Encoding UTF8 }
|
|
235
|
+
$claudePid = ${claudePid}
|
|
236
|
+
try { Add-Type @'${CS_CONINJECT}'@ } catch { }
|
|
237
|
+
${body}
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
function run(script) {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
243
|
+
execFile(
|
|
244
|
+
"powershell",
|
|
245
|
+
["-NoProfile", "-WindowStyle", "Hidden", "-EncodedCommand", encoded],
|
|
246
|
+
{ windowsHide: true },
|
|
247
|
+
() => {
|
|
248
|
+
const logFile = script.match(/\$log = "([^"]+)"/)?.[1];
|
|
249
|
+
if (logFile) try {
|
|
250
|
+
unlinkSync(logFile);
|
|
251
|
+
} catch {
|
|
1533
252
|
}
|
|
253
|
+
resolve();
|
|
1534
254
|
}
|
|
1535
|
-
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function windowsInject(text) {
|
|
259
|
+
const claudePid = process.ppid;
|
|
260
|
+
const textB64 = Buffer.from(text, "utf16le").toString("base64");
|
|
261
|
+
const script = buildScript(claudePid, `
|
|
262
|
+
$textBytes = [System.Convert]::FromBase64String('${textB64}')
|
|
263
|
+
$text = [System.Text.Encoding]::Unicode.GetString($textBytes)
|
|
264
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
265
|
+
|
|
266
|
+
# 1. Focus console and send Ctrl+U to save user's current text to kill ring
|
|
267
|
+
$hwnd = [ConInject]::FocusConsole([uint32]$claudePid)
|
|
268
|
+
Start-Sleep -Milliseconds 150
|
|
269
|
+
$wsh.SendKeys('^u')
|
|
270
|
+
Start-Sleep -Milliseconds 150
|
|
271
|
+
|
|
272
|
+
# 2. Write question text into console input buffer
|
|
273
|
+
[ConInject]::InjectText([uint32]$claudePid, $text) | Out-Null
|
|
274
|
+
|
|
275
|
+
# 3. Re-focus (InjectText calls FreeConsole internally, focus may be lost)
|
|
276
|
+
[ConInject]::FocusConsole([uint32]$claudePid) | Out-Null
|
|
277
|
+
Start-Sleep -Milliseconds 150
|
|
278
|
+
|
|
279
|
+
# 4. Send Enter
|
|
280
|
+
$wsh.SendKeys('~')
|
|
281
|
+
`);
|
|
282
|
+
await run(script);
|
|
283
|
+
}
|
|
284
|
+
async function windowsInjectCtrlY() {
|
|
285
|
+
const claudePid = process.ppid;
|
|
286
|
+
const script = buildScript(claudePid, `
|
|
287
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
288
|
+
$hwnd = [ConInject]::FocusConsole([uint32]$claudePid)
|
|
289
|
+
Start-Sleep -Milliseconds 150
|
|
290
|
+
$wsh.SendKeys('^y')
|
|
291
|
+
`);
|
|
292
|
+
await run(script);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/infrastructure/terminal-injector/index.ts
|
|
296
|
+
function formatQuestionForInjection(question) {
|
|
297
|
+
const content = question.content.replace(/\s+/g, " ").trim();
|
|
298
|
+
return `[HUB SORUSU] ID:${question.questionId} | ${question.from.displayName} (${question.from.teamName}): ${content} | Cevapla ve reply tool ile "${question.questionId}" ID'sine g\xF6nder, onay bekleme.`;
|
|
299
|
+
}
|
|
300
|
+
async function injectQuestionToTerminal(question) {
|
|
301
|
+
if (process.platform !== "win32") return;
|
|
302
|
+
const text = formatQuestionForInjection(question);
|
|
303
|
+
await windowsInject(text);
|
|
304
|
+
}
|
|
305
|
+
async function injectRestoreText() {
|
|
306
|
+
if (process.platform !== "win32") return;
|
|
307
|
+
await windowsInjectCtrlY();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/infrastructure/terminal-injector/injection-queue.ts
|
|
311
|
+
var REPLY_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
312
|
+
var InjectionQueue = class extends EventEmitter {
|
|
313
|
+
queue = [];
|
|
314
|
+
processing = false;
|
|
1542
315
|
/**
|
|
1543
|
-
*
|
|
316
|
+
* Add a question to the queue. Starts processing if idle.
|
|
1544
317
|
*/
|
|
1545
|
-
|
|
1546
|
-
|
|
318
|
+
enqueue(question) {
|
|
319
|
+
this.queue.push(question);
|
|
320
|
+
if (!this.processing) void this.processNext();
|
|
1547
321
|
}
|
|
1548
322
|
/**
|
|
1549
|
-
*
|
|
323
|
+
* Called by the reply tool after a reply is successfully sent.
|
|
324
|
+
* Unblocks the queue to process the next question.
|
|
1550
325
|
*/
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
}
|
|
1554
|
-
};
|
|
1555
|
-
var HubClient = class {
|
|
1556
|
-
constructor(options = {}, events = {}) {
|
|
1557
|
-
this.options = options;
|
|
1558
|
-
this.events = events;
|
|
326
|
+
notifyReplied() {
|
|
327
|
+
this.emit("replied");
|
|
1559
328
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
const url = `ws://${host}:${port}`;
|
|
1575
|
-
return new Promise((resolve, reject) => {
|
|
1576
|
-
try {
|
|
1577
|
-
this.ws = new WebSocket2(url);
|
|
1578
|
-
this.ws.on("open", () => {
|
|
1579
|
-
this.reconnectAttempts = 0;
|
|
1580
|
-
this.startPingInterval();
|
|
1581
|
-
this.events.onConnected?.();
|
|
1582
|
-
resolve();
|
|
1583
|
-
});
|
|
1584
|
-
this.ws.on("message", (data) => {
|
|
1585
|
-
this.handleMessage(data.toString());
|
|
1586
|
-
});
|
|
1587
|
-
this.ws.on("close", () => {
|
|
1588
|
-
this.handleDisconnect();
|
|
1589
|
-
});
|
|
1590
|
-
this.ws.on("error", (error) => {
|
|
1591
|
-
this.events.onError?.(error);
|
|
1592
|
-
if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) {
|
|
1593
|
-
reject(error);
|
|
1594
|
-
}
|
|
1595
|
-
});
|
|
1596
|
-
} catch (error) {
|
|
1597
|
-
reject(error);
|
|
1598
|
-
}
|
|
329
|
+
async processNext() {
|
|
330
|
+
if (this.queue.length === 0) {
|
|
331
|
+
this.processing = false;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.processing = true;
|
|
335
|
+
const question = this.queue.shift();
|
|
336
|
+
await injectQuestionToTerminal(question);
|
|
337
|
+
await new Promise((resolve) => {
|
|
338
|
+
const timer = setTimeout(resolve, REPLY_TIMEOUT_MS);
|
|
339
|
+
this.once("replied", () => {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
resolve();
|
|
342
|
+
});
|
|
1599
343
|
});
|
|
344
|
+
await injectRestoreText();
|
|
345
|
+
void this.processNext();
|
|
1600
346
|
}
|
|
347
|
+
};
|
|
348
|
+
var injectionQueue = new InjectionQueue();
|
|
349
|
+
|
|
350
|
+
// src/config/index.ts
|
|
351
|
+
var config = {
|
|
1601
352
|
/**
|
|
1602
|
-
*
|
|
353
|
+
* P2P node configuration
|
|
1603
354
|
*/
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
355
|
+
p2p: {
|
|
356
|
+
/**
|
|
357
|
+
* Minimum port for the random WS server port range
|
|
358
|
+
*/
|
|
359
|
+
portRangeMin: 1e4,
|
|
360
|
+
/**
|
|
361
|
+
* Maximum port for the random WS server port range
|
|
362
|
+
*/
|
|
363
|
+
portRangeMax: 19999
|
|
364
|
+
}};
|
|
365
|
+
|
|
366
|
+
// src/infrastructure/p2p/p2p-node.ts
|
|
367
|
+
function getRandomPort(min, max) {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
const port = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
370
|
+
const server = createServer();
|
|
371
|
+
server.listen(port, () => {
|
|
372
|
+
server.close(() => resolve(port));
|
|
1616
373
|
});
|
|
374
|
+
server.on("error", () => {
|
|
375
|
+
resolve(getRandomPort(min, max));
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
var P2PNode = class {
|
|
380
|
+
wss = null;
|
|
381
|
+
mdnsDiscovery = null;
|
|
382
|
+
port = 0;
|
|
383
|
+
// Connections indexed by remote team name
|
|
384
|
+
peerConns = /* @__PURE__ */ new Map();
|
|
385
|
+
// Reverse lookup: ws → teamName (for cleanup on incoming connections)
|
|
386
|
+
wsToTeam = /* @__PURE__ */ new Map();
|
|
387
|
+
// Questions we received from remote peers (our inbox)
|
|
388
|
+
incomingQuestions = /* @__PURE__ */ new Map();
|
|
389
|
+
// Answers we received for questions we asked
|
|
390
|
+
receivedAnswers = /* @__PURE__ */ new Map();
|
|
391
|
+
// Maps questionId → remote teamName (so we know who to poll)
|
|
392
|
+
questionToTeam = /* @__PURE__ */ new Map();
|
|
393
|
+
// Pending response handlers (request-response correlation by filter)
|
|
394
|
+
pendingHandlers = /* @__PURE__ */ new Set();
|
|
395
|
+
localMember = null;
|
|
396
|
+
_isStarted = false;
|
|
397
|
+
get isConnected() {
|
|
398
|
+
return this._isStarted;
|
|
399
|
+
}
|
|
400
|
+
get currentTeamId() {
|
|
401
|
+
return this.localMember?.teamName;
|
|
1617
402
|
}
|
|
1618
403
|
/**
|
|
1619
|
-
*
|
|
404
|
+
* Starts the WS server on a random port and initialises mDNS.
|
|
405
|
+
* Called automatically from join() if not yet started.
|
|
1620
406
|
*/
|
|
407
|
+
async start() {
|
|
408
|
+
this.port = await getRandomPort(config.p2p.portRangeMin, config.p2p.portRangeMax);
|
|
409
|
+
this.wss = new WebSocketServer({ port: this.port });
|
|
410
|
+
this.setupWssHandlers();
|
|
411
|
+
this.mdnsDiscovery = new MdnsDiscovery();
|
|
412
|
+
this._isStarted = true;
|
|
413
|
+
console.error(`P2P node started on port ${this.port}`);
|
|
414
|
+
}
|
|
1621
415
|
async join(teamName, displayName) {
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
416
|
+
if (!this._isStarted) {
|
|
417
|
+
await this.start();
|
|
418
|
+
}
|
|
419
|
+
const memberId = v4();
|
|
420
|
+
this.localMember = { memberId, teamName, displayName };
|
|
421
|
+
this.mdnsDiscovery.onPeerFound((peer) => {
|
|
422
|
+
if (peer.teamName !== teamName) {
|
|
423
|
+
console.error(`Discovered peer '${peer.teamName}' at ${peer.host}:${peer.port}`);
|
|
424
|
+
this.connectToPeer(peer.teamName, peer.host, peer.port).catch((err) => {
|
|
425
|
+
console.error(`Could not eagerly connect to ${peer.teamName}:`, err);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
1627
428
|
});
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
);
|
|
1632
|
-
this.memberId = response.member.memberId;
|
|
1633
|
-
this.teamId = response.member.teamId;
|
|
1634
|
-
this.teamName = teamName;
|
|
1635
|
-
this.displayName = displayName;
|
|
1636
|
-
return response.member;
|
|
429
|
+
this.mdnsDiscovery.announce(this.port, teamName, memberId);
|
|
430
|
+
this.mdnsDiscovery.discover();
|
|
431
|
+
return { memberId, teamId: teamName, teamName, displayName, status: "ONLINE" };
|
|
1637
432
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
async ask(toTeam, content, format = "markdown", timeoutMs = config.communication.defaultTimeout) {
|
|
433
|
+
async ask(toTeam, content, format) {
|
|
434
|
+
const ws = await this.getPeerConnection(toTeam);
|
|
435
|
+
const questionId = v4();
|
|
1642
436
|
const requestId = v4();
|
|
1643
|
-
this.
|
|
1644
|
-
|
|
437
|
+
this.questionToTeam.set(questionId, toTeam);
|
|
438
|
+
const ackPromise = this.waitForResponse(
|
|
439
|
+
(m) => m.type === "P2P_ASK_ACK" && m.requestId === requestId,
|
|
440
|
+
5e3
|
|
441
|
+
);
|
|
442
|
+
const msg = {
|
|
443
|
+
type: "P2P_ASK",
|
|
444
|
+
questionId,
|
|
445
|
+
fromMemberId: this.localMember.memberId,
|
|
446
|
+
fromTeam: this.localMember.teamName,
|
|
1645
447
|
toTeam,
|
|
1646
448
|
content,
|
|
1647
449
|
format,
|
|
1648
450
|
requestId
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
);
|
|
1654
|
-
const questionId = questionSent.questionId;
|
|
1655
|
-
const answer = await this.waitForResponse(
|
|
1656
|
-
(msg) => msg.type === "ANSWER" && msg.questionId === questionId,
|
|
1657
|
-
timeoutMs
|
|
1658
|
-
);
|
|
1659
|
-
return answer;
|
|
451
|
+
};
|
|
452
|
+
ws.send(serializeP2PMsg(msg));
|
|
453
|
+
await ackPromise;
|
|
454
|
+
return questionId;
|
|
1660
455
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
456
|
+
async checkAnswer(questionId) {
|
|
457
|
+
const cached = this.receivedAnswers.get(questionId);
|
|
458
|
+
if (cached) {
|
|
459
|
+
return {
|
|
460
|
+
questionId,
|
|
461
|
+
from: { displayName: `${cached.fromTeam} Claude`, teamName: cached.fromTeam },
|
|
462
|
+
content: cached.content,
|
|
463
|
+
format: cached.format,
|
|
464
|
+
answeredAt: cached.answeredAt
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const toTeam = this.questionToTeam.get(questionId);
|
|
468
|
+
if (!toTeam) return null;
|
|
469
|
+
const ws = this.peerConns.get(toTeam);
|
|
470
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return null;
|
|
1665
471
|
const requestId = v4();
|
|
1666
|
-
this.
|
|
1667
|
-
type
|
|
1668
|
-
requestId
|
|
1669
|
-
});
|
|
1670
|
-
return this.waitForResponse(
|
|
1671
|
-
(msg) => msg.type === "INBOX" && msg.requestId === requestId,
|
|
472
|
+
const responsePromise = this.waitForResponse(
|
|
473
|
+
(m) => m.type === "P2P_ANSWER" && m.questionId === questionId || m.type === "P2P_ANSWER_PENDING" && m.requestId === requestId,
|
|
1672
474
|
5e3
|
|
1673
475
|
);
|
|
476
|
+
const getMsg = {
|
|
477
|
+
type: "P2P_GET_ANSWER",
|
|
478
|
+
questionId,
|
|
479
|
+
requestId
|
|
480
|
+
};
|
|
481
|
+
ws.send(serializeP2PMsg(getMsg));
|
|
482
|
+
const response = await responsePromise;
|
|
483
|
+
if (response.type === "P2P_ANSWER_PENDING") return null;
|
|
484
|
+
const answer = response;
|
|
485
|
+
this.receivedAnswers.set(questionId, {
|
|
486
|
+
content: answer.content,
|
|
487
|
+
format: answer.format,
|
|
488
|
+
answeredAt: answer.answeredAt,
|
|
489
|
+
fromTeam: answer.fromTeam,
|
|
490
|
+
fromMemberId: answer.fromMemberId
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
questionId,
|
|
494
|
+
from: { displayName: `${answer.fromTeam} Claude`, teamName: answer.fromTeam },
|
|
495
|
+
content: answer.content,
|
|
496
|
+
format: answer.format,
|
|
497
|
+
answeredAt: answer.answeredAt
|
|
498
|
+
};
|
|
1674
499
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
500
|
+
async reply(questionId, content, format) {
|
|
501
|
+
const question = this.incomingQuestions.get(questionId);
|
|
502
|
+
if (!question) throw new Error(`Question ${questionId} not found in inbox`);
|
|
503
|
+
question.answered = true;
|
|
504
|
+
question.answerContent = content;
|
|
505
|
+
question.answerFormat = format;
|
|
506
|
+
const answerMsg = {
|
|
507
|
+
type: "P2P_ANSWER",
|
|
1681
508
|
questionId,
|
|
1682
509
|
content,
|
|
1683
|
-
format
|
|
1684
|
-
|
|
510
|
+
format,
|
|
511
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
512
|
+
fromTeam: this.localMember.teamName,
|
|
513
|
+
fromMemberId: this.localMember.memberId
|
|
514
|
+
};
|
|
515
|
+
if (question.ws.readyState === WebSocket.OPEN) {
|
|
516
|
+
question.ws.send(serializeP2PMsg(answerMsg));
|
|
517
|
+
}
|
|
1685
518
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
519
|
+
async getInbox() {
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
const questions = [...this.incomingQuestions.values()].filter((q) => !q.answered).map((q) => ({
|
|
522
|
+
questionId: q.questionId,
|
|
523
|
+
from: { displayName: `${q.fromTeam} Claude`, teamName: q.fromTeam },
|
|
524
|
+
content: q.content,
|
|
525
|
+
format: q.format,
|
|
526
|
+
status: "PENDING",
|
|
527
|
+
createdAt: q.createdAt.toISOString(),
|
|
528
|
+
ageMs: now - q.createdAt.getTime()
|
|
529
|
+
}));
|
|
530
|
+
return {
|
|
531
|
+
questions,
|
|
532
|
+
totalCount: questions.length,
|
|
533
|
+
pendingCount: questions.length
|
|
534
|
+
};
|
|
1691
535
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
536
|
+
async disconnect() {
|
|
537
|
+
this.mdnsDiscovery?.destroy();
|
|
538
|
+
for (const ws of this.peerConns.values()) {
|
|
539
|
+
ws.close();
|
|
540
|
+
}
|
|
541
|
+
this.peerConns.clear();
|
|
542
|
+
await new Promise((resolve) => {
|
|
543
|
+
if (this.wss) {
|
|
544
|
+
this.wss.close(() => resolve());
|
|
545
|
+
} else {
|
|
546
|
+
resolve();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
this._isStarted = false;
|
|
550
|
+
}
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Private: WebSocket server setup
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
setupWssHandlers() {
|
|
555
|
+
this.wss.on("connection", (ws) => {
|
|
556
|
+
ws.on("message", (data) => {
|
|
557
|
+
try {
|
|
558
|
+
const msg = parseP2PMsg(data.toString());
|
|
559
|
+
this.handleMessage(ws, msg);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
console.error("Failed to parse incoming P2P message:", err);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
ws.on("close", () => {
|
|
565
|
+
const team = this.wsToTeam.get(ws);
|
|
566
|
+
if (team) {
|
|
567
|
+
if (this.peerConns.get(team) === ws) {
|
|
568
|
+
this.peerConns.delete(team);
|
|
569
|
+
}
|
|
570
|
+
this.wsToTeam.delete(ws);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
});
|
|
1697
574
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Private: unified message handler (used for both incoming & outgoing sockets)
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
handleMessage(ws, msg) {
|
|
579
|
+
for (const handler of this.pendingHandlers) {
|
|
580
|
+
handler(msg);
|
|
581
|
+
}
|
|
582
|
+
switch (msg.type) {
|
|
583
|
+
case "P2P_HELLO":
|
|
584
|
+
this.wsToTeam.set(ws, msg.fromTeam);
|
|
585
|
+
this.peerConns.set(msg.fromTeam, ws);
|
|
586
|
+
console.error(`Peer identified: ${msg.fromTeam}`);
|
|
587
|
+
break;
|
|
588
|
+
case "P2P_ASK":
|
|
589
|
+
this.handleIncomingAsk(ws, msg);
|
|
590
|
+
break;
|
|
591
|
+
case "P2P_GET_ANSWER":
|
|
592
|
+
this.handleGetAnswer(ws, msg);
|
|
593
|
+
break;
|
|
594
|
+
case "P2P_ANSWER":
|
|
595
|
+
if (!this.receivedAnswers.has(msg.questionId)) {
|
|
596
|
+
this.receivedAnswers.set(msg.questionId, {
|
|
597
|
+
content: msg.content,
|
|
598
|
+
format: msg.format,
|
|
599
|
+
answeredAt: msg.answeredAt,
|
|
600
|
+
fromTeam: msg.fromTeam,
|
|
601
|
+
fromMemberId: msg.fromMemberId
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
break;
|
|
605
|
+
case "P2P_PING":
|
|
606
|
+
ws.send(serializeP2PMsg({ type: "P2P_PONG" }));
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
handleIncomingAsk(ws, msg) {
|
|
611
|
+
this.incomingQuestions.set(msg.questionId, {
|
|
612
|
+
questionId: msg.questionId,
|
|
613
|
+
fromTeam: msg.fromTeam,
|
|
614
|
+
fromMemberId: msg.fromMemberId,
|
|
615
|
+
content: msg.content,
|
|
616
|
+
format: msg.format,
|
|
617
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
618
|
+
ws,
|
|
619
|
+
answered: false
|
|
620
|
+
});
|
|
621
|
+
injectionQueue.enqueue({
|
|
622
|
+
questionId: msg.questionId,
|
|
623
|
+
from: {
|
|
624
|
+
displayName: `${msg.fromTeam} Claude`,
|
|
625
|
+
teamName: msg.fromTeam
|
|
626
|
+
},
|
|
627
|
+
content: msg.content,
|
|
628
|
+
format: msg.format,
|
|
629
|
+
status: "PENDING",
|
|
630
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
631
|
+
ageMs: 0
|
|
632
|
+
});
|
|
633
|
+
const ack = {
|
|
634
|
+
type: "P2P_ASK_ACK",
|
|
635
|
+
questionId: msg.questionId,
|
|
636
|
+
requestId: msg.requestId
|
|
637
|
+
};
|
|
638
|
+
ws.send(serializeP2PMsg(ack));
|
|
1703
639
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
640
|
+
handleGetAnswer(ws, msg) {
|
|
641
|
+
const question = this.incomingQuestions.get(msg.questionId);
|
|
642
|
+
if (!question?.answered) {
|
|
643
|
+
const pending = {
|
|
644
|
+
type: "P2P_ANSWER_PENDING",
|
|
645
|
+
questionId: msg.questionId,
|
|
646
|
+
requestId: msg.requestId
|
|
647
|
+
};
|
|
648
|
+
ws.send(serializeP2PMsg(pending));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const answer = {
|
|
652
|
+
type: "P2P_ANSWER",
|
|
653
|
+
questionId: msg.questionId,
|
|
654
|
+
content: question.answerContent,
|
|
655
|
+
format: question.answerFormat,
|
|
656
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
657
|
+
fromTeam: this.localMember.teamName,
|
|
658
|
+
fromMemberId: this.localMember.memberId,
|
|
659
|
+
requestId: msg.requestId
|
|
660
|
+
};
|
|
661
|
+
ws.send(serializeP2PMsg(answer));
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Private: peer connection management
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
async getPeerConnection(teamName) {
|
|
667
|
+
const existing = this.peerConns.get(teamName);
|
|
668
|
+
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
669
|
+
return existing;
|
|
670
|
+
}
|
|
671
|
+
let peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
672
|
+
if (!peer) {
|
|
673
|
+
this.mdnsDiscovery?.discover();
|
|
674
|
+
await this.waitForMdnsPeer(teamName, 1e4);
|
|
675
|
+
peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
676
|
+
}
|
|
677
|
+
if (!peer) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`Peer for team '${teamName}' not found via mDNS. Make sure the other terminal has joined with that team name.`
|
|
680
|
+
);
|
|
1707
681
|
}
|
|
682
|
+
return this.connectToPeer(teamName, peer.host, peer.port);
|
|
1708
683
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
684
|
+
async connectToPeer(teamName, host, port) {
|
|
685
|
+
const existing = this.peerConns.get(teamName);
|
|
686
|
+
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
687
|
+
return existing;
|
|
688
|
+
}
|
|
689
|
+
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
690
|
+
await new Promise((resolve, reject) => {
|
|
691
|
+
const timeout = setTimeout(
|
|
692
|
+
() => reject(new Error(`Connection timeout to team '${teamName}'`)),
|
|
693
|
+
5e3
|
|
694
|
+
);
|
|
695
|
+
ws.on("open", () => {
|
|
696
|
+
clearTimeout(timeout);
|
|
697
|
+
const hello = {
|
|
698
|
+
type: "P2P_HELLO",
|
|
699
|
+
fromTeam: this.localMember?.teamName ?? "unknown",
|
|
700
|
+
fromMemberId: this.localMember?.memberId ?? "unknown"
|
|
701
|
+
};
|
|
702
|
+
ws.send(serializeP2PMsg(hello));
|
|
703
|
+
resolve();
|
|
704
|
+
});
|
|
705
|
+
ws.on("error", (err) => {
|
|
706
|
+
clearTimeout(timeout);
|
|
707
|
+
reject(err);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
ws.on("message", (data) => {
|
|
711
|
+
try {
|
|
712
|
+
const msg = parseP2PMsg(data.toString());
|
|
713
|
+
this.handleMessage(ws, msg);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error("Failed to parse P2P message:", err);
|
|
1713
716
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
case "ANSWER":
|
|
1719
|
-
this.events.onAnswer?.(message);
|
|
1720
|
-
break;
|
|
1721
|
-
case "MEMBER_JOINED":
|
|
1722
|
-
this.events.onMemberJoined?.(message.member);
|
|
1723
|
-
break;
|
|
1724
|
-
case "MEMBER_LEFT":
|
|
1725
|
-
this.events.onMemberLeft?.(message.memberId, message.teamId);
|
|
1726
|
-
break;
|
|
1727
|
-
case "ERROR":
|
|
1728
|
-
this.events.onError?.(new Error(`${message.code}: ${message.message}`));
|
|
1729
|
-
break;
|
|
717
|
+
});
|
|
718
|
+
ws.on("close", () => {
|
|
719
|
+
if (this.peerConns.get(teamName) === ws) {
|
|
720
|
+
this.peerConns.delete(teamName);
|
|
1730
721
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
}
|
|
722
|
+
});
|
|
723
|
+
this.peerConns.set(teamName, ws);
|
|
724
|
+
return ws;
|
|
1735
725
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
726
|
+
waitForMdnsPeer(teamName, timeoutMs) {
|
|
727
|
+
return new Promise((resolve, reject) => {
|
|
728
|
+
const deadline = Date.now() + timeoutMs;
|
|
729
|
+
const check = () => {
|
|
730
|
+
if (this.mdnsDiscovery?.getPeerByTeam(teamName)) {
|
|
731
|
+
resolve();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (Date.now() >= deadline) {
|
|
735
|
+
reject(new Error(`mDNS timeout: team '${teamName}' not found`));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.mdnsDiscovery?.discover();
|
|
739
|
+
setTimeout(check, 500);
|
|
740
|
+
};
|
|
741
|
+
check();
|
|
742
|
+
});
|
|
1743
743
|
}
|
|
1744
744
|
waitForResponse(filter, timeoutMs) {
|
|
1745
745
|
return new Promise((resolve, reject) => {
|
|
1746
|
-
const requestId = v4();
|
|
1747
746
|
const timeout = setTimeout(() => {
|
|
1748
|
-
this.
|
|
1749
|
-
reject(new Error("
|
|
747
|
+
this.pendingHandlers.delete(handler);
|
|
748
|
+
reject(new Error("P2P request timed out"));
|
|
1750
749
|
}, timeoutMs);
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
},
|
|
1757
|
-
reject,
|
|
1758
|
-
timeout,
|
|
1759
|
-
filter
|
|
1760
|
-
};
|
|
1761
|
-
this.pendingRequests.set(requestId, pending);
|
|
1762
|
-
const originalHandler = this.handleMessage.bind(this);
|
|
1763
|
-
const checkFilter = (data) => {
|
|
1764
|
-
try {
|
|
1765
|
-
const message = parseHubMessage(data);
|
|
1766
|
-
if (filter(message)) {
|
|
1767
|
-
this.pendingRequests.delete(requestId);
|
|
1768
|
-
clearTimeout(timeout);
|
|
1769
|
-
resolve(message);
|
|
1770
|
-
}
|
|
1771
|
-
} catch {
|
|
750
|
+
const handler = (msg) => {
|
|
751
|
+
if (filter(msg)) {
|
|
752
|
+
clearTimeout(timeout);
|
|
753
|
+
this.pendingHandlers.delete(handler);
|
|
754
|
+
resolve(msg);
|
|
1772
755
|
}
|
|
1773
|
-
originalHandler(data);
|
|
1774
756
|
};
|
|
1775
|
-
|
|
1776
|
-
this.ws.removeAllListeners("message");
|
|
1777
|
-
this.ws.on("message", (data) => checkFilter(data.toString()));
|
|
1778
|
-
}
|
|
757
|
+
this.pendingHandlers.add(handler);
|
|
1779
758
|
});
|
|
1780
759
|
}
|
|
1781
|
-
handleDisconnect() {
|
|
1782
|
-
this.events.onDisconnected?.();
|
|
1783
|
-
if (this.isClosing) return;
|
|
1784
|
-
const shouldReconnect = this.options.reconnect ?? true;
|
|
1785
|
-
const maxAttempts = this.options.maxReconnectAttempts ?? config.autoStart.maxRetries;
|
|
1786
|
-
if (shouldReconnect && this.reconnectAttempts < maxAttempts) {
|
|
1787
|
-
this.reconnectAttempts++;
|
|
1788
|
-
const delay = this.options.reconnectDelay ?? config.autoStart.retryDelay;
|
|
1789
|
-
setTimeout(() => {
|
|
1790
|
-
this.connect().then(() => {
|
|
1791
|
-
if (this.teamName && this.displayName) {
|
|
1792
|
-
return this.join(this.teamName, this.displayName);
|
|
1793
|
-
}
|
|
1794
|
-
}).catch((error) => {
|
|
1795
|
-
this.events.onError?.(error);
|
|
1796
|
-
});
|
|
1797
|
-
}, delay);
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
pingInterval = null;
|
|
1801
|
-
startPingInterval() {
|
|
1802
|
-
this.pingInterval = setInterval(() => {
|
|
1803
|
-
this.send({ type: "PING" });
|
|
1804
|
-
}, config.hub.heartbeatInterval);
|
|
1805
|
-
}
|
|
1806
760
|
};
|
|
1807
761
|
var joinSchema = {
|
|
1808
762
|
team: z.string().describe('Team name to join (e.g., "frontend", "backend", "devops")'),
|
|
1809
763
|
displayName: z.string().optional().describe('Display name for this terminal (default: team + " Claude")')
|
|
1810
764
|
};
|
|
1811
|
-
function registerJoinTool(server,
|
|
765
|
+
function registerJoinTool(server, client) {
|
|
1812
766
|
server.tool("join", joinSchema, async (args) => {
|
|
1813
767
|
const teamName = args.team;
|
|
1814
768
|
const displayName = args.displayName ?? `${teamName} Claude`;
|
|
1815
769
|
try {
|
|
1816
|
-
|
|
1817
|
-
await hubClient.connect();
|
|
1818
|
-
}
|
|
1819
|
-
const member = await hubClient.join(teamName, displayName);
|
|
770
|
+
const member = await client.join(teamName, displayName);
|
|
1820
771
|
return {
|
|
1821
772
|
content: [
|
|
1822
773
|
{
|
|
@@ -1845,16 +796,14 @@ Status: ${member.status}`
|
|
|
1845
796
|
}
|
|
1846
797
|
var askSchema = {
|
|
1847
798
|
team: z.string().describe('Target team name to ask (e.g., "backend", "frontend")'),
|
|
1848
|
-
question: z.string().describe("The question to ask (supports markdown)")
|
|
1849
|
-
timeout: z.number().optional().describe(`Timeout in seconds to wait for answer (default: ${config.communication.defaultTimeout / 1e3}s)`)
|
|
799
|
+
question: z.string().describe("The question to ask (supports markdown)")
|
|
1850
800
|
};
|
|
1851
|
-
function registerAskTool(server,
|
|
801
|
+
function registerAskTool(server, client) {
|
|
1852
802
|
server.tool("ask", askSchema, async (args) => {
|
|
1853
803
|
const targetTeam = args.team;
|
|
1854
804
|
const question = args.question;
|
|
1855
|
-
const timeoutMs = (args.timeout ?? config.communication.defaultTimeout / 1e3) * 1e3;
|
|
1856
805
|
try {
|
|
1857
|
-
if (!
|
|
806
|
+
if (!client.currentTeamId) {
|
|
1858
807
|
return {
|
|
1859
808
|
content: [
|
|
1860
809
|
{
|
|
@@ -1865,25 +814,76 @@ function registerAskTool(server, hubClient) {
|
|
|
1865
814
|
isError: true
|
|
1866
815
|
};
|
|
1867
816
|
}
|
|
1868
|
-
const
|
|
817
|
+
const questionId = await client.ask(targetTeam, question, "markdown");
|
|
818
|
+
const POLL_INTERVAL_MS = 5e3;
|
|
819
|
+
const MAX_WAIT_MS = 5 * 60 * 1e3;
|
|
820
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
821
|
+
while (Date.now() < deadline) {
|
|
822
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
823
|
+
const answer = await client.checkAnswer(questionId);
|
|
824
|
+
if (answer !== null) {
|
|
825
|
+
return {
|
|
826
|
+
content: [
|
|
827
|
+
{
|
|
828
|
+
type: "text",
|
|
829
|
+
text: `**${answer.from.displayName} (${answer.from.teamName}) cevaplad\u0131:**
|
|
830
|
+
|
|
831
|
+
${answer.content}`
|
|
832
|
+
}
|
|
833
|
+
]
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
}
|
|
1869
837
|
return {
|
|
1870
838
|
content: [
|
|
1871
839
|
{
|
|
1872
840
|
type: "text",
|
|
1873
|
-
text:
|
|
841
|
+
text: `Soru g\xF6nderildi ancak 5 dakika i\xE7inde cevap gelmedi.
|
|
842
|
+
Question ID: \`${questionId}\`
|
|
1874
843
|
|
|
1875
|
-
|
|
844
|
+
Manuel kontrol i\xE7in "check_answer" tool'unu kullanabilirsin.`
|
|
1876
845
|
}
|
|
1877
846
|
]
|
|
1878
847
|
};
|
|
1879
848
|
} catch (error) {
|
|
1880
849
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1881
|
-
|
|
850
|
+
return {
|
|
851
|
+
content: [
|
|
852
|
+
{
|
|
853
|
+
type: "text",
|
|
854
|
+
text: `Failed to send question: ${errorMessage}`
|
|
855
|
+
}
|
|
856
|
+
],
|
|
857
|
+
isError: true
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
var checkAnswerSchema = {
|
|
863
|
+
question_id: z.string().describe('The question ID returned by the "ask" tool')
|
|
864
|
+
};
|
|
865
|
+
function registerCheckAnswerTool(server, client) {
|
|
866
|
+
server.tool("check_answer", checkAnswerSchema, async (args) => {
|
|
867
|
+
const questionId = args.question_id;
|
|
868
|
+
try {
|
|
869
|
+
if (!client.currentTeamId) {
|
|
870
|
+
return {
|
|
871
|
+
content: [
|
|
872
|
+
{
|
|
873
|
+
type: "text",
|
|
874
|
+
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
875
|
+
}
|
|
876
|
+
],
|
|
877
|
+
isError: true
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const answer = await client.checkAnswer(questionId);
|
|
881
|
+
if (!answer) {
|
|
1882
882
|
return {
|
|
1883
883
|
content: [
|
|
1884
884
|
{
|
|
1885
885
|
type: "text",
|
|
1886
|
-
text: `No
|
|
886
|
+
text: `No answer yet for question \`${questionId}\`. The other team hasn't replied yet. You can continue working and check again later.`
|
|
1887
887
|
}
|
|
1888
888
|
]
|
|
1889
889
|
};
|
|
@@ -1892,7 +892,19 @@ ${answer.content}`
|
|
|
1892
892
|
content: [
|
|
1893
893
|
{
|
|
1894
894
|
type: "text",
|
|
1895
|
-
text:
|
|
895
|
+
text: `**Answer from ${answer.from.displayName} (${answer.from.teamName}):**
|
|
896
|
+
|
|
897
|
+
${answer.content}`
|
|
898
|
+
}
|
|
899
|
+
]
|
|
900
|
+
};
|
|
901
|
+
} catch (error) {
|
|
902
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
903
|
+
return {
|
|
904
|
+
content: [
|
|
905
|
+
{
|
|
906
|
+
type: "text",
|
|
907
|
+
text: `Failed to check answer: ${errorMessage}`
|
|
1896
908
|
}
|
|
1897
909
|
],
|
|
1898
910
|
isError: true
|
|
@@ -1903,10 +915,10 @@ ${answer.content}`
|
|
|
1903
915
|
|
|
1904
916
|
// src/presentation/mcp/tools/inbox.tool.ts
|
|
1905
917
|
var inboxSchema = {};
|
|
1906
|
-
function registerInboxTool(server,
|
|
918
|
+
function registerInboxTool(server, client) {
|
|
1907
919
|
server.tool("inbox", inboxSchema, async () => {
|
|
1908
920
|
try {
|
|
1909
|
-
if (!
|
|
921
|
+
if (!client.currentTeamId) {
|
|
1910
922
|
return {
|
|
1911
923
|
content: [
|
|
1912
924
|
{
|
|
@@ -1917,7 +929,7 @@ function registerInboxTool(server, hubClient) {
|
|
|
1917
929
|
isError: true
|
|
1918
930
|
};
|
|
1919
931
|
}
|
|
1920
|
-
const inbox = await
|
|
932
|
+
const inbox = await client.getInbox();
|
|
1921
933
|
if (inbox.questions.length === 0) {
|
|
1922
934
|
return {
|
|
1923
935
|
content: [
|
|
@@ -1969,12 +981,12 @@ var replySchema = {
|
|
|
1969
981
|
questionId: z.string().describe("The ID of the question to reply to (from inbox)"),
|
|
1970
982
|
answer: z.string().describe("Your answer to the question (supports markdown)")
|
|
1971
983
|
};
|
|
1972
|
-
function registerReplyTool(server,
|
|
984
|
+
function registerReplyTool(server, client) {
|
|
1973
985
|
server.tool("reply", replySchema, async (args) => {
|
|
1974
986
|
const questionId = args.questionId;
|
|
1975
987
|
const answer = args.answer;
|
|
1976
988
|
try {
|
|
1977
|
-
if (!
|
|
989
|
+
if (!client.currentTeamId) {
|
|
1978
990
|
return {
|
|
1979
991
|
content: [
|
|
1980
992
|
{
|
|
@@ -1985,7 +997,8 @@ function registerReplyTool(server, hubClient) {
|
|
|
1985
997
|
isError: true
|
|
1986
998
|
};
|
|
1987
999
|
}
|
|
1988
|
-
await
|
|
1000
|
+
await client.reply(questionId, answer, "markdown");
|
|
1001
|
+
injectionQueue.notifyReplied();
|
|
1989
1002
|
return {
|
|
1990
1003
|
content: [
|
|
1991
1004
|
{
|
|
@@ -2011,7 +1024,7 @@ function registerReplyTool(server, hubClient) {
|
|
|
2011
1024
|
|
|
2012
1025
|
// src/presentation/mcp/server.ts
|
|
2013
1026
|
function createMcpServer(options) {
|
|
2014
|
-
const {
|
|
1027
|
+
const { client } = options;
|
|
2015
1028
|
const server = new McpServer(
|
|
2016
1029
|
{
|
|
2017
1030
|
name: "claude-collab",
|
|
@@ -2026,26 +1039,18 @@ function createMcpServer(options) {
|
|
|
2026
1039
|
}
|
|
2027
1040
|
}
|
|
2028
1041
|
);
|
|
2029
|
-
registerJoinTool(server,
|
|
2030
|
-
registerAskTool(server,
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
server
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
description: "Your inbox of pending questions from other teams",
|
|
2040
|
-
mimeType: "application/json"
|
|
2041
|
-
}
|
|
2042
|
-
]
|
|
2043
|
-
};
|
|
2044
|
-
});
|
|
2045
|
-
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2046
|
-
if (request.params.uri === "inbox://questions") {
|
|
1042
|
+
registerJoinTool(server, client);
|
|
1043
|
+
registerAskTool(server, client);
|
|
1044
|
+
registerCheckAnswerTool(server, client);
|
|
1045
|
+
registerInboxTool(server, client);
|
|
1046
|
+
registerReplyTool(server, client);
|
|
1047
|
+
server.resource(
|
|
1048
|
+
"inbox-questions",
|
|
1049
|
+
"inbox://questions",
|
|
1050
|
+
{ description: "Your inbox of pending questions from other teams", mimeType: "application/json" },
|
|
1051
|
+
async () => {
|
|
2047
1052
|
try {
|
|
2048
|
-
const inbox = await
|
|
1053
|
+
const inbox = await client.getInbox();
|
|
2049
1054
|
return {
|
|
2050
1055
|
contents: [
|
|
2051
1056
|
{
|
|
@@ -2060,147 +1065,33 @@ function createMcpServer(options) {
|
|
|
2060
1065
|
throw new Error(`Failed to read inbox: ${errorMessage}`);
|
|
2061
1066
|
}
|
|
2062
1067
|
}
|
|
2063
|
-
|
|
2064
|
-
});
|
|
1068
|
+
);
|
|
2065
1069
|
return server;
|
|
2066
1070
|
}
|
|
2067
1071
|
async function startMcpServer(options) {
|
|
2068
|
-
const { hubClient } = options;
|
|
2069
1072
|
const server = createMcpServer(options);
|
|
2070
1073
|
const transport = new StdioServerTransport();
|
|
2071
1074
|
await server.connect(transport);
|
|
2072
|
-
hubClient.events.onQuestion = async (question) => {
|
|
2073
|
-
await server.sendResourceUpdated({
|
|
2074
|
-
uri: "inbox://questions"
|
|
2075
|
-
});
|
|
2076
|
-
console.error(`[\u{1F4EC} New Question] From: ${question.from.displayName}`);
|
|
2077
|
-
console.error(`[\u{1F4A1} Tip] Check your inbox with: inbox()`);
|
|
2078
|
-
};
|
|
2079
|
-
}
|
|
2080
|
-
async function isHubRunning(host = config.hub.host, port = config.hub.port) {
|
|
2081
|
-
return new Promise((resolve) => {
|
|
2082
|
-
const socket = createConnection({ host, port }, () => {
|
|
2083
|
-
socket.end();
|
|
2084
|
-
resolve(true);
|
|
2085
|
-
});
|
|
2086
|
-
socket.on("error", () => {
|
|
2087
|
-
resolve(false);
|
|
2088
|
-
});
|
|
2089
|
-
socket.setTimeout(1e3, () => {
|
|
2090
|
-
socket.destroy();
|
|
2091
|
-
resolve(false);
|
|
2092
|
-
});
|
|
2093
|
-
});
|
|
2094
|
-
}
|
|
2095
|
-
async function waitForHub(host = config.hub.host, port = config.hub.port, maxRetries = config.autoStart.maxRetries, retryDelay = config.autoStart.retryDelay) {
|
|
2096
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
2097
|
-
if (await isHubRunning(host, port)) {
|
|
2098
|
-
return true;
|
|
2099
|
-
}
|
|
2100
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
2101
|
-
}
|
|
2102
|
-
return false;
|
|
2103
|
-
}
|
|
2104
|
-
function startHubProcess(options = {}) {
|
|
2105
|
-
const host = options.host ?? config.hub.host;
|
|
2106
|
-
const port = options.port ?? config.hub.port;
|
|
2107
|
-
const hubProcess = spawn(
|
|
2108
|
-
process.execPath,
|
|
2109
|
-
[
|
|
2110
|
-
"--experimental-specifier-resolution=node",
|
|
2111
|
-
new URL("../../hub-main.js", import.meta.url).pathname,
|
|
2112
|
-
"--host",
|
|
2113
|
-
host,
|
|
2114
|
-
"--port",
|
|
2115
|
-
port.toString()
|
|
2116
|
-
],
|
|
2117
|
-
{
|
|
2118
|
-
detached: true,
|
|
2119
|
-
stdio: "ignore"
|
|
2120
|
-
}
|
|
2121
|
-
);
|
|
2122
|
-
hubProcess.unref();
|
|
2123
|
-
return hubProcess;
|
|
2124
|
-
}
|
|
2125
|
-
async function ensureHubRunning(options = {}) {
|
|
2126
|
-
const host = options.host ?? config.hub.host;
|
|
2127
|
-
const port = options.port ?? config.hub.port;
|
|
2128
|
-
const maxRetries = options.maxRetries ?? config.autoStart.maxRetries;
|
|
2129
|
-
const retryDelay = options.retryDelay ?? config.autoStart.retryDelay;
|
|
2130
|
-
if (await isHubRunning(host, port)) {
|
|
2131
|
-
return true;
|
|
2132
|
-
}
|
|
2133
|
-
console.log(`Hub not running. Starting hub on ${host}:${port}...`);
|
|
2134
|
-
startHubProcess({ host, port });
|
|
2135
|
-
const isRunning = await waitForHub(host, port, maxRetries, retryDelay);
|
|
2136
|
-
if (isRunning) {
|
|
2137
|
-
console.log("Hub started successfully");
|
|
2138
|
-
} else {
|
|
2139
|
-
console.error("Failed to start hub");
|
|
2140
|
-
}
|
|
2141
|
-
return isRunning;
|
|
2142
1075
|
}
|
|
2143
1076
|
|
|
2144
1077
|
// src/cli.ts
|
|
2145
1078
|
var program = new Command();
|
|
2146
|
-
program.name("claude-collab").description("Real-time team collaboration between Claude Code terminals").version("0.1.0");
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
const port = parseInt(options.port, 10);
|
|
2150
|
-
const host = options.host;
|
|
2151
|
-
const server = new HubServer({ host, port });
|
|
2152
|
-
const shutdown = async () => {
|
|
2153
|
-
console.log("\nShutting down hub server...");
|
|
2154
|
-
await server.stop();
|
|
2155
|
-
process.exit(0);
|
|
2156
|
-
};
|
|
2157
|
-
process.on("SIGINT", shutdown);
|
|
2158
|
-
process.on("SIGTERM", shutdown);
|
|
1079
|
+
program.name("claude-collab").description("Real-time P2P team collaboration between Claude Code terminals").version("0.1.0");
|
|
1080
|
+
program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").option("-t, --team <team>", "Team to auto-join (e.g., frontend, backend)").action(async (options) => {
|
|
1081
|
+
const p2pNode = new P2PNode();
|
|
2159
1082
|
try {
|
|
2160
|
-
await
|
|
2161
|
-
|
|
1083
|
+
await p2pNode.start();
|
|
1084
|
+
if (options.team) {
|
|
1085
|
+
await p2pNode.join(options.team, `${options.team} Claude`);
|
|
1086
|
+
console.error(`Auto-joined team: ${options.team}`);
|
|
1087
|
+
}
|
|
2162
1088
|
} catch (error) {
|
|
2163
|
-
|
|
1089
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1090
|
+
console.error(`Failed to start P2P node: ${errorMessage}`);
|
|
2164
1091
|
process.exit(1);
|
|
2165
1092
|
}
|
|
1093
|
+
await startMcpServer({ client: p2pNode });
|
|
2166
1094
|
});
|
|
2167
|
-
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(
|
|
2168
|
-
async (options) => {
|
|
2169
|
-
const port = parseInt(options.port, 10);
|
|
2170
|
-
const host = options.host;
|
|
2171
|
-
if (options.autoHub) {
|
|
2172
|
-
const hubRunning = await ensureHubRunning({ host, port });
|
|
2173
|
-
if (!hubRunning) {
|
|
2174
|
-
console.error("Failed to start hub server. Exiting.");
|
|
2175
|
-
process.exit(1);
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
const hubClient = new HubClient(
|
|
2179
|
-
{ host, port, reconnect: true },
|
|
2180
|
-
{
|
|
2181
|
-
onError: (error) => {
|
|
2182
|
-
console.error("Hub client error:", error.message);
|
|
2183
|
-
},
|
|
2184
|
-
onQuestion: (question) => {
|
|
2185
|
-
console.error(`[Question received from ${question.from.displayName}]`);
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
);
|
|
2189
|
-
try {
|
|
2190
|
-
await hubClient.connect();
|
|
2191
|
-
if (options.team) {
|
|
2192
|
-
await hubClient.join(options.team, `${options.team} Claude`);
|
|
2193
|
-
console.error(`Auto-joined team: ${options.team}`);
|
|
2194
|
-
}
|
|
2195
|
-
} catch (error) {
|
|
2196
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2197
|
-
console.error(`Failed to connect to hub: ${errorMessage}`);
|
|
2198
|
-
console.error("Make sure the hub server is running or use --auto-hub flag.");
|
|
2199
|
-
process.exit(1);
|
|
2200
|
-
}
|
|
2201
|
-
await startMcpServer({ hubClient });
|
|
2202
|
-
}
|
|
2203
|
-
);
|
|
2204
1095
|
program.parse();
|
|
2205
1096
|
//# sourceMappingURL=cli.js.map
|
|
2206
1097
|
//# sourceMappingURL=cli.js.map
|