@dolusoft/claude-collab 1.8.6 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +293 -588
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +299 -577
- package/dist/mcp-main.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp-main.js
CHANGED
|
@@ -1,23 +1,82 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { v4 } from 'uuid';
|
|
4
|
+
import { createSocket } from 'dgram';
|
|
4
5
|
import { EventEmitter } from 'events';
|
|
5
6
|
import { execFile, spawn } from 'child_process';
|
|
6
7
|
import { unlinkSync } from 'fs';
|
|
7
8
|
import { tmpdir } from 'os';
|
|
8
9
|
import { join } from 'path';
|
|
9
|
-
import { createSocket } from 'dgram';
|
|
10
10
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
|
|
14
|
-
// src/infrastructure/
|
|
14
|
+
// src/infrastructure/p2p/p2p-protocol.ts
|
|
15
15
|
function serialize(msg) {
|
|
16
16
|
return JSON.stringify(msg);
|
|
17
17
|
}
|
|
18
18
|
function parse(data) {
|
|
19
19
|
return JSON.parse(data);
|
|
20
20
|
}
|
|
21
|
+
var PEER_DISCOVERY_PORT = 9998;
|
|
22
|
+
var BROADCAST_INTERVAL_MS = 3e3;
|
|
23
|
+
var BROADCAST_ADDRESS = "255.255.255.255";
|
|
24
|
+
var PeerBroadcaster = class {
|
|
25
|
+
socket = null;
|
|
26
|
+
timer = null;
|
|
27
|
+
start(name, port) {
|
|
28
|
+
if (this.socket) return;
|
|
29
|
+
const socket = createSocket("udp4");
|
|
30
|
+
this.socket = socket;
|
|
31
|
+
socket.on("error", (err) => {
|
|
32
|
+
console.error("[peer-broadcaster] error:", err.message);
|
|
33
|
+
});
|
|
34
|
+
socket.bind(0, () => {
|
|
35
|
+
socket.setBroadcast(true);
|
|
36
|
+
const send = () => {
|
|
37
|
+
if (!this.socket) return;
|
|
38
|
+
const msg = Buffer.from(JSON.stringify({ type: "claude-collab-peer", name, port }));
|
|
39
|
+
socket.send(msg, 0, msg.length, PEER_DISCOVERY_PORT, BROADCAST_ADDRESS, (err) => {
|
|
40
|
+
if (err) console.error("[peer-broadcaster] send error:", err.message);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
send();
|
|
44
|
+
this.timer = setInterval(send, BROADCAST_INTERVAL_MS);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
stop() {
|
|
48
|
+
if (this.timer) {
|
|
49
|
+
clearInterval(this.timer);
|
|
50
|
+
this.timer = null;
|
|
51
|
+
}
|
|
52
|
+
if (this.socket) {
|
|
53
|
+
this.socket.close();
|
|
54
|
+
this.socket = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function watchForPeer(onFound) {
|
|
59
|
+
const socket = createSocket("udp4");
|
|
60
|
+
socket.on("error", (err) => {
|
|
61
|
+
console.error("[peer-listener] error:", err.message);
|
|
62
|
+
});
|
|
63
|
+
socket.on("message", (msg, rinfo) => {
|
|
64
|
+
try {
|
|
65
|
+
const data = JSON.parse(msg.toString());
|
|
66
|
+
if (data.type === "claude-collab-peer" && typeof data.name === "string" && typeof data.port === "number") {
|
|
67
|
+
onFound({ name: data.name, host: rinfo.address, port: data.port });
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.bind(PEER_DISCOVERY_PORT, "0.0.0.0");
|
|
73
|
+
return () => {
|
|
74
|
+
try {
|
|
75
|
+
socket.close();
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
21
80
|
var CS_CONINJECT = `
|
|
22
81
|
using System;
|
|
23
82
|
using System.Collections.Generic;
|
|
@@ -236,93 +295,73 @@ var InjectionQueue = class extends EventEmitter {
|
|
|
236
295
|
};
|
|
237
296
|
var injectionQueue = new InjectionQueue();
|
|
238
297
|
|
|
239
|
-
// src/infrastructure/
|
|
240
|
-
var
|
|
241
|
-
|
|
298
|
+
// src/infrastructure/p2p/p2p-node.ts
|
|
299
|
+
var P2PNode = class {
|
|
300
|
+
constructor(port) {
|
|
301
|
+
this.port = port;
|
|
302
|
+
}
|
|
303
|
+
server = null;
|
|
242
304
|
myName = "";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
305
|
+
running = false;
|
|
306
|
+
// One connection per peer (inbound or outbound — whichever was established first)
|
|
307
|
+
peerConnections = /* @__PURE__ */ new Map();
|
|
308
|
+
// Reverse map: ws → peer name (only for registered connections)
|
|
309
|
+
wsToName = /* @__PURE__ */ new Map();
|
|
310
|
+
// Prevent duplicate outbound connect attempts
|
|
311
|
+
connectingPeers = /* @__PURE__ */ new Set();
|
|
246
312
|
incomingQuestions = /* @__PURE__ */ new Map();
|
|
247
313
|
receivedAnswers = /* @__PURE__ */ new Map();
|
|
248
|
-
questionToName = /* @__PURE__ */ new Map();
|
|
249
|
-
questionToSender = /* @__PURE__ */ new Map();
|
|
250
314
|
sentQuestions = /* @__PURE__ */ new Map();
|
|
315
|
+
questionToSender = /* @__PURE__ */ new Map();
|
|
251
316
|
pendingHandlers = /* @__PURE__ */ new Set();
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
317
|
+
broadcaster = null;
|
|
318
|
+
stopPeerWatcher = null;
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// ICollabClient implementation
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
255
322
|
get isConnected() {
|
|
256
|
-
return this.
|
|
323
|
+
return this.running;
|
|
257
324
|
}
|
|
258
325
|
get currentTeamId() {
|
|
259
326
|
return this.myName || void 0;
|
|
260
327
|
}
|
|
261
328
|
async join(name, displayName) {
|
|
262
329
|
this.myName = name;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
330
|
+
await this.startServer();
|
|
331
|
+
this.startDiscovery();
|
|
266
332
|
return {
|
|
267
333
|
memberId: v4(),
|
|
268
334
|
teamId: name,
|
|
269
335
|
teamName: name,
|
|
270
336
|
displayName,
|
|
271
337
|
status: "ONLINE",
|
|
272
|
-
port:
|
|
338
|
+
port: this.port
|
|
273
339
|
};
|
|
274
340
|
}
|
|
275
|
-
async connectToHub(url) {
|
|
276
|
-
this.serverUrl = url;
|
|
277
|
-
await this.connectAndHello();
|
|
278
|
-
}
|
|
279
341
|
async ask(toPeer, content, format) {
|
|
342
|
+
const ws = this.peerConnections.get(toPeer);
|
|
343
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
344
|
+
throw new Error(`Peer "${toPeer}" is not connected. Use peers() to see who's online.`);
|
|
345
|
+
}
|
|
280
346
|
const questionId = v4();
|
|
281
|
-
const requestId = v4();
|
|
282
|
-
this.questionToName.set(questionId, toPeer);
|
|
283
347
|
this.sentQuestions.set(questionId, { toPeer, content, askedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
284
348
|
const ackPromise = this.waitForResponse(
|
|
285
|
-
(m) => m.type === "ASK_ACK" && m.
|
|
349
|
+
(m) => m.type === "ASK_ACK" && m.questionId === questionId,
|
|
286
350
|
5e3
|
|
287
351
|
);
|
|
288
|
-
this.
|
|
352
|
+
this.sendToWs(ws, { type: "ASK", from: this.myName, questionId, content, format });
|
|
289
353
|
await ackPromise;
|
|
290
354
|
return questionId;
|
|
291
355
|
}
|
|
292
356
|
async checkAnswer(questionId) {
|
|
293
357
|
const cached = this.receivedAnswers.get(questionId);
|
|
294
|
-
if (cached)
|
|
295
|
-
return {
|
|
296
|
-
questionId,
|
|
297
|
-
from: { displayName: `${cached.fromName} Claude`, teamName: cached.fromName },
|
|
298
|
-
content: cached.content,
|
|
299
|
-
format: cached.format,
|
|
300
|
-
answeredAt: cached.answeredAt
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
const toPeer = this.questionToName.get(questionId);
|
|
304
|
-
if (!toPeer || !this.isConnected) return null;
|
|
305
|
-
const requestId = v4();
|
|
306
|
-
const responsePromise = this.waitForResponse(
|
|
307
|
-
(m) => m.type === "ANSWER" && m.questionId === questionId || m.type === "ANSWER_PENDING" && m.requestId === requestId,
|
|
308
|
-
5e3
|
|
309
|
-
);
|
|
310
|
-
this.sendToHub({ type: "GET_ANSWER", from: this.myName, to: toPeer, questionId, requestId });
|
|
311
|
-
const response = await responsePromise;
|
|
312
|
-
if (response.type === "ANSWER_PENDING") return null;
|
|
313
|
-
const answer = response;
|
|
314
|
-
this.receivedAnswers.set(questionId, {
|
|
315
|
-
content: answer.content,
|
|
316
|
-
format: answer.format,
|
|
317
|
-
answeredAt: answer.answeredAt,
|
|
318
|
-
fromName: answer.from
|
|
319
|
-
});
|
|
358
|
+
if (!cached) return null;
|
|
320
359
|
return {
|
|
321
360
|
questionId,
|
|
322
|
-
from: { displayName: `${
|
|
323
|
-
content:
|
|
324
|
-
format:
|
|
325
|
-
answeredAt:
|
|
361
|
+
from: { displayName: `${cached.fromName} Claude`, teamName: cached.fromName },
|
|
362
|
+
content: cached.content,
|
|
363
|
+
format: cached.format,
|
|
364
|
+
answeredAt: cached.answeredAt
|
|
326
365
|
};
|
|
327
366
|
}
|
|
328
367
|
async reply(questionId, content, format) {
|
|
@@ -333,15 +372,17 @@ var HubClient = class {
|
|
|
333
372
|
question.answerFormat = format;
|
|
334
373
|
const senderName = this.questionToSender.get(questionId);
|
|
335
374
|
if (senderName) {
|
|
336
|
-
this.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
375
|
+
const ws = this.peerConnections.get(senderName);
|
|
376
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
377
|
+
this.sendToWs(ws, {
|
|
378
|
+
type: "ANSWER",
|
|
379
|
+
from: this.myName,
|
|
380
|
+
questionId,
|
|
381
|
+
content,
|
|
382
|
+
format,
|
|
383
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
384
|
+
});
|
|
385
|
+
}
|
|
345
386
|
}
|
|
346
387
|
injectionQueue.notifyReplied();
|
|
347
388
|
}
|
|
@@ -361,8 +402,8 @@ var HubClient = class {
|
|
|
361
402
|
getInfo() {
|
|
362
403
|
return {
|
|
363
404
|
teamName: this.myName,
|
|
364
|
-
port:
|
|
365
|
-
connectedPeers: [...this.
|
|
405
|
+
port: this.port,
|
|
406
|
+
connectedPeers: [...this.peerConnections.keys()]
|
|
366
407
|
};
|
|
367
408
|
}
|
|
368
409
|
getHistory() {
|
|
@@ -374,107 +415,143 @@ var HubClient = class {
|
|
|
374
415
|
questionId,
|
|
375
416
|
peer: sent.toPeer,
|
|
376
417
|
question: sent.content,
|
|
377
|
-
answer: answer?.content,
|
|
378
418
|
askedAt: sent.askedAt,
|
|
379
|
-
answeredAt: answer
|
|
419
|
+
...answer ? { answer: answer.content, answeredAt: answer.answeredAt } : {}
|
|
380
420
|
});
|
|
381
421
|
}
|
|
382
|
-
for (const [
|
|
422
|
+
for (const [, incoming] of this.incomingQuestions) {
|
|
383
423
|
entries.push({
|
|
384
424
|
direction: "received",
|
|
385
|
-
questionId,
|
|
425
|
+
questionId: incoming.questionId,
|
|
386
426
|
peer: incoming.fromName,
|
|
387
427
|
question: incoming.content,
|
|
388
|
-
answer: incoming.answered ? incoming.answerContent : void 0,
|
|
389
428
|
askedAt: incoming.createdAt.toISOString(),
|
|
390
|
-
|
|
429
|
+
...incoming.answered && incoming.answerContent ? { answer: incoming.answerContent, answeredAt: (/* @__PURE__ */ new Date()).toISOString() } : {}
|
|
391
430
|
});
|
|
392
431
|
}
|
|
393
432
|
return entries.sort((a, b) => a.askedAt.localeCompare(b.askedAt));
|
|
394
433
|
}
|
|
395
434
|
async disconnect() {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
this.
|
|
401
|
-
this.
|
|
435
|
+
this.stopPeerWatcher?.();
|
|
436
|
+
this.broadcaster?.stop();
|
|
437
|
+
for (const ws of this.peerConnections.values()) ws.close();
|
|
438
|
+
this.peerConnections.clear();
|
|
439
|
+
this.wsToName.clear();
|
|
440
|
+
this.server?.close();
|
|
441
|
+
this.server = null;
|
|
442
|
+
this.running = false;
|
|
443
|
+
}
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Private: server startup
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
startServer() {
|
|
448
|
+
return new Promise((resolve, reject) => {
|
|
449
|
+
const wss = new WebSocketServer({ port: this.port });
|
|
450
|
+
this.server = wss;
|
|
451
|
+
wss.on("listening", () => {
|
|
452
|
+
this.running = true;
|
|
453
|
+
console.error(`[p2p] listening on port ${this.port} as "${this.myName}"`);
|
|
454
|
+
resolve();
|
|
455
|
+
});
|
|
456
|
+
wss.on("error", (err) => {
|
|
457
|
+
if (!this.running) reject(err);
|
|
458
|
+
else console.error("[p2p] server error:", err.message);
|
|
459
|
+
});
|
|
460
|
+
wss.on("connection", (ws) => {
|
|
461
|
+
ws.on("message", (data) => {
|
|
462
|
+
try {
|
|
463
|
+
this.handleMessage(ws, parse(data.toString()));
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
ws.on("close", () => {
|
|
468
|
+
const name = this.wsToName.get(ws);
|
|
469
|
+
if (name) {
|
|
470
|
+
this.wsToName.delete(ws);
|
|
471
|
+
if (this.peerConnections.get(name) === ws) {
|
|
472
|
+
this.peerConnections.delete(name);
|
|
473
|
+
console.error(`[p2p] peer disconnected (inbound): ${name}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
ws.on("error", (err) => {
|
|
478
|
+
console.error("[p2p] inbound ws error:", err.message);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
|
402
482
|
}
|
|
403
483
|
// ---------------------------------------------------------------------------
|
|
404
|
-
// Private:
|
|
484
|
+
// Private: discovery + outbound connections
|
|
405
485
|
// ---------------------------------------------------------------------------
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.
|
|
486
|
+
startDiscovery() {
|
|
487
|
+
this.broadcaster = new PeerBroadcaster();
|
|
488
|
+
this.broadcaster.start(this.myName, this.port);
|
|
489
|
+
this.stopPeerWatcher = watchForPeer((peer) => {
|
|
490
|
+
if (peer.name === this.myName) return;
|
|
491
|
+
if (this.peerConnections.has(peer.name)) return;
|
|
492
|
+
if (this.connectingPeers.has(peer.name)) return;
|
|
493
|
+
this.connectToPeer(peer.name, peer.host, peer.port);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
connectToPeer(peerName, host, port) {
|
|
497
|
+
this.connectingPeers.add(peerName);
|
|
498
|
+
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
499
|
+
ws.on("open", () => {
|
|
500
|
+
this.sendToWs(ws, { type: "HELLO", name: this.myName });
|
|
501
|
+
});
|
|
409
502
|
ws.on("message", (data) => {
|
|
410
503
|
try {
|
|
411
|
-
|
|
412
|
-
this.handleMessage(msg);
|
|
504
|
+
this.handleMessage(ws, parse(data.toString()));
|
|
413
505
|
} catch {
|
|
414
506
|
}
|
|
415
507
|
});
|
|
416
508
|
ws.on("close", () => {
|
|
417
|
-
this.
|
|
418
|
-
this.
|
|
509
|
+
this.connectingPeers.delete(peerName);
|
|
510
|
+
const name = this.wsToName.get(ws);
|
|
511
|
+
if (name) {
|
|
512
|
+
this.wsToName.delete(ws);
|
|
513
|
+
if (this.peerConnections.get(name) === ws) {
|
|
514
|
+
this.peerConnections.delete(name);
|
|
515
|
+
console.error(`[p2p] disconnected from peer: ${name}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
419
518
|
});
|
|
420
519
|
ws.on("error", (err) => {
|
|
421
|
-
console.error(
|
|
422
|
-
|
|
423
|
-
await new Promise((resolve, reject) => {
|
|
424
|
-
const timeout = setTimeout(
|
|
425
|
-
() => reject(new Error(`Cannot connect to hub at ${this.serverUrl}`)),
|
|
426
|
-
1e4
|
|
427
|
-
);
|
|
428
|
-
ws.on("open", () => {
|
|
429
|
-
clearTimeout(timeout);
|
|
430
|
-
ws.send(serialize({ type: "HELLO", name: this.myName }));
|
|
431
|
-
resolve();
|
|
432
|
-
});
|
|
433
|
-
ws.on("error", (err) => {
|
|
434
|
-
clearTimeout(timeout);
|
|
435
|
-
reject(err);
|
|
436
|
-
});
|
|
520
|
+
console.error(`[p2p] connect to "${peerName}" failed: ${err.message}`);
|
|
521
|
+
this.connectingPeers.delete(peerName);
|
|
437
522
|
});
|
|
438
|
-
const ack = await this.waitForResponse(
|
|
439
|
-
(m) => m.type === "HELLO_ACK",
|
|
440
|
-
1e4
|
|
441
|
-
);
|
|
442
|
-
for (const peer of ack.peers) this.connectedPeers.add(peer);
|
|
443
|
-
console.error(`[hub-client] connected as "${this.myName}", peers: [${ack.peers.join(", ")}]`);
|
|
444
|
-
}
|
|
445
|
-
scheduleReconnect() {
|
|
446
|
-
if (this.reconnectTimer) return;
|
|
447
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
448
|
-
this.reconnectTimer = null;
|
|
449
|
-
try {
|
|
450
|
-
await this.connectAndHello();
|
|
451
|
-
console.error("[hub-client] reconnected to hub");
|
|
452
|
-
} catch (err) {
|
|
453
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
454
|
-
console.error(`[hub-client] reconnect failed: ${msg}, retrying in 5s...`);
|
|
455
|
-
this.scheduleReconnect();
|
|
456
|
-
}
|
|
457
|
-
}, 5e3);
|
|
458
523
|
}
|
|
459
524
|
// ---------------------------------------------------------------------------
|
|
460
525
|
// Private: message handling
|
|
461
526
|
// ---------------------------------------------------------------------------
|
|
462
|
-
handleMessage(msg) {
|
|
527
|
+
handleMessage(ws, msg) {
|
|
463
528
|
for (const handler of this.pendingHandlers) handler(msg);
|
|
464
529
|
switch (msg.type) {
|
|
465
|
-
case "
|
|
466
|
-
this.
|
|
467
|
-
|
|
530
|
+
case "HELLO": {
|
|
531
|
+
if (this.peerConnections.has(msg.name)) {
|
|
532
|
+
ws.terminate();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.peerConnections.set(msg.name, ws);
|
|
536
|
+
this.wsToName.set(ws, msg.name);
|
|
537
|
+
this.connectingPeers.delete(msg.name);
|
|
538
|
+
this.sendToWs(ws, { type: "HELLO_ACK", name: this.myName });
|
|
539
|
+
console.error(`[p2p] peer joined (inbound): ${msg.name}`);
|
|
468
540
|
break;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
541
|
+
}
|
|
542
|
+
case "HELLO_ACK": {
|
|
543
|
+
if (this.peerConnections.has(msg.name)) {
|
|
544
|
+
ws.terminate();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.peerConnections.set(msg.name, ws);
|
|
548
|
+
this.wsToName.set(ws, msg.name);
|
|
549
|
+
this.connectingPeers.delete(msg.name);
|
|
550
|
+
console.error(`[p2p] connected to peer: ${msg.name}`);
|
|
472
551
|
break;
|
|
552
|
+
}
|
|
473
553
|
case "ASK":
|
|
474
|
-
this.handleIncomingAsk(msg);
|
|
475
|
-
break;
|
|
476
|
-
case "GET_ANSWER":
|
|
477
|
-
this.handleGetAnswer(msg);
|
|
554
|
+
this.handleIncomingAsk(ws, msg);
|
|
478
555
|
break;
|
|
479
556
|
case "ANSWER":
|
|
480
557
|
if (!this.receivedAnswers.has(msg.questionId)) {
|
|
@@ -486,12 +563,9 @@ var HubClient = class {
|
|
|
486
563
|
});
|
|
487
564
|
}
|
|
488
565
|
break;
|
|
489
|
-
case "HUB_ERROR":
|
|
490
|
-
console.error(`[hub-client] hub error: ${msg.message}`);
|
|
491
|
-
break;
|
|
492
566
|
}
|
|
493
567
|
}
|
|
494
|
-
handleIncomingAsk(msg) {
|
|
568
|
+
handleIncomingAsk(ws, msg) {
|
|
495
569
|
this.questionToSender.set(msg.questionId, msg.from);
|
|
496
570
|
this.incomingQuestions.set(msg.questionId, {
|
|
497
571
|
questionId: msg.questionId,
|
|
@@ -501,6 +575,7 @@ var HubClient = class {
|
|
|
501
575
|
createdAt: /* @__PURE__ */ new Date(),
|
|
502
576
|
answered: false
|
|
503
577
|
});
|
|
578
|
+
this.sendToWs(ws, { type: "ASK_ACK", questionId: msg.questionId });
|
|
504
579
|
injectionQueue.enqueue({
|
|
505
580
|
questionId: msg.questionId,
|
|
506
581
|
from: { displayName: `${msg.from} Claude`, teamName: msg.from },
|
|
@@ -511,39 +586,16 @@ var HubClient = class {
|
|
|
511
586
|
ageMs: 0
|
|
512
587
|
});
|
|
513
588
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
this.sendToHub({
|
|
518
|
-
type: "ANSWER_PENDING",
|
|
519
|
-
to: msg.from,
|
|
520
|
-
questionId: msg.questionId,
|
|
521
|
-
requestId: msg.requestId
|
|
522
|
-
});
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
this.sendToHub({
|
|
526
|
-
type: "ANSWER",
|
|
527
|
-
from: this.myName,
|
|
528
|
-
to: msg.from,
|
|
529
|
-
questionId: msg.questionId,
|
|
530
|
-
content: question.answerContent,
|
|
531
|
-
format: question.answerFormat,
|
|
532
|
-
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
533
|
-
requestId: msg.requestId
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
sendToHub(msg) {
|
|
537
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
538
|
-
throw new Error("Not connected to hub. Will retry automatically.");
|
|
589
|
+
sendToWs(ws, msg) {
|
|
590
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
591
|
+
ws.send(serialize(msg));
|
|
539
592
|
}
|
|
540
|
-
this.ws.send(serialize(msg));
|
|
541
593
|
}
|
|
542
594
|
waitForResponse(filter, timeoutMs) {
|
|
543
595
|
return new Promise((resolve, reject) => {
|
|
544
596
|
const timeout = setTimeout(() => {
|
|
545
597
|
this.pendingHandlers.delete(handler);
|
|
546
|
-
reject(new Error("
|
|
598
|
+
reject(new Error("Request timed out"));
|
|
547
599
|
}, timeoutMs);
|
|
548
600
|
const handler = (msg) => {
|
|
549
601
|
if (filter(msg)) {
|
|
@@ -556,239 +608,6 @@ var HubClient = class {
|
|
|
556
608
|
});
|
|
557
609
|
}
|
|
558
610
|
};
|
|
559
|
-
var HubServer = class {
|
|
560
|
-
wss = null;
|
|
561
|
-
clients = /* @__PURE__ */ new Map();
|
|
562
|
-
// name → ws
|
|
563
|
-
wsToName = /* @__PURE__ */ new Map();
|
|
564
|
-
// ws → name
|
|
565
|
-
start(port) {
|
|
566
|
-
this.wss = new WebSocketServer({ port });
|
|
567
|
-
this.wss.on("listening", () => {
|
|
568
|
-
console.log(`claude-collab hub running on port ${port}`);
|
|
569
|
-
console.log("Waiting for peers...\n");
|
|
570
|
-
});
|
|
571
|
-
this.wss.on("connection", (ws) => {
|
|
572
|
-
ws.on("message", (data) => {
|
|
573
|
-
try {
|
|
574
|
-
const msg = parse(data.toString());
|
|
575
|
-
this.handleMessage(ws, msg);
|
|
576
|
-
} catch {
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
ws.on("close", () => {
|
|
580
|
-
const name = this.wsToName.get(ws);
|
|
581
|
-
if (name) {
|
|
582
|
-
this.clients.delete(name);
|
|
583
|
-
this.wsToName.delete(ws);
|
|
584
|
-
this.broadcast({ type: "PEER_LEFT", name });
|
|
585
|
-
console.log(`\u2190 ${name} left (${this.clients.size} online: ${[...this.clients.keys()].join(", ") || "none"})`);
|
|
586
|
-
}
|
|
587
|
-
});
|
|
588
|
-
ws.on("error", (err) => {
|
|
589
|
-
console.error("[hub] ws error:", err.message);
|
|
590
|
-
});
|
|
591
|
-
});
|
|
592
|
-
this.wss.on("error", (err) => {
|
|
593
|
-
console.error("[hub] server error:", err.message);
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
stop() {
|
|
597
|
-
if (!this.wss) return;
|
|
598
|
-
for (const ws of this.clients.values()) ws.terminate();
|
|
599
|
-
this.clients.clear();
|
|
600
|
-
this.wsToName.clear();
|
|
601
|
-
this.wss.close();
|
|
602
|
-
this.wss = null;
|
|
603
|
-
console.log("claude-collab hub stopped");
|
|
604
|
-
}
|
|
605
|
-
handleMessage(ws, msg) {
|
|
606
|
-
switch (msg.type) {
|
|
607
|
-
case "HELLO": {
|
|
608
|
-
const existing = this.clients.get(msg.name);
|
|
609
|
-
const isReconnect = existing != null && existing !== ws;
|
|
610
|
-
if (isReconnect) {
|
|
611
|
-
this.wsToName.delete(existing);
|
|
612
|
-
existing.close();
|
|
613
|
-
}
|
|
614
|
-
this.clients.set(msg.name, ws);
|
|
615
|
-
this.wsToName.set(ws, msg.name);
|
|
616
|
-
const peers = [...this.clients.keys()].filter((n) => n !== msg.name);
|
|
617
|
-
const ack = { type: "HELLO_ACK", peers };
|
|
618
|
-
this.send(ws, ack);
|
|
619
|
-
if (!isReconnect) {
|
|
620
|
-
this.broadcast({ type: "PEER_JOINED", name: msg.name }, ws);
|
|
621
|
-
}
|
|
622
|
-
console.log(`\u2192 ${msg.name} joined (${this.clients.size} online: ${[...this.clients.keys()].join(", ")})`);
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
625
|
-
case "ASK": {
|
|
626
|
-
const target = this.clients.get(msg.to);
|
|
627
|
-
if (!target) {
|
|
628
|
-
const err = {
|
|
629
|
-
type: "HUB_ERROR",
|
|
630
|
-
code: "PEER_NOT_FOUND",
|
|
631
|
-
message: `'${msg.to}' is not connected to the hub`
|
|
632
|
-
};
|
|
633
|
-
this.send(ws, err);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
this.send(target, msg);
|
|
637
|
-
const ack = { type: "ASK_ACK", questionId: msg.questionId, requestId: msg.requestId };
|
|
638
|
-
this.send(ws, ack);
|
|
639
|
-
break;
|
|
640
|
-
}
|
|
641
|
-
case "GET_ANSWER": {
|
|
642
|
-
const target = this.clients.get(msg.to);
|
|
643
|
-
if (!target) {
|
|
644
|
-
this.send(ws, { type: "ANSWER_PENDING", to: msg.from, questionId: msg.questionId, requestId: msg.requestId });
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
this.send(target, msg);
|
|
648
|
-
break;
|
|
649
|
-
}
|
|
650
|
-
case "ANSWER":
|
|
651
|
-
case "ANSWER_PENDING": {
|
|
652
|
-
const target = this.clients.get(msg.to);
|
|
653
|
-
if (target) this.send(target, msg);
|
|
654
|
-
break;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
send(ws, msg) {
|
|
659
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
660
|
-
ws.send(serialize(msg));
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
broadcast(msg, except) {
|
|
664
|
-
for (const ws of this.clients.values()) {
|
|
665
|
-
if (ws !== except) this.send(ws, msg);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
};
|
|
669
|
-
var DISCOVERY_PORT = 9998;
|
|
670
|
-
var BROADCAST_INTERVAL_MS = 3e3;
|
|
671
|
-
var BROADCAST_ADDRESS = "255.255.255.255";
|
|
672
|
-
var HubBroadcaster = class {
|
|
673
|
-
socket = null;
|
|
674
|
-
timer = null;
|
|
675
|
-
start(hubPort) {
|
|
676
|
-
if (this.socket) return;
|
|
677
|
-
const socket = createSocket("udp4");
|
|
678
|
-
this.socket = socket;
|
|
679
|
-
socket.on("error", (err) => {
|
|
680
|
-
console.error("[hub-broadcaster] error:", err.message);
|
|
681
|
-
});
|
|
682
|
-
socket.bind(0, () => {
|
|
683
|
-
socket.setBroadcast(true);
|
|
684
|
-
const send = () => {
|
|
685
|
-
if (!this.socket) return;
|
|
686
|
-
const msg = Buffer.from(JSON.stringify({ type: "claude-collab-hub", port: hubPort }));
|
|
687
|
-
socket.send(msg, 0, msg.length, DISCOVERY_PORT, BROADCAST_ADDRESS, (err) => {
|
|
688
|
-
if (err) console.error("[hub-broadcaster] send error:", err.message);
|
|
689
|
-
});
|
|
690
|
-
};
|
|
691
|
-
send();
|
|
692
|
-
this.timer = setInterval(send, BROADCAST_INTERVAL_MS);
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
stop() {
|
|
696
|
-
if (this.timer) {
|
|
697
|
-
clearInterval(this.timer);
|
|
698
|
-
this.timer = null;
|
|
699
|
-
}
|
|
700
|
-
if (this.socket) {
|
|
701
|
-
this.socket.close();
|
|
702
|
-
this.socket = null;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
// src/infrastructure/hub/hub-manager.ts
|
|
708
|
-
var HubManager = class {
|
|
709
|
-
hubServer = null;
|
|
710
|
-
broadcaster = null;
|
|
711
|
-
currentPort = null;
|
|
712
|
-
get isRunning() {
|
|
713
|
-
return this.hubServer !== null;
|
|
714
|
-
}
|
|
715
|
-
get port() {
|
|
716
|
-
return this.currentPort;
|
|
717
|
-
}
|
|
718
|
-
async start(port) {
|
|
719
|
-
if (this.isRunning) throw new Error("Hub is already running");
|
|
720
|
-
const server = new HubServer();
|
|
721
|
-
server.start(port);
|
|
722
|
-
this.hubServer = server;
|
|
723
|
-
this.currentPort = port;
|
|
724
|
-
const broadcaster = new HubBroadcaster();
|
|
725
|
-
broadcaster.start(port);
|
|
726
|
-
this.broadcaster = broadcaster;
|
|
727
|
-
let firewallAdded = false;
|
|
728
|
-
try {
|
|
729
|
-
await addFirewallRule(port);
|
|
730
|
-
firewallAdded = true;
|
|
731
|
-
} catch (err) {
|
|
732
|
-
console.error("[hub-manager] firewall rule failed:", err);
|
|
733
|
-
}
|
|
734
|
-
return { firewallAdded };
|
|
735
|
-
}
|
|
736
|
-
async stop() {
|
|
737
|
-
if (!this.isRunning) throw new Error("Hub is not running");
|
|
738
|
-
if (this.broadcaster) {
|
|
739
|
-
this.broadcaster.stop();
|
|
740
|
-
this.broadcaster = null;
|
|
741
|
-
}
|
|
742
|
-
const port = this.currentPort;
|
|
743
|
-
this.hubServer.stop();
|
|
744
|
-
this.hubServer = null;
|
|
745
|
-
this.currentPort = null;
|
|
746
|
-
let firewallRemoved = false;
|
|
747
|
-
try {
|
|
748
|
-
await removeFirewallRule(port);
|
|
749
|
-
firewallRemoved = true;
|
|
750
|
-
} catch (err) {
|
|
751
|
-
console.error("[hub-manager] firewall rule removal failed:", err);
|
|
752
|
-
}
|
|
753
|
-
return { firewallRemoved };
|
|
754
|
-
}
|
|
755
|
-
};
|
|
756
|
-
function runElevated(argArray) {
|
|
757
|
-
const argList = argArray.map((a) => `"${a}"`).join(",");
|
|
758
|
-
const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
|
|
759
|
-
return new Promise((resolve, reject) => {
|
|
760
|
-
const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
|
|
761
|
-
ps.on("close", (code) => {
|
|
762
|
-
if (code === 0) resolve();
|
|
763
|
-
else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
|
|
764
|
-
});
|
|
765
|
-
ps.on("error", (err) => {
|
|
766
|
-
reject(new Error(`Failed to launch PowerShell: ${err.message}`));
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
async function addFirewallRule(port) {
|
|
771
|
-
await runElevated([
|
|
772
|
-
"advfirewall",
|
|
773
|
-
"firewall",
|
|
774
|
-
"add",
|
|
775
|
-
"rule",
|
|
776
|
-
`name=claude-collab-${port}`,
|
|
777
|
-
"protocol=TCP",
|
|
778
|
-
"dir=in",
|
|
779
|
-
`localport=${port}`,
|
|
780
|
-
"action=allow"
|
|
781
|
-
]);
|
|
782
|
-
}
|
|
783
|
-
async function removeFirewallRule(port) {
|
|
784
|
-
await runElevated([
|
|
785
|
-
"advfirewall",
|
|
786
|
-
"firewall",
|
|
787
|
-
"delete",
|
|
788
|
-
"rule",
|
|
789
|
-
`name=claude-collab-${port}`
|
|
790
|
-
]);
|
|
791
|
-
}
|
|
792
611
|
var askSchema = {
|
|
793
612
|
peer: z.string().describe('Name of the peer to ask (e.g., "alice", "backend")'),
|
|
794
613
|
question: z.string().describe("The question to ask (supports markdown)")
|
|
@@ -904,12 +723,14 @@ function registerPeersTool(server, client) {
|
|
|
904
723
|
server.tool("peers", {}, async () => {
|
|
905
724
|
const info = client.getInfo();
|
|
906
725
|
const myName = info.teamName ?? "(starting...)";
|
|
726
|
+
const myPort = info.port ?? "?";
|
|
907
727
|
const connected = info.connectedPeers;
|
|
908
728
|
if (connected.length === 0) {
|
|
909
729
|
return {
|
|
910
730
|
content: [{
|
|
911
731
|
type: "text",
|
|
912
|
-
text: `You are "${myName}". No peers connected yet
|
|
732
|
+
text: `You are "${myName}" (listening on port ${myPort}). No peers connected yet.
|
|
733
|
+
Use firewall_open to allow inbound connections, or wait for peers to connect to you.`
|
|
913
734
|
}]
|
|
914
735
|
};
|
|
915
736
|
}
|
|
@@ -917,7 +738,7 @@ function registerPeersTool(server, client) {
|
|
|
917
738
|
return {
|
|
918
739
|
content: [{
|
|
919
740
|
type: "text",
|
|
920
|
-
text: `You are "${myName}". Connected peers (${connected.length}):
|
|
741
|
+
text: `You are "${myName}" (port ${myPort}). Connected peers (${connected.length}):
|
|
921
742
|
${list}`
|
|
922
743
|
}]
|
|
923
744
|
};
|
|
@@ -950,174 +771,108 @@ ${answerLine}`;
|
|
|
950
771
|
};
|
|
951
772
|
});
|
|
952
773
|
}
|
|
953
|
-
function
|
|
774
|
+
function runElevated(argArray) {
|
|
775
|
+
const argList = argArray.map((a) => `"${a}"`).join(",");
|
|
776
|
+
const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
|
|
777
|
+
return new Promise((resolve, reject) => {
|
|
778
|
+
const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
|
|
779
|
+
ps.on("close", (code) => {
|
|
780
|
+
if (code === 0) resolve();
|
|
781
|
+
else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
|
|
782
|
+
});
|
|
783
|
+
ps.on("error", (err) => {
|
|
784
|
+
reject(new Error(`Failed to launch PowerShell: ${err.message}`));
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
async function addFirewallRule(port) {
|
|
789
|
+
await runElevated([
|
|
790
|
+
"advfirewall",
|
|
791
|
+
"firewall",
|
|
792
|
+
"add",
|
|
793
|
+
"rule",
|
|
794
|
+
`name=claude-collab-${port}`,
|
|
795
|
+
"protocol=TCP",
|
|
796
|
+
"dir=in",
|
|
797
|
+
`localport=${port}`,
|
|
798
|
+
"action=allow"
|
|
799
|
+
]);
|
|
800
|
+
}
|
|
801
|
+
async function removeFirewallRule(port) {
|
|
802
|
+
await runElevated([
|
|
803
|
+
"advfirewall",
|
|
804
|
+
"firewall",
|
|
805
|
+
"delete",
|
|
806
|
+
"rule",
|
|
807
|
+
`name=claude-collab-${port}`
|
|
808
|
+
]);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/presentation/mcp/tools/firewall-open.tool.ts
|
|
812
|
+
function registerFirewallOpenTool(server, client) {
|
|
954
813
|
server.tool(
|
|
955
|
-
"
|
|
956
|
-
"
|
|
957
|
-
{
|
|
958
|
-
|
|
959
|
-
|
|
814
|
+
"firewall_open",
|
|
815
|
+
"Open a Windows Firewall inbound rule for your P2P listen port. A UAC popup will appear \u2014 accept it to allow peers to connect to you directly.",
|
|
816
|
+
{
|
|
817
|
+
port: z.number().min(1024).max(65535).optional().describe("Port to open (defaults to your current listen port)")
|
|
818
|
+
},
|
|
819
|
+
async ({ port }) => {
|
|
820
|
+
const targetPort = port ?? client.getInfo().port;
|
|
821
|
+
if (!targetPort) {
|
|
960
822
|
return {
|
|
961
|
-
content: [{
|
|
962
|
-
|
|
963
|
-
text: `Hub is already running on port ${hubManager.port}.`
|
|
964
|
-
}]
|
|
823
|
+
content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
|
|
824
|
+
isError: true
|
|
965
825
|
};
|
|
966
826
|
}
|
|
967
|
-
let firewallAdded = false;
|
|
968
827
|
try {
|
|
969
|
-
|
|
970
|
-
firewallAdded = result.firewallAdded;
|
|
971
|
-
} catch (err) {
|
|
972
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
973
|
-
return {
|
|
974
|
-
content: [{ type: "text", text: `Failed to start hub: ${msg}` }]
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
try {
|
|
978
|
-
await client.connectToHub(`ws://localhost:${port}`);
|
|
979
|
-
} catch (err) {
|
|
980
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
981
|
-
return {
|
|
982
|
-
content: [{ type: "text", text: `Hub started on port ${port}, but failed to self-connect: ${msg}` }]
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
const lines = [
|
|
986
|
-
`Hub started on port ${port}.`,
|
|
987
|
-
firewallAdded ? `Firewall rule added (claude-collab-${port}) \u2014 LAN peers can connect.` : `WARNING: Firewall rule could not be added (UAC was cancelled or denied). Peers on other machines may be blocked by Windows Firewall. Run start_hub again and accept the UAC prompt to fix this.`,
|
|
988
|
-
`Others on the LAN will auto-discover and connect via mDNS.`,
|
|
989
|
-
`Use stop_hub when you are done.`
|
|
990
|
-
];
|
|
991
|
-
return {
|
|
992
|
-
content: [{ type: "text", text: lines.join("\n") }]
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
// src/presentation/mcp/tools/stop-hub.tool.ts
|
|
999
|
-
function registerStopHubTool(server, hubManager) {
|
|
1000
|
-
server.tool(
|
|
1001
|
-
"stop_hub",
|
|
1002
|
-
"Stop the running hub server. Removes the firewall rule (UAC prompt) and stops LAN advertising. Connected peers will be disconnected.",
|
|
1003
|
-
{},
|
|
1004
|
-
async () => {
|
|
1005
|
-
if (!hubManager.isRunning) {
|
|
828
|
+
await addFirewallRule(targetPort);
|
|
1006
829
|
return {
|
|
1007
830
|
content: [{
|
|
1008
831
|
type: "text",
|
|
1009
|
-
text:
|
|
832
|
+
text: [
|
|
833
|
+
`Firewall rule opened for port ${targetPort} (rule name: claude-collab-${targetPort}).`,
|
|
834
|
+
`Peers on the LAN can now connect to you directly.`
|
|
835
|
+
].join("\n")
|
|
1010
836
|
}]
|
|
1011
837
|
};
|
|
1012
|
-
}
|
|
1013
|
-
const port = hubManager.port;
|
|
1014
|
-
let firewallRemoved = false;
|
|
1015
|
-
try {
|
|
1016
|
-
const result = await hubManager.stop();
|
|
1017
|
-
firewallRemoved = result.firewallRemoved;
|
|
1018
838
|
} catch (err) {
|
|
1019
839
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1020
840
|
return {
|
|
1021
|
-
content: [{ type: "text", text: `Failed to
|
|
841
|
+
content: [{ type: "text", text: `Failed to open firewall: ${msg}` }],
|
|
842
|
+
isError: true
|
|
1022
843
|
};
|
|
1023
844
|
}
|
|
1024
|
-
const lines = [
|
|
1025
|
-
`Hub stopped (was on port ${port}).`,
|
|
1026
|
-
firewallRemoved ? `Firewall rule removed (claude-collab-${port}).` : `WARNING: Firewall rule could not be removed (UAC was cancelled). Remove it manually: netsh advfirewall firewall delete rule name="claude-collab-${port}"`,
|
|
1027
|
-
`All peers have been disconnected.`
|
|
1028
|
-
];
|
|
1029
|
-
return {
|
|
1030
|
-
content: [{ type: "text", text: lines.join("\n") }]
|
|
1031
|
-
};
|
|
1032
845
|
}
|
|
1033
846
|
);
|
|
1034
847
|
}
|
|
1035
|
-
function
|
|
1036
|
-
return new Promise((resolve) => {
|
|
1037
|
-
const socket = createSocket("udp4");
|
|
1038
|
-
let settled = false;
|
|
1039
|
-
const finish = (result) => {
|
|
1040
|
-
if (settled) return;
|
|
1041
|
-
settled = true;
|
|
1042
|
-
clearTimeout(timer);
|
|
1043
|
-
try {
|
|
1044
|
-
socket.close();
|
|
1045
|
-
} catch {
|
|
1046
|
-
}
|
|
1047
|
-
resolve(result);
|
|
1048
|
-
};
|
|
1049
|
-
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
1050
|
-
socket.on("error", () => finish(null));
|
|
1051
|
-
socket.on("message", (msg, rinfo) => {
|
|
1052
|
-
try {
|
|
1053
|
-
const data = JSON.parse(msg.toString());
|
|
1054
|
-
if (data.type === "claude-collab-hub" && typeof data.port === "number") {
|
|
1055
|
-
finish({ host: rinfo.address, port: data.port });
|
|
1056
|
-
}
|
|
1057
|
-
} catch {
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
socket.bind(DISCOVERY_PORT, "0.0.0.0");
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
function watchForHub(onFound) {
|
|
1064
|
-
const socket = createSocket("udp4");
|
|
1065
|
-
socket.on("error", (err) => {
|
|
1066
|
-
console.error("[hub-listener] error:", err.message);
|
|
1067
|
-
});
|
|
1068
|
-
socket.on("message", (msg, rinfo) => {
|
|
1069
|
-
try {
|
|
1070
|
-
const data = JSON.parse(msg.toString());
|
|
1071
|
-
if (data.type === "claude-collab-hub" && typeof data.port === "number") {
|
|
1072
|
-
onFound({ host: rinfo.address, port: data.port });
|
|
1073
|
-
}
|
|
1074
|
-
} catch {
|
|
1075
|
-
}
|
|
1076
|
-
});
|
|
1077
|
-
socket.bind(DISCOVERY_PORT, "0.0.0.0");
|
|
1078
|
-
return () => {
|
|
1079
|
-
try {
|
|
1080
|
-
socket.close();
|
|
1081
|
-
} catch {
|
|
1082
|
-
}
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// src/presentation/mcp/tools/connect.tool.ts
|
|
1087
|
-
function registerConnectTool(server, client) {
|
|
848
|
+
function registerFirewallCloseTool(server, client) {
|
|
1088
849
|
server.tool(
|
|
1089
|
-
"
|
|
1090
|
-
"
|
|
1091
|
-
{
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
const hub = await discoverHub(1e4);
|
|
1100
|
-
if (!hub) {
|
|
850
|
+
"firewall_close",
|
|
851
|
+
"Remove the Windows Firewall inbound rule for your P2P listen port. A UAC popup will appear \u2014 accept it to close the rule.",
|
|
852
|
+
{
|
|
853
|
+
port: z.number().min(1024).max(65535).optional().describe("Port to close (defaults to your current listen port)")
|
|
854
|
+
},
|
|
855
|
+
async ({ port }) => {
|
|
856
|
+
const targetPort = port ?? client.getInfo().port;
|
|
857
|
+
if (!targetPort) {
|
|
1101
858
|
return {
|
|
1102
|
-
content: [{
|
|
1103
|
-
|
|
1104
|
-
text: "No hub found on the LAN. Make sure someone has called start_hub on the host machine."
|
|
1105
|
-
}]
|
|
859
|
+
content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
|
|
860
|
+
isError: true
|
|
1106
861
|
};
|
|
1107
862
|
}
|
|
1108
863
|
try {
|
|
1109
|
-
await
|
|
1110
|
-
const info = client.getInfo();
|
|
864
|
+
await removeFirewallRule(targetPort);
|
|
1111
865
|
return {
|
|
1112
866
|
content: [{
|
|
1113
867
|
type: "text",
|
|
1114
|
-
text: `
|
|
868
|
+
text: `Firewall rule removed for port ${targetPort} (rule name: claude-collab-${targetPort}).`
|
|
1115
869
|
}]
|
|
1116
870
|
};
|
|
1117
871
|
} catch (err) {
|
|
1118
872
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1119
873
|
return {
|
|
1120
|
-
content: [{ type: "text", text: `
|
|
874
|
+
content: [{ type: "text", text: `Failed to close firewall: ${msg}` }],
|
|
875
|
+
isError: true
|
|
1121
876
|
};
|
|
1122
877
|
}
|
|
1123
878
|
}
|
|
@@ -1126,7 +881,7 @@ function registerConnectTool(server, client) {
|
|
|
1126
881
|
|
|
1127
882
|
// src/presentation/mcp/server.ts
|
|
1128
883
|
function createMcpServer(options) {
|
|
1129
|
-
const { client
|
|
884
|
+
const { client } = options;
|
|
1130
885
|
const server = new McpServer({
|
|
1131
886
|
name: "claude-collab",
|
|
1132
887
|
version: "0.1.0"
|
|
@@ -1135,9 +890,8 @@ function createMcpServer(options) {
|
|
|
1135
890
|
registerReplyTool(server, client);
|
|
1136
891
|
registerPeersTool(server, client);
|
|
1137
892
|
registerHistoryTool(server, client);
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
registerConnectTool(server, client);
|
|
893
|
+
registerFirewallOpenTool(server, client);
|
|
894
|
+
registerFirewallCloseTool(server, client);
|
|
1141
895
|
return server;
|
|
1142
896
|
}
|
|
1143
897
|
async function startMcpServer(options) {
|
|
@@ -1147,52 +901,20 @@ async function startMcpServer(options) {
|
|
|
1147
901
|
}
|
|
1148
902
|
|
|
1149
903
|
// src/mcp-main.ts
|
|
904
|
+
var P2P_PORT = 9999;
|
|
1150
905
|
function getArg(flag) {
|
|
1151
906
|
const idx = process.argv.indexOf(flag);
|
|
1152
907
|
return idx !== -1 ? process.argv[idx + 1] : void 0;
|
|
1153
908
|
}
|
|
1154
909
|
async function main() {
|
|
1155
910
|
const name = getArg("--name");
|
|
1156
|
-
const server = getArg("--server");
|
|
1157
911
|
if (!name) {
|
|
1158
912
|
console.error("--name is required");
|
|
1159
913
|
process.exit(1);
|
|
1160
914
|
}
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const url = server.startsWith("ws") ? server : `ws://${server}`;
|
|
1165
|
-
client.setServerUrl(url);
|
|
1166
|
-
await client.join(name, name);
|
|
1167
|
-
} else {
|
|
1168
|
-
await client.join(name, name);
|
|
1169
|
-
}
|
|
1170
|
-
const mcpReady = startMcpServer({ client, hubManager });
|
|
1171
|
-
if (!server) {
|
|
1172
|
-
discoverHub(5e3).then(async (hub) => {
|
|
1173
|
-
if (!hub || client.isConnected) return;
|
|
1174
|
-
try {
|
|
1175
|
-
console.error(`[mcp-main] hub found at ${hub.host}:${hub.port}, connecting...`);
|
|
1176
|
-
await client.connectToHub(`ws://${hub.host}:${hub.port}`);
|
|
1177
|
-
} catch {
|
|
1178
|
-
}
|
|
1179
|
-
});
|
|
1180
|
-
const stopWatch = watchForHub(async (hub) => {
|
|
1181
|
-
if (client.isConnected) {
|
|
1182
|
-
stopWatch();
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
try {
|
|
1186
|
-
console.error(`[mcp-main] hub appeared at ${hub.host}:${hub.port}, connecting...`);
|
|
1187
|
-
await client.connectToHub(`ws://${hub.host}:${hub.port}`);
|
|
1188
|
-
stopWatch();
|
|
1189
|
-
} catch (err) {
|
|
1190
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1191
|
-
console.error(`[mcp-main] auto-connect failed: ${msg}`);
|
|
1192
|
-
}
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
await mcpReady;
|
|
915
|
+
const node = new P2PNode(P2P_PORT);
|
|
916
|
+
await node.join(name, name);
|
|
917
|
+
await startMcpServer({ client: node });
|
|
1196
918
|
}
|
|
1197
919
|
main().catch((error) => {
|
|
1198
920
|
console.error("Unexpected error:", error);
|