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