@dolusoft/claude-collab 0.1.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +785 -1886
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +808 -414
- 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,784 @@
|
|
|
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 { networkInterfaces, tmpdir } from 'os';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { execFile } from 'child_process';
|
|
10
|
+
import { unlinkSync } from 'fs';
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
_lastActivityAt;
|
|
20
|
-
constructor(props) {
|
|
21
|
-
this._id = props.id;
|
|
22
|
-
this._teamId = props.teamId;
|
|
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 */;
|
|
16
|
+
function getLocalIp() {
|
|
17
|
+
const nets = networkInterfaces();
|
|
18
|
+
for (const name of Object.keys(nets)) {
|
|
19
|
+
for (const net of nets[name] ?? []) {
|
|
20
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
21
|
+
return net.address;
|
|
22
|
+
}
|
|
94
23
|
}
|
|
95
24
|
}
|
|
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;
|
|
113
|
-
constructor() {
|
|
114
|
-
this.eventId = v4();
|
|
115
|
-
this.timestamp = /* @__PURE__ */ new Date();
|
|
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
|
-
};
|
|
127
|
-
}
|
|
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;
|
|
141
|
-
}
|
|
142
|
-
get payload() {
|
|
143
|
-
return {
|
|
144
|
-
memberId: this.memberId,
|
|
145
|
-
teamId: this.teamId,
|
|
146
|
-
displayName: this.displayName
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// src/shared/types/branded-types.ts
|
|
152
|
-
var MemberId = {
|
|
153
|
-
create: (id) => id,
|
|
154
|
-
isValid: (id) => id.length > 0
|
|
155
|
-
};
|
|
156
|
-
var TeamId = {
|
|
157
|
-
create: (id) => id.toLowerCase().trim(),
|
|
158
|
-
isValid: (id) => /^[a-z][a-z0-9-]*$/.test(id.toLowerCase().trim())
|
|
159
|
-
};
|
|
160
|
-
var QuestionId = {
|
|
161
|
-
create: (id) => id,
|
|
162
|
-
isValid: (id) => id.length > 0
|
|
163
|
-
};
|
|
164
|
-
var AnswerId = {
|
|
165
|
-
create: (id) => id,
|
|
166
|
-
isValid: (id) => id.length > 0
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// src/shared/utils/id-generator.ts
|
|
170
|
-
function generateMemberId() {
|
|
171
|
-
return MemberId.create(v4());
|
|
25
|
+
return "127.0.0.1";
|
|
172
26
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
27
|
+
var SERVICE_TYPE = "_claude-collab._tcp.local";
|
|
28
|
+
var MdnsDiscovery = class {
|
|
29
|
+
mdns;
|
|
30
|
+
announced = false;
|
|
31
|
+
port = 0;
|
|
32
|
+
teamName = "";
|
|
33
|
+
memberId = "";
|
|
34
|
+
peersByTeam = /* @__PURE__ */ new Map();
|
|
35
|
+
peersByMemberId = /* @__PURE__ */ new Map();
|
|
36
|
+
onPeerFoundCb;
|
|
37
|
+
onPeerLostCb;
|
|
38
|
+
constructor() {
|
|
39
|
+
this.mdns = multicastDns();
|
|
40
|
+
this.setupHandlers();
|
|
220
41
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// src/application/use-cases/join-team.use-case.ts
|
|
224
|
-
var JoinTeamUseCase = class {
|
|
225
|
-
constructor(deps) {
|
|
226
|
-
this.deps = deps;
|
|
42
|
+
get serviceName() {
|
|
43
|
+
return `${this.memberId}.${SERVICE_TYPE}`;
|
|
227
44
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
45
|
+
buildAnswers() {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
name: SERVICE_TYPE,
|
|
49
|
+
type: "PTR",
|
|
50
|
+
ttl: 300,
|
|
51
|
+
data: this.serviceName
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: this.serviceName,
|
|
55
|
+
type: "SRV",
|
|
56
|
+
ttl: 300,
|
|
57
|
+
data: {
|
|
58
|
+
priority: 0,
|
|
59
|
+
weight: 0,
|
|
60
|
+
port: this.port,
|
|
61
|
+
target: getLocalIp()
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: this.serviceName,
|
|
66
|
+
type: "TXT",
|
|
67
|
+
ttl: 300,
|
|
68
|
+
data: [
|
|
69
|
+
Buffer.from(`team=${this.teamName}`),
|
|
70
|
+
Buffer.from(`memberId=${this.memberId}`),
|
|
71
|
+
Buffer.from("ver=1")
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
setupHandlers() {
|
|
77
|
+
this.mdns.on("query", (query) => {
|
|
78
|
+
if (!this.announced) return;
|
|
79
|
+
const questions = query.questions ?? [];
|
|
80
|
+
const ptrQuery = questions.find(
|
|
81
|
+
(q) => q.type === "PTR" && q.name === SERVICE_TYPE
|
|
82
|
+
);
|
|
83
|
+
if (!ptrQuery) return;
|
|
84
|
+
this.mdns.respond({ answers: this.buildAnswers() });
|
|
246
85
|
});
|
|
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 */
|
|
86
|
+
this.mdns.on("response", (response) => {
|
|
87
|
+
this.parseResponse(response);
|
|
292
88
|
});
|
|
293
89
|
}
|
|
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`
|
|
90
|
+
parseResponse(response) {
|
|
91
|
+
const allRecords = [
|
|
92
|
+
...response.answers ?? [],
|
|
93
|
+
...response.additionals ?? []
|
|
94
|
+
];
|
|
95
|
+
const ptrRecords = allRecords.filter(
|
|
96
|
+
(r) => r.type === "PTR" && r.name === SERVICE_TYPE
|
|
97
|
+
);
|
|
98
|
+
for (const ptr of ptrRecords) {
|
|
99
|
+
const instanceName = ptr.data;
|
|
100
|
+
const srv = allRecords.find(
|
|
101
|
+
(r) => r.type === "SRV" && r.name === instanceName
|
|
464
102
|
);
|
|
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
|
|
103
|
+
const txt = allRecords.find(
|
|
104
|
+
(r) => r.type === "TXT" && r.name === instanceName
|
|
596
105
|
);
|
|
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
|
-
});
|
|
106
|
+
if (!srv) continue;
|
|
107
|
+
const port = srv.data.port;
|
|
108
|
+
const host = srv.data.target || "127.0.0.1";
|
|
109
|
+
let teamName = "";
|
|
110
|
+
let memberId = "";
|
|
111
|
+
if (txt) {
|
|
112
|
+
const txtData = txt.data ?? [];
|
|
113
|
+
for (const entry of txtData) {
|
|
114
|
+
const str = Buffer.isBuffer(entry) ? entry.toString() : String(entry);
|
|
115
|
+
if (str.startsWith("team=")) teamName = str.slice(5);
|
|
116
|
+
if (str.startsWith("memberId=")) memberId = str.slice(9);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!teamName || !memberId) continue;
|
|
120
|
+
if (memberId === this.memberId) continue;
|
|
121
|
+
const ptrTtl = ptr.ttl ?? 300;
|
|
122
|
+
const srvTtl = srv.ttl ?? 300;
|
|
123
|
+
if (ptrTtl === 0 || srvTtl === 0) {
|
|
124
|
+
this.peersByTeam.delete(teamName);
|
|
125
|
+
this.peersByMemberId.delete(memberId);
|
|
126
|
+
this.onPeerLostCb?.(memberId);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const peer = { host, port, teamName, memberId };
|
|
130
|
+
this.peersByTeam.set(teamName, peer);
|
|
131
|
+
this.peersByMemberId.set(memberId, peer);
|
|
132
|
+
this.onPeerFoundCb?.(peer);
|
|
642
133
|
}
|
|
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
134
|
}
|
|
669
135
|
/**
|
|
670
|
-
*
|
|
136
|
+
* Announce this node's service via mDNS.
|
|
137
|
+
* Sends an unsolicited response so existing peers notice immediately.
|
|
671
138
|
*/
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
static reconstitute(props) {
|
|
679
|
-
return new _Answer(props);
|
|
680
|
-
}
|
|
681
|
-
// Getters
|
|
682
|
-
get id() {
|
|
683
|
-
return this._id;
|
|
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;
|
|
139
|
+
announce(port, teamName, memberId) {
|
|
140
|
+
this.port = port;
|
|
141
|
+
this.teamName = teamName;
|
|
142
|
+
this.memberId = memberId;
|
|
143
|
+
this.announced = true;
|
|
144
|
+
this.mdns.respond({ answers: this.buildAnswers() });
|
|
738
145
|
}
|
|
739
146
|
/**
|
|
740
|
-
*
|
|
147
|
+
* Send a PTR query to discover existing peers.
|
|
741
148
|
*/
|
|
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()
|
|
149
|
+
discover() {
|
|
150
|
+
this.mdns.query({
|
|
151
|
+
questions: [{ name: SERVICE_TYPE, type: "PTR" }]
|
|
762
152
|
});
|
|
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
153
|
}
|
|
795
|
-
|
|
796
|
-
return
|
|
154
|
+
getPeerByTeam(teamName) {
|
|
155
|
+
return this.peersByTeam.get(teamName);
|
|
797
156
|
}
|
|
798
|
-
|
|
799
|
-
|
|
157
|
+
onPeerFound(cb) {
|
|
158
|
+
this.onPeerFoundCb = cb;
|
|
800
159
|
}
|
|
801
|
-
|
|
802
|
-
|
|
160
|
+
onPeerLost(cb) {
|
|
161
|
+
this.onPeerLostCb = cb;
|
|
803
162
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
async findAll() {
|
|
808
|
-
return [...this.members.values()];
|
|
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;
|
|
163
|
+
destroy() {
|
|
164
|
+
this.mdns.destroy();
|
|
821
165
|
}
|
|
822
166
|
};
|
|
823
167
|
|
|
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
|
-
};
|
|
168
|
+
// src/infrastructure/p2p/p2p-message-protocol.ts
|
|
169
|
+
function serializeP2PMsg(msg) {
|
|
170
|
+
return JSON.stringify(msg);
|
|
171
|
+
}
|
|
172
|
+
function parseP2PMsg(data) {
|
|
173
|
+
return JSON.parse(data);
|
|
174
|
+
}
|
|
175
|
+
var CS_CONINJECT = `
|
|
176
|
+
using System;
|
|
177
|
+
using System.Collections.Generic;
|
|
178
|
+
using System.Runtime.InteropServices;
|
|
918
179
|
|
|
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
|
-
};
|
|
180
|
+
public class ConInject {
|
|
181
|
+
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
|
|
182
|
+
[DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
|
|
183
|
+
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
|
|
184
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
|
|
185
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
186
|
+
public static extern IntPtr CreateFile(
|
|
187
|
+
string lpFileName, uint dwDesiredAccess, uint dwShareMode,
|
|
188
|
+
IntPtr lpSecurityAttributes, uint dwCreationDisposition,
|
|
189
|
+
uint dwFlagsAndAttributes, IntPtr hTemplateFile);
|
|
190
|
+
[DllImport("kernel32.dll")] public static extern bool WriteConsoleInput(
|
|
191
|
+
IntPtr hIn, INPUT_RECORD[] buf, uint len, out uint written);
|
|
192
|
+
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
|
971
193
|
|
|
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
|
-
}
|
|
194
|
+
[StructLayout(LayoutKind.Explicit, Size=20)]
|
|
195
|
+
public struct INPUT_RECORD {
|
|
196
|
+
[FieldOffset(0)] public ushort EventType;
|
|
197
|
+
[FieldOffset(4)] public int bKeyDown;
|
|
198
|
+
[FieldOffset(8)] public ushort wRepeatCount;
|
|
199
|
+
[FieldOffset(10)] public ushort wVirtualKeyCode;
|
|
200
|
+
[FieldOffset(12)] public ushort wVirtualScanCode;
|
|
201
|
+
[FieldOffset(14)] public ushort UnicodeChar;
|
|
202
|
+
[FieldOffset(16)] public uint dwControlKeyState;
|
|
1009
203
|
}
|
|
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
204
|
|
|
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
|
-
}
|
|
205
|
+
const uint LEFT_CTRL = 0x0008;
|
|
206
|
+
|
|
207
|
+
static IntPtr OpenConin(uint pid) {
|
|
208
|
+
FreeConsole();
|
|
209
|
+
if (!AttachConsole(pid)) return new IntPtr(-1);
|
|
210
|
+
return CreateFile("CONIN$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
|
|
1040
211
|
}
|
|
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
212
|
|
|
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
|
-
}
|
|
213
|
+
// Inject only text characters into console input buffer (no Ctrl keys, no Enter)
|
|
214
|
+
public static int InjectText(uint pid, string text) {
|
|
215
|
+
IntPtr hIn = OpenConin(pid);
|
|
216
|
+
if (hIn == new IntPtr(-1)) return -1;
|
|
1089
217
|
|
|
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();
|
|
218
|
+
var records = new List<INPUT_RECORD>();
|
|
219
|
+
foreach (char c in text) {
|
|
220
|
+
records.Add(new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, UnicodeChar=(ushort)c });
|
|
221
|
+
records.Add(new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, UnicodeChar=(ushort)c });
|
|
1203
222
|
}
|
|
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));
|
|
223
|
+
|
|
224
|
+
var arr = records.ToArray();
|
|
225
|
+
uint written;
|
|
226
|
+
bool ok = WriteConsoleInput(hIn, arr, (uint)arr.Length, out written);
|
|
227
|
+
CloseHandle(hIn);
|
|
228
|
+
return ok ? (int)written : -2;
|
|
1524
229
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
230
|
+
|
|
231
|
+
// Focus the console window for WScript.Shell.SendKeys
|
|
232
|
+
public static IntPtr FocusConsole(uint pid) {
|
|
233
|
+
FreeConsole();
|
|
234
|
+
if (!AttachConsole(pid)) return IntPtr.Zero;
|
|
235
|
+
IntPtr hwnd = GetConsoleWindow();
|
|
236
|
+
if (hwnd != IntPtr.Zero) SetForegroundWindow(hwnd);
|
|
237
|
+
return hwnd;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
`;
|
|
241
|
+
function buildScript(claudePid, body) {
|
|
242
|
+
const logFile = join(tmpdir(), `cc-inject-${Date.now()}.log`).replace(/\\/g, "/");
|
|
243
|
+
return `
|
|
244
|
+
$log = "${logFile}"
|
|
245
|
+
function Log($msg) { Add-Content -Path $log -Value $msg -Encoding UTF8 }
|
|
246
|
+
$claudePid = ${claudePid}
|
|
247
|
+
try { Add-Type @'${CS_CONINJECT}'@ } catch { }
|
|
248
|
+
${body}
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
function run(script) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
254
|
+
execFile(
|
|
255
|
+
"powershell",
|
|
256
|
+
["-NoProfile", "-WindowStyle", "Hidden", "-EncodedCommand", encoded],
|
|
257
|
+
{ windowsHide: true },
|
|
258
|
+
() => {
|
|
259
|
+
const logFile = script.match(/\$log = "([^"]+)"/)?.[1];
|
|
260
|
+
if (logFile) try {
|
|
261
|
+
unlinkSync(logFile);
|
|
262
|
+
} catch {
|
|
1533
263
|
}
|
|
264
|
+
resolve();
|
|
1534
265
|
}
|
|
1535
|
-
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function windowsInject(text) {
|
|
270
|
+
const claudePid = process.ppid;
|
|
271
|
+
const textB64 = Buffer.from(text, "utf16le").toString("base64");
|
|
272
|
+
const script = buildScript(claudePid, `
|
|
273
|
+
$textBytes = [System.Convert]::FromBase64String('${textB64}')
|
|
274
|
+
$text = [System.Text.Encoding]::Unicode.GetString($textBytes)
|
|
275
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
276
|
+
|
|
277
|
+
# 1. Focus console and send Ctrl+U to save user's current text to kill ring
|
|
278
|
+
$hwnd = [ConInject]::FocusConsole([uint32]$claudePid)
|
|
279
|
+
Start-Sleep -Milliseconds 150
|
|
280
|
+
$wsh.SendKeys('^u')
|
|
281
|
+
Start-Sleep -Milliseconds 150
|
|
282
|
+
|
|
283
|
+
# 2. Write question text into console input buffer
|
|
284
|
+
[ConInject]::InjectText([uint32]$claudePid, $text) | Out-Null
|
|
285
|
+
|
|
286
|
+
# 3. Re-focus (InjectText calls FreeConsole internally, focus may be lost)
|
|
287
|
+
[ConInject]::FocusConsole([uint32]$claudePid) | Out-Null
|
|
288
|
+
Start-Sleep -Milliseconds 150
|
|
289
|
+
|
|
290
|
+
# 4. Send Enter
|
|
291
|
+
$wsh.SendKeys('~')
|
|
292
|
+
`);
|
|
293
|
+
await run(script);
|
|
294
|
+
}
|
|
295
|
+
async function windowsInjectCtrlY() {
|
|
296
|
+
const claudePid = process.ppid;
|
|
297
|
+
const script = buildScript(claudePid, `
|
|
298
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
299
|
+
$hwnd = [ConInject]::FocusConsole([uint32]$claudePid)
|
|
300
|
+
Start-Sleep -Milliseconds 150
|
|
301
|
+
$wsh.SendKeys('^y')
|
|
302
|
+
`);
|
|
303
|
+
await run(script);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/infrastructure/terminal-injector/index.ts
|
|
307
|
+
function formatQuestionForInjection(question) {
|
|
308
|
+
const content = question.content.replace(/\s+/g, " ").trim();
|
|
309
|
+
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.`;
|
|
310
|
+
}
|
|
311
|
+
async function injectQuestionToTerminal(question) {
|
|
312
|
+
if (process.platform !== "win32") return;
|
|
313
|
+
const text = formatQuestionForInjection(question);
|
|
314
|
+
await windowsInject(text);
|
|
315
|
+
}
|
|
316
|
+
async function injectRestoreText() {
|
|
317
|
+
if (process.platform !== "win32") return;
|
|
318
|
+
await windowsInjectCtrlY();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/infrastructure/terminal-injector/injection-queue.ts
|
|
322
|
+
var REPLY_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
323
|
+
var InjectionQueue = class extends EventEmitter {
|
|
324
|
+
queue = [];
|
|
325
|
+
processing = false;
|
|
1542
326
|
/**
|
|
1543
|
-
*
|
|
327
|
+
* Add a question to the queue. Starts processing if idle.
|
|
1544
328
|
*/
|
|
1545
|
-
|
|
1546
|
-
|
|
329
|
+
enqueue(question) {
|
|
330
|
+
this.queue.push(question);
|
|
331
|
+
if (!this.processing) void this.processNext();
|
|
1547
332
|
}
|
|
1548
333
|
/**
|
|
1549
|
-
*
|
|
334
|
+
* Called by the reply tool after a reply is successfully sent.
|
|
335
|
+
* Unblocks the queue to process the next question.
|
|
1550
336
|
*/
|
|
1551
|
-
|
|
1552
|
-
|
|
337
|
+
notifyReplied() {
|
|
338
|
+
this.emit("replied");
|
|
1553
339
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
/**
|
|
1569
|
-
* Connects to the Hub server
|
|
1570
|
-
*/
|
|
1571
|
-
async connect() {
|
|
1572
|
-
const host = this.options.host ?? config.hub.host;
|
|
1573
|
-
const port = this.options.port ?? config.hub.port;
|
|
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
|
-
}
|
|
340
|
+
async processNext() {
|
|
341
|
+
if (this.queue.length === 0) {
|
|
342
|
+
this.processing = false;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.processing = true;
|
|
346
|
+
const question = this.queue.shift();
|
|
347
|
+
await injectQuestionToTerminal(question);
|
|
348
|
+
await new Promise((resolve) => {
|
|
349
|
+
const timer = setTimeout(resolve, REPLY_TIMEOUT_MS);
|
|
350
|
+
this.once("replied", () => {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
resolve();
|
|
353
|
+
});
|
|
1599
354
|
});
|
|
355
|
+
await injectRestoreText();
|
|
356
|
+
void this.processNext();
|
|
1600
357
|
}
|
|
358
|
+
};
|
|
359
|
+
var injectionQueue = new InjectionQueue();
|
|
360
|
+
|
|
361
|
+
// src/config/index.ts
|
|
362
|
+
var config = {
|
|
1601
363
|
/**
|
|
1602
|
-
*
|
|
364
|
+
* P2P node configuration
|
|
1603
365
|
*/
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
366
|
+
p2p: {
|
|
367
|
+
/**
|
|
368
|
+
* Minimum port for the random WS server port range
|
|
369
|
+
*/
|
|
370
|
+
portRangeMin: 1e4,
|
|
371
|
+
/**
|
|
372
|
+
* Maximum port for the random WS server port range
|
|
373
|
+
*/
|
|
374
|
+
portRangeMax: 19999
|
|
375
|
+
}};
|
|
376
|
+
|
|
377
|
+
// src/infrastructure/p2p/p2p-node.ts
|
|
378
|
+
function getRandomPort(min, max) {
|
|
379
|
+
return new Promise((resolve) => {
|
|
380
|
+
const port = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
381
|
+
const server = createServer();
|
|
382
|
+
server.listen(port, () => {
|
|
383
|
+
server.close(() => resolve(port));
|
|
384
|
+
});
|
|
385
|
+
server.on("error", () => {
|
|
386
|
+
resolve(getRandomPort(min, max));
|
|
1616
387
|
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
var P2PNode = class {
|
|
391
|
+
wss = null;
|
|
392
|
+
mdnsDiscovery = null;
|
|
393
|
+
port = 0;
|
|
394
|
+
// Connections indexed by remote team name
|
|
395
|
+
peerConns = /* @__PURE__ */ new Map();
|
|
396
|
+
// Reverse lookup: ws → teamName (for cleanup on incoming connections)
|
|
397
|
+
wsToTeam = /* @__PURE__ */ new Map();
|
|
398
|
+
// Questions we received from remote peers (our inbox)
|
|
399
|
+
incomingQuestions = /* @__PURE__ */ new Map();
|
|
400
|
+
// Answers we received for questions we asked
|
|
401
|
+
receivedAnswers = /* @__PURE__ */ new Map();
|
|
402
|
+
// Maps questionId → remote teamName (so we know who to poll)
|
|
403
|
+
questionToTeam = /* @__PURE__ */ new Map();
|
|
404
|
+
// Pending response handlers (request-response correlation by filter)
|
|
405
|
+
pendingHandlers = /* @__PURE__ */ new Set();
|
|
406
|
+
localMember = null;
|
|
407
|
+
_isStarted = false;
|
|
408
|
+
get isConnected() {
|
|
409
|
+
return this._isStarted;
|
|
410
|
+
}
|
|
411
|
+
get currentTeamId() {
|
|
412
|
+
return this.localMember?.teamName;
|
|
1617
413
|
}
|
|
1618
414
|
/**
|
|
1619
|
-
*
|
|
415
|
+
* Starts the WS server on a random port and initialises mDNS.
|
|
416
|
+
* Called automatically from join() if not yet started.
|
|
1620
417
|
*/
|
|
418
|
+
async start() {
|
|
419
|
+
this.port = await getRandomPort(config.p2p.portRangeMin, config.p2p.portRangeMax);
|
|
420
|
+
this.wss = new WebSocketServer({ port: this.port });
|
|
421
|
+
this.setupWssHandlers();
|
|
422
|
+
this.mdnsDiscovery = new MdnsDiscovery();
|
|
423
|
+
this._isStarted = true;
|
|
424
|
+
console.error(`P2P node started on port ${this.port}`);
|
|
425
|
+
}
|
|
1621
426
|
async join(teamName, displayName) {
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
427
|
+
if (!this._isStarted) {
|
|
428
|
+
await this.start();
|
|
429
|
+
}
|
|
430
|
+
const memberId = v4();
|
|
431
|
+
this.localMember = { memberId, teamName, displayName };
|
|
432
|
+
this.mdnsDiscovery.onPeerFound((peer) => {
|
|
433
|
+
if (peer.teamName !== teamName) {
|
|
434
|
+
console.error(`Discovered peer '${peer.teamName}' at ${peer.host}:${peer.port}`);
|
|
435
|
+
this.connectToPeer(peer.teamName, peer.host, peer.port).catch((err) => {
|
|
436
|
+
console.error(`Could not eagerly connect to ${peer.teamName}:`, err);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
1627
439
|
});
|
|
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;
|
|
440
|
+
this.mdnsDiscovery.announce(this.port, teamName, memberId);
|
|
441
|
+
this.mdnsDiscovery.discover();
|
|
442
|
+
return { memberId, teamId: teamName, teamName, displayName, status: "ONLINE" };
|
|
1637
443
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
async ask(toTeam, content, format = "markdown", timeoutMs = config.communication.defaultTimeout) {
|
|
444
|
+
async ask(toTeam, content, format) {
|
|
445
|
+
const ws = await this.getPeerConnection(toTeam);
|
|
446
|
+
const questionId = v4();
|
|
1642
447
|
const requestId = v4();
|
|
1643
|
-
this.
|
|
1644
|
-
|
|
448
|
+
this.questionToTeam.set(questionId, toTeam);
|
|
449
|
+
const ackPromise = this.waitForResponse(
|
|
450
|
+
(m) => m.type === "P2P_ASK_ACK" && m.requestId === requestId,
|
|
451
|
+
5e3
|
|
452
|
+
);
|
|
453
|
+
const msg = {
|
|
454
|
+
type: "P2P_ASK",
|
|
455
|
+
questionId,
|
|
456
|
+
fromMemberId: this.localMember.memberId,
|
|
457
|
+
fromTeam: this.localMember.teamName,
|
|
1645
458
|
toTeam,
|
|
1646
459
|
content,
|
|
1647
460
|
format,
|
|
1648
461
|
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;
|
|
462
|
+
};
|
|
463
|
+
ws.send(serializeP2PMsg(msg));
|
|
464
|
+
await ackPromise;
|
|
465
|
+
return questionId;
|
|
1660
466
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
467
|
+
async checkAnswer(questionId) {
|
|
468
|
+
const cached = this.receivedAnswers.get(questionId);
|
|
469
|
+
if (cached) {
|
|
470
|
+
return {
|
|
471
|
+
questionId,
|
|
472
|
+
from: { displayName: `${cached.fromTeam} Claude`, teamName: cached.fromTeam },
|
|
473
|
+
content: cached.content,
|
|
474
|
+
format: cached.format,
|
|
475
|
+
answeredAt: cached.answeredAt
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const toTeam = this.questionToTeam.get(questionId);
|
|
479
|
+
if (!toTeam) return null;
|
|
480
|
+
const ws = this.peerConns.get(toTeam);
|
|
481
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return null;
|
|
1665
482
|
const requestId = v4();
|
|
1666
|
-
this.
|
|
1667
|
-
type
|
|
1668
|
-
requestId
|
|
1669
|
-
});
|
|
1670
|
-
return this.waitForResponse(
|
|
1671
|
-
(msg) => msg.type === "INBOX" && msg.requestId === requestId,
|
|
483
|
+
const responsePromise = this.waitForResponse(
|
|
484
|
+
(m) => m.type === "P2P_ANSWER" && m.questionId === questionId || m.type === "P2P_ANSWER_PENDING" && m.requestId === requestId,
|
|
1672
485
|
5e3
|
|
1673
486
|
);
|
|
487
|
+
const getMsg = {
|
|
488
|
+
type: "P2P_GET_ANSWER",
|
|
489
|
+
questionId,
|
|
490
|
+
requestId
|
|
491
|
+
};
|
|
492
|
+
ws.send(serializeP2PMsg(getMsg));
|
|
493
|
+
const response = await responsePromise;
|
|
494
|
+
if (response.type === "P2P_ANSWER_PENDING") return null;
|
|
495
|
+
const answer = response;
|
|
496
|
+
this.receivedAnswers.set(questionId, {
|
|
497
|
+
content: answer.content,
|
|
498
|
+
format: answer.format,
|
|
499
|
+
answeredAt: answer.answeredAt,
|
|
500
|
+
fromTeam: answer.fromTeam,
|
|
501
|
+
fromMemberId: answer.fromMemberId
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
questionId,
|
|
505
|
+
from: { displayName: `${answer.fromTeam} Claude`, teamName: answer.fromTeam },
|
|
506
|
+
content: answer.content,
|
|
507
|
+
format: answer.format,
|
|
508
|
+
answeredAt: answer.answeredAt
|
|
509
|
+
};
|
|
1674
510
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
511
|
+
async reply(questionId, content, format) {
|
|
512
|
+
const question = this.incomingQuestions.get(questionId);
|
|
513
|
+
if (!question) throw new Error(`Question ${questionId} not found in inbox`);
|
|
514
|
+
question.answered = true;
|
|
515
|
+
question.answerContent = content;
|
|
516
|
+
question.answerFormat = format;
|
|
517
|
+
const answerMsg = {
|
|
518
|
+
type: "P2P_ANSWER",
|
|
1681
519
|
questionId,
|
|
1682
520
|
content,
|
|
1683
|
-
format
|
|
1684
|
-
|
|
521
|
+
format,
|
|
522
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
523
|
+
fromTeam: this.localMember.teamName,
|
|
524
|
+
fromMemberId: this.localMember.memberId
|
|
525
|
+
};
|
|
526
|
+
if (question.ws.readyState === WebSocket.OPEN) {
|
|
527
|
+
question.ws.send(serializeP2PMsg(answerMsg));
|
|
528
|
+
}
|
|
1685
529
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
530
|
+
async getInbox() {
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
const questions = [...this.incomingQuestions.values()].filter((q) => !q.answered).map((q) => ({
|
|
533
|
+
questionId: q.questionId,
|
|
534
|
+
from: { displayName: `${q.fromTeam} Claude`, teamName: q.fromTeam },
|
|
535
|
+
content: q.content,
|
|
536
|
+
format: q.format,
|
|
537
|
+
status: "PENDING",
|
|
538
|
+
createdAt: q.createdAt.toISOString(),
|
|
539
|
+
ageMs: now - q.createdAt.getTime()
|
|
540
|
+
}));
|
|
541
|
+
return {
|
|
542
|
+
questions,
|
|
543
|
+
totalCount: questions.length,
|
|
544
|
+
pendingCount: questions.length
|
|
545
|
+
};
|
|
1691
546
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
547
|
+
async disconnect() {
|
|
548
|
+
this.mdnsDiscovery?.destroy();
|
|
549
|
+
for (const ws of this.peerConns.values()) {
|
|
550
|
+
ws.close();
|
|
551
|
+
}
|
|
552
|
+
this.peerConns.clear();
|
|
553
|
+
await new Promise((resolve) => {
|
|
554
|
+
if (this.wss) {
|
|
555
|
+
this.wss.close(() => resolve());
|
|
556
|
+
} else {
|
|
557
|
+
resolve();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
this._isStarted = false;
|
|
561
|
+
}
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Private: WebSocket server setup
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
setupWssHandlers() {
|
|
566
|
+
this.wss.on("connection", (ws) => {
|
|
567
|
+
ws.on("message", (data) => {
|
|
568
|
+
try {
|
|
569
|
+
const msg = parseP2PMsg(data.toString());
|
|
570
|
+
this.handleMessage(ws, msg);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
console.error("Failed to parse incoming P2P message:", err);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
ws.on("close", () => {
|
|
576
|
+
const team = this.wsToTeam.get(ws);
|
|
577
|
+
if (team) {
|
|
578
|
+
if (this.peerConns.get(team) === ws) {
|
|
579
|
+
this.peerConns.delete(team);
|
|
580
|
+
}
|
|
581
|
+
this.wsToTeam.delete(ws);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
});
|
|
1697
585
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// Private: unified message handler (used for both incoming & outgoing sockets)
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
handleMessage(ws, msg) {
|
|
590
|
+
for (const handler of this.pendingHandlers) {
|
|
591
|
+
handler(msg);
|
|
592
|
+
}
|
|
593
|
+
switch (msg.type) {
|
|
594
|
+
case "P2P_HELLO":
|
|
595
|
+
this.wsToTeam.set(ws, msg.fromTeam);
|
|
596
|
+
this.peerConns.set(msg.fromTeam, ws);
|
|
597
|
+
console.error(`Peer identified: ${msg.fromTeam}`);
|
|
598
|
+
break;
|
|
599
|
+
case "P2P_ASK":
|
|
600
|
+
this.handleIncomingAsk(ws, msg);
|
|
601
|
+
break;
|
|
602
|
+
case "P2P_GET_ANSWER":
|
|
603
|
+
this.handleGetAnswer(ws, msg);
|
|
604
|
+
break;
|
|
605
|
+
case "P2P_ANSWER":
|
|
606
|
+
if (!this.receivedAnswers.has(msg.questionId)) {
|
|
607
|
+
this.receivedAnswers.set(msg.questionId, {
|
|
608
|
+
content: msg.content,
|
|
609
|
+
format: msg.format,
|
|
610
|
+
answeredAt: msg.answeredAt,
|
|
611
|
+
fromTeam: msg.fromTeam,
|
|
612
|
+
fromMemberId: msg.fromMemberId
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
case "P2P_PING":
|
|
617
|
+
ws.send(serializeP2PMsg({ type: "P2P_PONG" }));
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
handleIncomingAsk(ws, msg) {
|
|
622
|
+
this.incomingQuestions.set(msg.questionId, {
|
|
623
|
+
questionId: msg.questionId,
|
|
624
|
+
fromTeam: msg.fromTeam,
|
|
625
|
+
fromMemberId: msg.fromMemberId,
|
|
626
|
+
content: msg.content,
|
|
627
|
+
format: msg.format,
|
|
628
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
629
|
+
ws,
|
|
630
|
+
answered: false
|
|
631
|
+
});
|
|
632
|
+
injectionQueue.enqueue({
|
|
633
|
+
questionId: msg.questionId,
|
|
634
|
+
from: {
|
|
635
|
+
displayName: `${msg.fromTeam} Claude`,
|
|
636
|
+
teamName: msg.fromTeam
|
|
637
|
+
},
|
|
638
|
+
content: msg.content,
|
|
639
|
+
format: msg.format,
|
|
640
|
+
status: "PENDING",
|
|
641
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
642
|
+
ageMs: 0
|
|
643
|
+
});
|
|
644
|
+
const ack = {
|
|
645
|
+
type: "P2P_ASK_ACK",
|
|
646
|
+
questionId: msg.questionId,
|
|
647
|
+
requestId: msg.requestId
|
|
648
|
+
};
|
|
649
|
+
ws.send(serializeP2PMsg(ack));
|
|
1703
650
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
651
|
+
handleGetAnswer(ws, msg) {
|
|
652
|
+
const question = this.incomingQuestions.get(msg.questionId);
|
|
653
|
+
if (!question?.answered) {
|
|
654
|
+
const pending = {
|
|
655
|
+
type: "P2P_ANSWER_PENDING",
|
|
656
|
+
questionId: msg.questionId,
|
|
657
|
+
requestId: msg.requestId
|
|
658
|
+
};
|
|
659
|
+
ws.send(serializeP2PMsg(pending));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const answer = {
|
|
663
|
+
type: "P2P_ANSWER",
|
|
664
|
+
questionId: msg.questionId,
|
|
665
|
+
content: question.answerContent,
|
|
666
|
+
format: question.answerFormat,
|
|
667
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
668
|
+
fromTeam: this.localMember.teamName,
|
|
669
|
+
fromMemberId: this.localMember.memberId,
|
|
670
|
+
requestId: msg.requestId
|
|
671
|
+
};
|
|
672
|
+
ws.send(serializeP2PMsg(answer));
|
|
673
|
+
}
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// Private: peer connection management
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
async getPeerConnection(teamName) {
|
|
678
|
+
const existing = this.peerConns.get(teamName);
|
|
679
|
+
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
680
|
+
return existing;
|
|
681
|
+
}
|
|
682
|
+
let peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
683
|
+
if (!peer) {
|
|
684
|
+
this.mdnsDiscovery?.discover();
|
|
685
|
+
await this.waitForMdnsPeer(teamName, 1e4);
|
|
686
|
+
peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
687
|
+
}
|
|
688
|
+
if (!peer) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`Peer for team '${teamName}' not found via mDNS. Make sure the other terminal has joined with that team name.`
|
|
691
|
+
);
|
|
1707
692
|
}
|
|
693
|
+
return this.connectToPeer(teamName, peer.host, peer.port);
|
|
1708
694
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
695
|
+
async connectToPeer(teamName, host, port) {
|
|
696
|
+
const existing = this.peerConns.get(teamName);
|
|
697
|
+
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
698
|
+
return existing;
|
|
699
|
+
}
|
|
700
|
+
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
701
|
+
await new Promise((resolve, reject) => {
|
|
702
|
+
const timeout = setTimeout(
|
|
703
|
+
() => reject(new Error(`Connection timeout to team '${teamName}'`)),
|
|
704
|
+
5e3
|
|
705
|
+
);
|
|
706
|
+
ws.on("open", () => {
|
|
707
|
+
clearTimeout(timeout);
|
|
708
|
+
const hello = {
|
|
709
|
+
type: "P2P_HELLO",
|
|
710
|
+
fromTeam: this.localMember?.teamName ?? "unknown",
|
|
711
|
+
fromMemberId: this.localMember?.memberId ?? "unknown"
|
|
712
|
+
};
|
|
713
|
+
ws.send(serializeP2PMsg(hello));
|
|
714
|
+
resolve();
|
|
715
|
+
});
|
|
716
|
+
ws.on("error", (err) => {
|
|
717
|
+
clearTimeout(timeout);
|
|
718
|
+
reject(err);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
ws.on("message", (data) => {
|
|
722
|
+
try {
|
|
723
|
+
const msg = parseP2PMsg(data.toString());
|
|
724
|
+
this.handleMessage(ws, msg);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error("Failed to parse P2P message:", err);
|
|
1713
727
|
}
|
|
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;
|
|
728
|
+
});
|
|
729
|
+
ws.on("close", () => {
|
|
730
|
+
if (this.peerConns.get(teamName) === ws) {
|
|
731
|
+
this.peerConns.delete(teamName);
|
|
1730
732
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
}
|
|
733
|
+
});
|
|
734
|
+
this.peerConns.set(teamName, ws);
|
|
735
|
+
return ws;
|
|
1735
736
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
737
|
+
waitForMdnsPeer(teamName, timeoutMs) {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
const deadline = Date.now() + timeoutMs;
|
|
740
|
+
const check = () => {
|
|
741
|
+
if (this.mdnsDiscovery?.getPeerByTeam(teamName)) {
|
|
742
|
+
resolve();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (Date.now() >= deadline) {
|
|
746
|
+
reject(new Error(`mDNS timeout: team '${teamName}' not found`));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
this.mdnsDiscovery?.discover();
|
|
750
|
+
setTimeout(check, 500);
|
|
751
|
+
};
|
|
752
|
+
check();
|
|
753
|
+
});
|
|
1743
754
|
}
|
|
1744
755
|
waitForResponse(filter, timeoutMs) {
|
|
1745
756
|
return new Promise((resolve, reject) => {
|
|
1746
|
-
const requestId = v4();
|
|
1747
757
|
const timeout = setTimeout(() => {
|
|
1748
|
-
this.
|
|
1749
|
-
reject(new Error("
|
|
758
|
+
this.pendingHandlers.delete(handler);
|
|
759
|
+
reject(new Error("P2P request timed out"));
|
|
1750
760
|
}, 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 {
|
|
761
|
+
const handler = (msg) => {
|
|
762
|
+
if (filter(msg)) {
|
|
763
|
+
clearTimeout(timeout);
|
|
764
|
+
this.pendingHandlers.delete(handler);
|
|
765
|
+
resolve(msg);
|
|
1772
766
|
}
|
|
1773
|
-
originalHandler(data);
|
|
1774
767
|
};
|
|
1775
|
-
|
|
1776
|
-
this.ws.removeAllListeners("message");
|
|
1777
|
-
this.ws.on("message", (data) => checkFilter(data.toString()));
|
|
1778
|
-
}
|
|
768
|
+
this.pendingHandlers.add(handler);
|
|
1779
769
|
});
|
|
1780
770
|
}
|
|
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
771
|
};
|
|
1807
772
|
var joinSchema = {
|
|
1808
773
|
team: z.string().describe('Team name to join (e.g., "frontend", "backend", "devops")'),
|
|
1809
774
|
displayName: z.string().optional().describe('Display name for this terminal (default: team + " Claude")')
|
|
1810
775
|
};
|
|
1811
|
-
function registerJoinTool(server,
|
|
776
|
+
function registerJoinTool(server, client) {
|
|
1812
777
|
server.tool("join", joinSchema, async (args) => {
|
|
1813
778
|
const teamName = args.team;
|
|
1814
779
|
const displayName = args.displayName ?? `${teamName} Claude`;
|
|
1815
780
|
try {
|
|
1816
|
-
|
|
1817
|
-
await hubClient.connect();
|
|
1818
|
-
}
|
|
1819
|
-
const member = await hubClient.join(teamName, displayName);
|
|
781
|
+
const member = await client.join(teamName, displayName);
|
|
1820
782
|
return {
|
|
1821
783
|
content: [
|
|
1822
784
|
{
|
|
@@ -1845,16 +807,14 @@ Status: ${member.status}`
|
|
|
1845
807
|
}
|
|
1846
808
|
var askSchema = {
|
|
1847
809
|
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)`)
|
|
810
|
+
question: z.string().describe("The question to ask (supports markdown)")
|
|
1850
811
|
};
|
|
1851
|
-
function registerAskTool(server,
|
|
812
|
+
function registerAskTool(server, client) {
|
|
1852
813
|
server.tool("ask", askSchema, async (args) => {
|
|
1853
814
|
const targetTeam = args.team;
|
|
1854
815
|
const question = args.question;
|
|
1855
|
-
const timeoutMs = (args.timeout ?? config.communication.defaultTimeout / 1e3) * 1e3;
|
|
1856
816
|
try {
|
|
1857
|
-
if (!
|
|
817
|
+
if (!client.currentTeamId) {
|
|
1858
818
|
return {
|
|
1859
819
|
content: [
|
|
1860
820
|
{
|
|
@@ -1865,25 +825,76 @@ function registerAskTool(server, hubClient) {
|
|
|
1865
825
|
isError: true
|
|
1866
826
|
};
|
|
1867
827
|
}
|
|
1868
|
-
const
|
|
828
|
+
const questionId = await client.ask(targetTeam, question, "markdown");
|
|
829
|
+
const POLL_INTERVAL_MS = 5e3;
|
|
830
|
+
const MAX_WAIT_MS = 5 * 60 * 1e3;
|
|
831
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
832
|
+
while (Date.now() < deadline) {
|
|
833
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
834
|
+
const answer = await client.checkAnswer(questionId);
|
|
835
|
+
if (answer !== null) {
|
|
836
|
+
return {
|
|
837
|
+
content: [
|
|
838
|
+
{
|
|
839
|
+
type: "text",
|
|
840
|
+
text: `**${answer.from.displayName} (${answer.from.teamName}) cevaplad\u0131:**
|
|
841
|
+
|
|
842
|
+
${answer.content}`
|
|
843
|
+
}
|
|
844
|
+
]
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
1869
848
|
return {
|
|
1870
849
|
content: [
|
|
1871
850
|
{
|
|
1872
851
|
type: "text",
|
|
1873
|
-
text:
|
|
852
|
+
text: `Soru g\xF6nderildi ancak 5 dakika i\xE7inde cevap gelmedi.
|
|
853
|
+
Question ID: \`${questionId}\`
|
|
1874
854
|
|
|
1875
|
-
|
|
855
|
+
Manuel kontrol i\xE7in "check_answer" tool'unu kullanabilirsin.`
|
|
1876
856
|
}
|
|
1877
857
|
]
|
|
1878
858
|
};
|
|
1879
859
|
} catch (error) {
|
|
1880
860
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1881
|
-
|
|
861
|
+
return {
|
|
862
|
+
content: [
|
|
863
|
+
{
|
|
864
|
+
type: "text",
|
|
865
|
+
text: `Failed to send question: ${errorMessage}`
|
|
866
|
+
}
|
|
867
|
+
],
|
|
868
|
+
isError: true
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
var checkAnswerSchema = {
|
|
874
|
+
question_id: z.string().describe('The question ID returned by the "ask" tool')
|
|
875
|
+
};
|
|
876
|
+
function registerCheckAnswerTool(server, client) {
|
|
877
|
+
server.tool("check_answer", checkAnswerSchema, async (args) => {
|
|
878
|
+
const questionId = args.question_id;
|
|
879
|
+
try {
|
|
880
|
+
if (!client.currentTeamId) {
|
|
881
|
+
return {
|
|
882
|
+
content: [
|
|
883
|
+
{
|
|
884
|
+
type: "text",
|
|
885
|
+
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
886
|
+
}
|
|
887
|
+
],
|
|
888
|
+
isError: true
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const answer = await client.checkAnswer(questionId);
|
|
892
|
+
if (!answer) {
|
|
1882
893
|
return {
|
|
1883
894
|
content: [
|
|
1884
895
|
{
|
|
1885
896
|
type: "text",
|
|
1886
|
-
text: `No
|
|
897
|
+
text: `No answer yet for question \`${questionId}\`. The other team hasn't replied yet. You can continue working and check again later.`
|
|
1887
898
|
}
|
|
1888
899
|
]
|
|
1889
900
|
};
|
|
@@ -1892,7 +903,19 @@ ${answer.content}`
|
|
|
1892
903
|
content: [
|
|
1893
904
|
{
|
|
1894
905
|
type: "text",
|
|
1895
|
-
text:
|
|
906
|
+
text: `**Answer from ${answer.from.displayName} (${answer.from.teamName}):**
|
|
907
|
+
|
|
908
|
+
${answer.content}`
|
|
909
|
+
}
|
|
910
|
+
]
|
|
911
|
+
};
|
|
912
|
+
} catch (error) {
|
|
913
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
914
|
+
return {
|
|
915
|
+
content: [
|
|
916
|
+
{
|
|
917
|
+
type: "text",
|
|
918
|
+
text: `Failed to check answer: ${errorMessage}`
|
|
1896
919
|
}
|
|
1897
920
|
],
|
|
1898
921
|
isError: true
|
|
@@ -1903,10 +926,10 @@ ${answer.content}`
|
|
|
1903
926
|
|
|
1904
927
|
// src/presentation/mcp/tools/inbox.tool.ts
|
|
1905
928
|
var inboxSchema = {};
|
|
1906
|
-
function registerInboxTool(server,
|
|
929
|
+
function registerInboxTool(server, client) {
|
|
1907
930
|
server.tool("inbox", inboxSchema, async () => {
|
|
1908
931
|
try {
|
|
1909
|
-
if (!
|
|
932
|
+
if (!client.currentTeamId) {
|
|
1910
933
|
return {
|
|
1911
934
|
content: [
|
|
1912
935
|
{
|
|
@@ -1917,7 +940,7 @@ function registerInboxTool(server, hubClient) {
|
|
|
1917
940
|
isError: true
|
|
1918
941
|
};
|
|
1919
942
|
}
|
|
1920
|
-
const inbox = await
|
|
943
|
+
const inbox = await client.getInbox();
|
|
1921
944
|
if (inbox.questions.length === 0) {
|
|
1922
945
|
return {
|
|
1923
946
|
content: [
|
|
@@ -1969,12 +992,12 @@ var replySchema = {
|
|
|
1969
992
|
questionId: z.string().describe("The ID of the question to reply to (from inbox)"),
|
|
1970
993
|
answer: z.string().describe("Your answer to the question (supports markdown)")
|
|
1971
994
|
};
|
|
1972
|
-
function registerReplyTool(server,
|
|
995
|
+
function registerReplyTool(server, client) {
|
|
1973
996
|
server.tool("reply", replySchema, async (args) => {
|
|
1974
997
|
const questionId = args.questionId;
|
|
1975
998
|
const answer = args.answer;
|
|
1976
999
|
try {
|
|
1977
|
-
if (!
|
|
1000
|
+
if (!client.currentTeamId) {
|
|
1978
1001
|
return {
|
|
1979
1002
|
content: [
|
|
1980
1003
|
{
|
|
@@ -1985,7 +1008,8 @@ function registerReplyTool(server, hubClient) {
|
|
|
1985
1008
|
isError: true
|
|
1986
1009
|
};
|
|
1987
1010
|
}
|
|
1988
|
-
await
|
|
1011
|
+
await client.reply(questionId, answer, "markdown");
|
|
1012
|
+
injectionQueue.notifyReplied();
|
|
1989
1013
|
return {
|
|
1990
1014
|
content: [
|
|
1991
1015
|
{
|
|
@@ -2011,7 +1035,7 @@ function registerReplyTool(server, hubClient) {
|
|
|
2011
1035
|
|
|
2012
1036
|
// src/presentation/mcp/server.ts
|
|
2013
1037
|
function createMcpServer(options) {
|
|
2014
|
-
const {
|
|
1038
|
+
const { client } = options;
|
|
2015
1039
|
const server = new McpServer(
|
|
2016
1040
|
{
|
|
2017
1041
|
name: "claude-collab",
|
|
@@ -2026,26 +1050,18 @@ function createMcpServer(options) {
|
|
|
2026
1050
|
}
|
|
2027
1051
|
}
|
|
2028
1052
|
);
|
|
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") {
|
|
1053
|
+
registerJoinTool(server, client);
|
|
1054
|
+
registerAskTool(server, client);
|
|
1055
|
+
registerCheckAnswerTool(server, client);
|
|
1056
|
+
registerInboxTool(server, client);
|
|
1057
|
+
registerReplyTool(server, client);
|
|
1058
|
+
server.resource(
|
|
1059
|
+
"inbox-questions",
|
|
1060
|
+
"inbox://questions",
|
|
1061
|
+
{ description: "Your inbox of pending questions from other teams", mimeType: "application/json" },
|
|
1062
|
+
async () => {
|
|
2047
1063
|
try {
|
|
2048
|
-
const inbox = await
|
|
1064
|
+
const inbox = await client.getInbox();
|
|
2049
1065
|
return {
|
|
2050
1066
|
contents: [
|
|
2051
1067
|
{
|
|
@@ -2060,150 +1076,33 @@ function createMcpServer(options) {
|
|
|
2060
1076
|
throw new Error(`Failed to read inbox: ${errorMessage}`);
|
|
2061
1077
|
}
|
|
2062
1078
|
}
|
|
2063
|
-
|
|
2064
|
-
});
|
|
1079
|
+
);
|
|
2065
1080
|
return server;
|
|
2066
1081
|
}
|
|
2067
1082
|
async function startMcpServer(options) {
|
|
2068
|
-
const { hubClient } = options;
|
|
2069
1083
|
const server = createMcpServer(options);
|
|
2070
1084
|
const transport = new StdioServerTransport();
|
|
2071
1085
|
await server.connect(transport);
|
|
2072
|
-
hubClient.events.onQuestion = async (question) => {
|
|
2073
|
-
await server.notification({
|
|
2074
|
-
method: "notifications/resources/updated",
|
|
2075
|
-
params: {
|
|
2076
|
-
uri: "inbox://questions"
|
|
2077
|
-
}
|
|
2078
|
-
});
|
|
2079
|
-
console.error(`[\u{1F4EC} New Question] From: ${question.from.displayName}`);
|
|
2080
|
-
console.error(`[\u{1F4A1} Tip] Check your inbox with: inbox()`);
|
|
2081
|
-
};
|
|
2082
|
-
}
|
|
2083
|
-
async function isHubRunning(host = config.hub.host, port = config.hub.port) {
|
|
2084
|
-
return new Promise((resolve) => {
|
|
2085
|
-
const socket = createConnection({ host, port }, () => {
|
|
2086
|
-
socket.end();
|
|
2087
|
-
resolve(true);
|
|
2088
|
-
});
|
|
2089
|
-
socket.on("error", () => {
|
|
2090
|
-
resolve(false);
|
|
2091
|
-
});
|
|
2092
|
-
socket.setTimeout(1e3, () => {
|
|
2093
|
-
socket.destroy();
|
|
2094
|
-
resolve(false);
|
|
2095
|
-
});
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
async function waitForHub(host = config.hub.host, port = config.hub.port, maxRetries = config.autoStart.maxRetries, retryDelay = config.autoStart.retryDelay) {
|
|
2099
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
2100
|
-
if (await isHubRunning(host, port)) {
|
|
2101
|
-
return true;
|
|
2102
|
-
}
|
|
2103
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
2104
|
-
}
|
|
2105
|
-
return false;
|
|
2106
|
-
}
|
|
2107
|
-
function startHubProcess(options = {}) {
|
|
2108
|
-
const host = options.host ?? config.hub.host;
|
|
2109
|
-
const port = options.port ?? config.hub.port;
|
|
2110
|
-
const hubProcess = spawn(
|
|
2111
|
-
process.execPath,
|
|
2112
|
-
[
|
|
2113
|
-
"--experimental-specifier-resolution=node",
|
|
2114
|
-
new URL("../../hub-main.js", import.meta.url).pathname,
|
|
2115
|
-
"--host",
|
|
2116
|
-
host,
|
|
2117
|
-
"--port",
|
|
2118
|
-
port.toString()
|
|
2119
|
-
],
|
|
2120
|
-
{
|
|
2121
|
-
detached: true,
|
|
2122
|
-
stdio: "ignore"
|
|
2123
|
-
}
|
|
2124
|
-
);
|
|
2125
|
-
hubProcess.unref();
|
|
2126
|
-
return hubProcess;
|
|
2127
|
-
}
|
|
2128
|
-
async function ensureHubRunning(options = {}) {
|
|
2129
|
-
const host = options.host ?? config.hub.host;
|
|
2130
|
-
const port = options.port ?? config.hub.port;
|
|
2131
|
-
const maxRetries = options.maxRetries ?? config.autoStart.maxRetries;
|
|
2132
|
-
const retryDelay = options.retryDelay ?? config.autoStart.retryDelay;
|
|
2133
|
-
if (await isHubRunning(host, port)) {
|
|
2134
|
-
return true;
|
|
2135
|
-
}
|
|
2136
|
-
console.log(`Hub not running. Starting hub on ${host}:${port}...`);
|
|
2137
|
-
startHubProcess({ host, port });
|
|
2138
|
-
const isRunning = await waitForHub(host, port, maxRetries, retryDelay);
|
|
2139
|
-
if (isRunning) {
|
|
2140
|
-
console.log("Hub started successfully");
|
|
2141
|
-
} else {
|
|
2142
|
-
console.error("Failed to start hub");
|
|
2143
|
-
}
|
|
2144
|
-
return isRunning;
|
|
2145
1086
|
}
|
|
2146
1087
|
|
|
2147
1088
|
// src/cli.ts
|
|
2148
1089
|
var program = new Command();
|
|
2149
|
-
program.name("claude-collab").description("Real-time team collaboration between Claude Code terminals").version("0.1.0");
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
const port = parseInt(options.port, 10);
|
|
2153
|
-
const host = options.host;
|
|
2154
|
-
const server = new HubServer({ host, port });
|
|
2155
|
-
const shutdown = async () => {
|
|
2156
|
-
console.log("\nShutting down hub server...");
|
|
2157
|
-
await server.stop();
|
|
2158
|
-
process.exit(0);
|
|
2159
|
-
};
|
|
2160
|
-
process.on("SIGINT", shutdown);
|
|
2161
|
-
process.on("SIGTERM", shutdown);
|
|
1090
|
+
program.name("claude-collab").description("Real-time P2P team collaboration between Claude Code terminals").version("0.1.0");
|
|
1091
|
+
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) => {
|
|
1092
|
+
const p2pNode = new P2PNode();
|
|
2162
1093
|
try {
|
|
2163
|
-
await
|
|
2164
|
-
|
|
1094
|
+
await p2pNode.start();
|
|
1095
|
+
if (options.team) {
|
|
1096
|
+
await p2pNode.join(options.team, `${options.team} Claude`);
|
|
1097
|
+
console.error(`Auto-joined team: ${options.team}`);
|
|
1098
|
+
}
|
|
2165
1099
|
} catch (error) {
|
|
2166
|
-
|
|
1100
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1101
|
+
console.error(`Failed to start P2P node: ${errorMessage}`);
|
|
2167
1102
|
process.exit(1);
|
|
2168
1103
|
}
|
|
1104
|
+
await startMcpServer({ client: p2pNode });
|
|
2169
1105
|
});
|
|
2170
|
-
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(
|
|
2171
|
-
async (options) => {
|
|
2172
|
-
const port = parseInt(options.port, 10);
|
|
2173
|
-
const host = options.host;
|
|
2174
|
-
if (options.autoHub) {
|
|
2175
|
-
const hubRunning = await ensureHubRunning({ host, port });
|
|
2176
|
-
if (!hubRunning) {
|
|
2177
|
-
console.error("Failed to start hub server. Exiting.");
|
|
2178
|
-
process.exit(1);
|
|
2179
|
-
}
|
|
2180
|
-
}
|
|
2181
|
-
const hubClient = new HubClient(
|
|
2182
|
-
{ host, port, reconnect: true },
|
|
2183
|
-
{
|
|
2184
|
-
onError: (error) => {
|
|
2185
|
-
console.error("Hub client error:", error.message);
|
|
2186
|
-
},
|
|
2187
|
-
onQuestion: (question) => {
|
|
2188
|
-
console.error(`[Question received from ${question.from.displayName}]`);
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
);
|
|
2192
|
-
try {
|
|
2193
|
-
await hubClient.connect();
|
|
2194
|
-
if (options.team) {
|
|
2195
|
-
await hubClient.join(options.team, `${options.team} Claude`);
|
|
2196
|
-
console.error(`Auto-joined team: ${options.team}`);
|
|
2197
|
-
}
|
|
2198
|
-
} catch (error) {
|
|
2199
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2200
|
-
console.error(`Failed to connect to hub: ${errorMessage}`);
|
|
2201
|
-
console.error("Make sure the hub server is running or use --auto-hub flag.");
|
|
2202
|
-
process.exit(1);
|
|
2203
|
-
}
|
|
2204
|
-
await startMcpServer({ hubClient });
|
|
2205
|
-
}
|
|
2206
|
-
);
|
|
2207
1106
|
program.parse();
|
|
2208
1107
|
//# sourceMappingURL=cli.js.map
|
|
2209
1108
|
//# sourceMappingURL=cli.js.map
|