@dolusoft/claude-collab 1.10.4 → 1.11.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/README.md +106 -17
- package/dist/cli.js +313 -354
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +312 -353
- package/dist/mcp-main.js.map +1 -1
- package/package.json +80 -81
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
4
4
|
import { v4 } from 'uuid';
|
|
5
|
-
import dgram from 'dgram';
|
|
6
5
|
import os, { tmpdir } from 'os';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
7
|
import { EventEmitter } from 'events';
|
|
8
|
-
import { execFile, spawn } from 'child_process';
|
|
9
8
|
import { unlinkSync } from 'fs';
|
|
10
9
|
import { join } from 'path';
|
|
11
10
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -19,131 +18,33 @@ function serialize(msg) {
|
|
|
19
18
|
function parse(data) {
|
|
20
19
|
return JSON.parse(data);
|
|
21
20
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
peers = /* @__PURE__ */ new Map();
|
|
31
|
-
myName = "";
|
|
32
|
-
myWsPort = 0;
|
|
33
|
-
start(name, wsPort) {
|
|
34
|
-
this.myName = name;
|
|
35
|
-
this.myWsPort = wsPort;
|
|
36
|
-
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
37
|
-
this.socket = socket;
|
|
38
|
-
socket.on("error", (err) => {
|
|
39
|
-
console.error("[multicast] socket error:", err.message);
|
|
40
|
-
});
|
|
41
|
-
socket.on("message", (buf, rinfo) => {
|
|
42
|
-
try {
|
|
43
|
-
const msg = JSON.parse(buf.toString());
|
|
44
|
-
this.handleMessage(msg, rinfo.address);
|
|
45
|
-
} catch {
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
socket.bind(MULTICAST_PORT, () => {
|
|
49
|
-
try {
|
|
50
|
-
socket.addMembership(MULTICAST_ADDR);
|
|
51
|
-
socket.setMulticastTTL(1);
|
|
52
|
-
socket.setMulticastLoopback(false);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.error("[multicast] membership error:", err);
|
|
55
|
-
}
|
|
56
|
-
this.announce();
|
|
57
|
-
this.heartbeatTimer = setInterval(() => this.announce(), HEARTBEAT_INTERVAL_MS);
|
|
58
|
-
this.timeoutTimer = setInterval(() => this.checkTimeouts(), 5e3);
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
stop() {
|
|
62
|
-
if (this.heartbeatTimer) {
|
|
63
|
-
clearInterval(this.heartbeatTimer);
|
|
64
|
-
this.heartbeatTimer = null;
|
|
65
|
-
}
|
|
66
|
-
if (this.timeoutTimer) {
|
|
67
|
-
clearInterval(this.timeoutTimer);
|
|
68
|
-
this.timeoutTimer = null;
|
|
69
|
-
}
|
|
70
|
-
if (this.socket) {
|
|
71
|
-
this.sendMessage({ type: "LEAVE", name: this.myName });
|
|
72
|
-
try {
|
|
73
|
-
this.socket.dropMembership(MULTICAST_ADDR);
|
|
74
|
-
this.socket.close();
|
|
75
|
-
} catch {
|
|
76
|
-
}
|
|
77
|
-
this.socket = null;
|
|
78
|
-
}
|
|
79
|
-
this.peers.clear();
|
|
80
|
-
}
|
|
81
|
-
resolveLocalIp() {
|
|
82
|
-
const interfaces = os.networkInterfaces();
|
|
83
|
-
for (const iface of Object.values(interfaces)) {
|
|
84
|
-
if (!iface) continue;
|
|
85
|
-
for (const addr of iface) {
|
|
86
|
-
if (addr.family === "IPv4" && !addr.internal) return addr.address;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return "127.0.0.1";
|
|
90
|
-
}
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Private
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
announce() {
|
|
95
|
-
this.sendMessage({ type: "ANNOUNCE", name: this.myName, wsPort: this.myWsPort });
|
|
96
|
-
}
|
|
97
|
-
sendMessage(msg) {
|
|
98
|
-
if (!this.socket) return;
|
|
99
|
-
const buf = Buffer.from(JSON.stringify(msg));
|
|
100
|
-
this.socket.send(buf, MULTICAST_PORT, MULTICAST_ADDR, (err) => {
|
|
101
|
-
if (err) console.error("[multicast] send error:", err.message);
|
|
21
|
+
function runNetshElevated(argArray) {
|
|
22
|
+
const argList = argArray.map((a) => `"${a}"`).join(",");
|
|
23
|
+
const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
|
|
26
|
+
ps.on("close", (code) => {
|
|
27
|
+
if (code === 0) resolve();
|
|
28
|
+
else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
|
|
102
29
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!this.socket) return;
|
|
106
|
-
const buf = Buffer.from(JSON.stringify({ type: "ANNOUNCE", name: this.myName, wsPort: this.myWsPort, unicast: true }));
|
|
107
|
-
this.socket.send(buf, MULTICAST_PORT, toIp, (err) => {
|
|
108
|
-
if (err) console.error(`[multicast] unicast reply to ${toIp} error:`, err.message);
|
|
30
|
+
ps.on("error", (err) => {
|
|
31
|
+
reject(new Error(`Failed to launch PowerShell: ${err.message}`));
|
|
109
32
|
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.sendUnicast(fromIp);
|
|
126
|
-
}
|
|
127
|
-
this.emit("peer-found", { name: msg.name, ip: fromIp, wsPort: msg.wsPort });
|
|
128
|
-
} else if (msg.type === "LEAVE") {
|
|
129
|
-
if (this.peers.has(msg.name)) {
|
|
130
|
-
this.peers.delete(msg.name);
|
|
131
|
-
this.emit("peer-lost", msg.name);
|
|
132
|
-
console.error(`[multicast] peer left: ${msg.name}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
checkTimeouts() {
|
|
137
|
-
const now = Date.now();
|
|
138
|
-
for (const [name, peer] of this.peers) {
|
|
139
|
-
if (now - peer.lastSeen > PEER_TIMEOUT_MS) {
|
|
140
|
-
this.peers.delete(name);
|
|
141
|
-
this.emit("peer-lost", name);
|
|
142
|
-
console.error(`[multicast] peer timed out: ${name}`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function addFirewallRule(port) {
|
|
36
|
+
await runNetshElevated([
|
|
37
|
+
"advfirewall",
|
|
38
|
+
"firewall",
|
|
39
|
+
"add",
|
|
40
|
+
"rule",
|
|
41
|
+
`name=claude-collab-${port}`,
|
|
42
|
+
"protocol=TCP",
|
|
43
|
+
"dir=in",
|
|
44
|
+
`localport=${port}`,
|
|
45
|
+
"action=allow"
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
147
48
|
var CS_CONINJECT = `
|
|
148
49
|
using System;
|
|
149
50
|
using System.Collections.Generic;
|
|
@@ -363,17 +264,18 @@ var InjectionQueue = class extends EventEmitter {
|
|
|
363
264
|
var injectionQueue = new InjectionQueue();
|
|
364
265
|
|
|
365
266
|
// src/infrastructure/p2p/p2p-node.ts
|
|
267
|
+
var FIXED_PORT = 12345;
|
|
366
268
|
var P2PNode = class {
|
|
367
|
-
constructor(portRange = [1e4, 19999]) {
|
|
368
|
-
this.portRange = portRange;
|
|
369
|
-
}
|
|
370
269
|
server = null;
|
|
371
270
|
myName = "";
|
|
372
271
|
running = false;
|
|
272
|
+
firewallOpened = false;
|
|
373
273
|
// One connection per peer (inbound or outbound — whichever was established first)
|
|
374
274
|
peerConnections = /* @__PURE__ */ new Map();
|
|
375
275
|
// Reverse map: ws → peer name (only for registered connections)
|
|
376
276
|
wsToName = /* @__PURE__ */ new Map();
|
|
277
|
+
// IP of each known peer: name → ip
|
|
278
|
+
peerIPs = /* @__PURE__ */ new Map();
|
|
377
279
|
// Prevent duplicate outbound connect attempts
|
|
378
280
|
connectingPeers = /* @__PURE__ */ new Set();
|
|
379
281
|
incomingQuestions = /* @__PURE__ */ new Map();
|
|
@@ -385,8 +287,6 @@ var P2PNode = class {
|
|
|
385
287
|
answerWaiters = /* @__PURE__ */ new Map();
|
|
386
288
|
// Answers queued for offline peers: peerName → AnswerMsg (delivered on reconnect)
|
|
387
289
|
pendingOutboundAnswers = /* @__PURE__ */ new Map();
|
|
388
|
-
discovery = null;
|
|
389
|
-
boundPort = 0;
|
|
390
290
|
// ---------------------------------------------------------------------------
|
|
391
291
|
// ICollabClient implementation
|
|
392
292
|
// ---------------------------------------------------------------------------
|
|
@@ -394,7 +294,7 @@ var P2PNode = class {
|
|
|
394
294
|
return this.running;
|
|
395
295
|
}
|
|
396
296
|
get port() {
|
|
397
|
-
return
|
|
297
|
+
return FIXED_PORT;
|
|
398
298
|
}
|
|
399
299
|
get currentTeamId() {
|
|
400
300
|
return this.myName || void 0;
|
|
@@ -402,16 +302,67 @@ var P2PNode = class {
|
|
|
402
302
|
async join(name, displayName) {
|
|
403
303
|
this.myName = name;
|
|
404
304
|
await this.startServer();
|
|
405
|
-
this.startDiscovery();
|
|
406
305
|
return {
|
|
407
306
|
memberId: v4(),
|
|
408
307
|
teamId: name,
|
|
409
308
|
teamName: name,
|
|
410
309
|
displayName,
|
|
411
310
|
status: "ONLINE",
|
|
412
|
-
port:
|
|
311
|
+
port: FIXED_PORT
|
|
413
312
|
};
|
|
414
313
|
}
|
|
314
|
+
async connectByIp(ip) {
|
|
315
|
+
if (!this.firewallOpened) {
|
|
316
|
+
await addFirewallRule(FIXED_PORT);
|
|
317
|
+
this.firewallOpened = true;
|
|
318
|
+
}
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
const url = `ws://${ip}:${FIXED_PORT}`;
|
|
321
|
+
const ws = new WebSocket(url);
|
|
322
|
+
const timeout = setTimeout(() => {
|
|
323
|
+
ws.terminate();
|
|
324
|
+
reject(new Error(`Connection to ${ip}:${FIXED_PORT} timed out`));
|
|
325
|
+
}, 1e4);
|
|
326
|
+
ws.on("open", () => {
|
|
327
|
+
this.sendToWs(ws, { type: "HELLO", name: this.myName });
|
|
328
|
+
});
|
|
329
|
+
ws.on("message", (data) => {
|
|
330
|
+
try {
|
|
331
|
+
const msg = parse(data.toString());
|
|
332
|
+
if (msg.type === "HELLO_ACK") {
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
if (this.peerConnections.has(msg.name)) {
|
|
335
|
+
ws.terminate();
|
|
336
|
+
resolve(msg.name);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.registerPeer(msg.name, ip, ws);
|
|
340
|
+
this.afterHandshake(msg.name, ws);
|
|
341
|
+
resolve(msg.name);
|
|
342
|
+
}
|
|
343
|
+
this.handleMessage(ws, msg);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
ws.on("close", () => {
|
|
348
|
+
clearTimeout(timeout);
|
|
349
|
+
this.connectingPeers.delete(ip);
|
|
350
|
+
const name = this.wsToName.get(ws);
|
|
351
|
+
if (name) {
|
|
352
|
+
this.wsToName.delete(ws);
|
|
353
|
+
if (this.peerConnections.get(name) === ws) {
|
|
354
|
+
this.peerConnections.delete(name);
|
|
355
|
+
console.error(`[p2p] disconnected from peer: ${name}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
ws.on("error", (err) => {
|
|
360
|
+
clearTimeout(timeout);
|
|
361
|
+
this.connectingPeers.delete(ip);
|
|
362
|
+
reject(new Error(`Failed to connect to ${ip}:${FIXED_PORT} \u2014 ${err.message}`));
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
}
|
|
415
366
|
async ask(toPeer, content, format) {
|
|
416
367
|
const ws = this.peerConnections.get(toPeer);
|
|
417
368
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
@@ -429,9 +380,7 @@ var P2PNode = class {
|
|
|
429
380
|
}
|
|
430
381
|
waitForAnswer(questionId, timeoutMs) {
|
|
431
382
|
const cached = this.receivedAnswers.get(questionId);
|
|
432
|
-
if (cached)
|
|
433
|
-
return Promise.resolve(this.formatAnswer(questionId, cached));
|
|
434
|
-
}
|
|
383
|
+
if (cached) return Promise.resolve(this.formatAnswer(questionId, cached));
|
|
435
384
|
return new Promise((resolve) => {
|
|
436
385
|
const timeout = setTimeout(() => {
|
|
437
386
|
this.answerWaiters.delete(questionId);
|
|
@@ -498,10 +447,22 @@ var P2PNode = class {
|
|
|
498
447
|
getInfo() {
|
|
499
448
|
return {
|
|
500
449
|
teamName: this.myName,
|
|
501
|
-
port:
|
|
502
|
-
connectedPeers: [...this.peerConnections.keys()]
|
|
450
|
+
port: FIXED_PORT,
|
|
451
|
+
connectedPeers: [...this.peerConnections.keys()],
|
|
452
|
+
peerIPs: Object.fromEntries(this.peerIPs)
|
|
503
453
|
};
|
|
504
454
|
}
|
|
455
|
+
getLocalIps() {
|
|
456
|
+
const result = [];
|
|
457
|
+
const interfaces = os.networkInterfaces();
|
|
458
|
+
for (const iface of Object.values(interfaces)) {
|
|
459
|
+
if (!iface) continue;
|
|
460
|
+
for (const addr of iface) {
|
|
461
|
+
if (addr.family === "IPv4" && !addr.internal) result.push(addr.address);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
505
466
|
getHistory() {
|
|
506
467
|
const entries = [];
|
|
507
468
|
for (const [questionId, sent] of this.sentQuestions) {
|
|
@@ -528,11 +489,10 @@ var P2PNode = class {
|
|
|
528
489
|
return entries.sort((a, b) => a.askedAt.localeCompare(b.askedAt));
|
|
529
490
|
}
|
|
530
491
|
async disconnect() {
|
|
531
|
-
this.discovery?.stop();
|
|
532
|
-
this.discovery = null;
|
|
533
492
|
for (const ws of this.peerConnections.values()) ws.close();
|
|
534
493
|
this.peerConnections.clear();
|
|
535
494
|
this.wsToName.clear();
|
|
495
|
+
this.peerIPs.clear();
|
|
536
496
|
this.server?.close();
|
|
537
497
|
this.server = null;
|
|
538
498
|
this.running = false;
|
|
@@ -541,40 +501,28 @@ var P2PNode = class {
|
|
|
541
501
|
// Private: server startup
|
|
542
502
|
// ---------------------------------------------------------------------------
|
|
543
503
|
startServer() {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const wss = new WebSocketServer({ port });
|
|
553
|
-
wss.once("listening", () => {
|
|
554
|
-
this.server = wss;
|
|
555
|
-
this.boundPort = port;
|
|
556
|
-
this.running = true;
|
|
557
|
-
console.error(`[p2p] listening on port ${port} as "${this.myName}"`);
|
|
558
|
-
this.attachServerHandlers(wss);
|
|
559
|
-
resolve();
|
|
560
|
-
});
|
|
561
|
-
wss.once("error", (err) => {
|
|
562
|
-
wss.close();
|
|
563
|
-
if (err.code === "EADDRINUSE") {
|
|
564
|
-
tryBind(attemptsLeft - 1).then(resolve, reject);
|
|
565
|
-
} else {
|
|
566
|
-
reject(err);
|
|
567
|
-
}
|
|
568
|
-
});
|
|
504
|
+
return new Promise((resolve, reject) => {
|
|
505
|
+
const wss = new WebSocketServer({ port: FIXED_PORT });
|
|
506
|
+
wss.once("listening", () => {
|
|
507
|
+
this.server = wss;
|
|
508
|
+
this.running = true;
|
|
509
|
+
console.error(`[p2p] listening on port ${FIXED_PORT} as "${this.myName}"`);
|
|
510
|
+
this.attachServerHandlers(wss);
|
|
511
|
+
resolve();
|
|
569
512
|
});
|
|
570
|
-
|
|
571
|
-
|
|
513
|
+
wss.once("error", (err) => {
|
|
514
|
+
wss.close();
|
|
515
|
+
reject(err);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
572
518
|
}
|
|
573
519
|
attachServerHandlers(wss) {
|
|
574
|
-
wss.on("connection", (ws) => {
|
|
520
|
+
wss.on("connection", (ws, request) => {
|
|
521
|
+
const rawIp = request.socket.remoteAddress ?? "";
|
|
522
|
+
const remoteIp = rawIp.replace(/^::ffff:/, "");
|
|
575
523
|
ws.on("message", (data) => {
|
|
576
524
|
try {
|
|
577
|
-
this.
|
|
525
|
+
this.handleInboundMessage(ws, remoteIp, parse(data.toString()));
|
|
578
526
|
} catch {
|
|
579
527
|
}
|
|
580
528
|
});
|
|
@@ -597,21 +545,35 @@ var P2PNode = class {
|
|
|
597
545
|
});
|
|
598
546
|
}
|
|
599
547
|
// ---------------------------------------------------------------------------
|
|
600
|
-
// Private:
|
|
548
|
+
// Private: mesh helpers
|
|
601
549
|
// ---------------------------------------------------------------------------
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
550
|
+
/** Called once handshake completes — exchange peer lists and announce to existing peers. */
|
|
551
|
+
afterHandshake(newPeerName, ws) {
|
|
552
|
+
const listForNew = [...this.peerConnections.keys()].filter((n) => n !== newPeerName).map((n) => ({ name: n, ip: this.peerIPs.get(n) ?? "" })).filter((p) => p.ip !== "");
|
|
553
|
+
this.sendToWs(ws, { type: "PEER_LIST", peers: listForNew });
|
|
554
|
+
const newIp = this.peerIPs.get(newPeerName);
|
|
555
|
+
if (newIp) {
|
|
556
|
+
for (const [name, existingWs] of this.peerConnections) {
|
|
557
|
+
if (name !== newPeerName) {
|
|
558
|
+
this.sendToWs(existingWs, { type: "PEER_ANNOUNCE", name: newPeerName, ip: newIp });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
registerPeer(name, ip, ws) {
|
|
564
|
+
this.peerConnections.set(name, ws);
|
|
565
|
+
this.wsToName.set(ws, name);
|
|
566
|
+
this.peerIPs.set(name, ip);
|
|
567
|
+
this.connectingPeers.delete(name);
|
|
568
|
+
console.error(`[p2p] peer registered: ${name} @ ${ip}`);
|
|
569
|
+
this.deliverPendingAnswer(name, ws);
|
|
611
570
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
571
|
+
/** Connect outbound to a peer discovered via PEER_LIST or PEER_ANNOUNCE. */
|
|
572
|
+
connectMeshPeer(name, ip) {
|
|
573
|
+
if (this.peerConnections.has(name)) return;
|
|
574
|
+
if (this.connectingPeers.has(name)) return;
|
|
575
|
+
this.connectingPeers.add(name);
|
|
576
|
+
const ws = new WebSocket(`ws://${ip}:${FIXED_PORT}`);
|
|
615
577
|
ws.on("open", () => {
|
|
616
578
|
this.sendToWs(ws, { type: "HELLO", name: this.myName });
|
|
617
579
|
});
|
|
@@ -622,52 +584,70 @@ var P2PNode = class {
|
|
|
622
584
|
}
|
|
623
585
|
});
|
|
624
586
|
ws.on("close", () => {
|
|
625
|
-
this.connectingPeers.delete(
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
587
|
+
this.connectingPeers.delete(name);
|
|
588
|
+
const peerName = this.wsToName.get(ws);
|
|
589
|
+
if (peerName) {
|
|
628
590
|
this.wsToName.delete(ws);
|
|
629
|
-
if (this.peerConnections.get(
|
|
630
|
-
this.peerConnections.delete(
|
|
631
|
-
console.error(`[p2p] disconnected from peer: ${
|
|
591
|
+
if (this.peerConnections.get(peerName) === ws) {
|
|
592
|
+
this.peerConnections.delete(peerName);
|
|
593
|
+
console.error(`[p2p] disconnected from mesh peer: ${peerName}`);
|
|
632
594
|
}
|
|
633
595
|
}
|
|
634
596
|
});
|
|
635
597
|
ws.on("error", (err) => {
|
|
636
|
-
console.error(`[p2p] connect to "${
|
|
637
|
-
this.connectingPeers.delete(
|
|
598
|
+
console.error(`[p2p] mesh connect to "${name}" @ ${ip} failed: ${err.message}`);
|
|
599
|
+
this.connectingPeers.delete(name);
|
|
638
600
|
});
|
|
639
601
|
}
|
|
640
602
|
// ---------------------------------------------------------------------------
|
|
641
603
|
// Private: message handling
|
|
642
604
|
// ---------------------------------------------------------------------------
|
|
605
|
+
/** Handles messages on inbound connections (server side — we know the remote IP). */
|
|
606
|
+
handleInboundMessage(ws, remoteIp, msg) {
|
|
607
|
+
for (const handler of this.pendingHandlers) handler(msg);
|
|
608
|
+
if (msg.type === "HELLO") {
|
|
609
|
+
if (this.peerConnections.has(msg.name)) {
|
|
610
|
+
ws.terminate();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
this.registerPeer(msg.name, remoteIp, ws);
|
|
614
|
+
this.sendToWs(ws, { type: "HELLO_ACK", name: this.myName });
|
|
615
|
+
console.error(`[p2p] peer joined (inbound): ${msg.name}`);
|
|
616
|
+
this.afterHandshake(msg.name, ws);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
this.handleMessage(ws, msg);
|
|
620
|
+
}
|
|
621
|
+
/** Handles all other messages (both inbound and outbound connections). */
|
|
643
622
|
handleMessage(ws, msg) {
|
|
644
623
|
for (const handler of this.pendingHandlers) handler(msg);
|
|
645
624
|
switch (msg.type) {
|
|
646
|
-
case "
|
|
647
|
-
if (this.peerConnections.has(msg.name)) {
|
|
648
|
-
|
|
649
|
-
|
|
625
|
+
case "HELLO_ACK":
|
|
626
|
+
if (!this.peerConnections.has(msg.name)) {
|
|
627
|
+
this.peerConnections.set(msg.name, ws);
|
|
628
|
+
this.wsToName.set(ws, msg.name);
|
|
629
|
+
this.connectingPeers.delete(msg.name);
|
|
630
|
+
console.error(`[p2p] connected to mesh peer: ${msg.name}`);
|
|
631
|
+
this.deliverPendingAnswer(msg.name, ws);
|
|
632
|
+
this.afterHandshake(msg.name, ws);
|
|
650
633
|
}
|
|
651
|
-
this.peerConnections.set(msg.name, ws);
|
|
652
|
-
this.wsToName.set(ws, msg.name);
|
|
653
|
-
this.connectingPeers.delete(msg.name);
|
|
654
|
-
this.sendToWs(ws, { type: "HELLO_ACK", name: this.myName });
|
|
655
|
-
console.error(`[p2p] peer joined (inbound): ${msg.name}`);
|
|
656
|
-
this.deliverPendingAnswer(msg.name, ws);
|
|
657
634
|
break;
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
635
|
+
case "PEER_LIST":
|
|
636
|
+
for (const peer of msg.peers) {
|
|
637
|
+
if (peer.name !== this.myName && !this.peerConnections.has(peer.name)) {
|
|
638
|
+
console.error(`[p2p] mesh: connecting to ${peer.name} @ ${peer.ip} via PEER_LIST`);
|
|
639
|
+
this.peerIPs.set(peer.name, peer.ip);
|
|
640
|
+
this.connectMeshPeer(peer.name, peer.ip);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
case "PEER_ANNOUNCE":
|
|
645
|
+
if (msg.name !== this.myName && !this.peerConnections.has(msg.name)) {
|
|
646
|
+
console.error(`[p2p] mesh: connecting to ${msg.name} @ ${msg.ip} via PEER_ANNOUNCE`);
|
|
647
|
+
this.peerIPs.set(msg.name, msg.ip);
|
|
648
|
+
this.connectMeshPeer(msg.name, msg.ip);
|
|
663
649
|
}
|
|
664
|
-
this.peerConnections.set(msg.name, ws);
|
|
665
|
-
this.wsToName.set(ws, msg.name);
|
|
666
|
-
this.connectingPeers.delete(msg.name);
|
|
667
|
-
console.error(`[p2p] connected to peer: ${msg.name}`);
|
|
668
|
-
this.deliverPendingAnswer(msg.name, ws);
|
|
669
650
|
break;
|
|
670
|
-
}
|
|
671
651
|
case "ASK":
|
|
672
652
|
this.handleIncomingAsk(ws, msg);
|
|
673
653
|
break;
|
|
@@ -740,6 +720,72 @@ var P2PNode = class {
|
|
|
740
720
|
});
|
|
741
721
|
}
|
|
742
722
|
};
|
|
723
|
+
var CONNECT_DESCRIPTION = `Connect to another Claude instance by their IP address.
|
|
724
|
+
|
|
725
|
+
WHEN TO USE:
|
|
726
|
+
- First time connecting to a teammate
|
|
727
|
+
- A new peer wants to join an existing session
|
|
728
|
+
|
|
729
|
+
WHAT HAPPENS:
|
|
730
|
+
1. First call only: opens Windows Firewall port 12345 via UAC popup (stays open)
|
|
731
|
+
2. Connects directly to the peer at ws://IP:12345
|
|
732
|
+
3. Both sides exchange their peer lists \u2014 everyone connects to everyone (full mesh)
|
|
733
|
+
|
|
734
|
+
FINDING YOUR IP:
|
|
735
|
+
- The other person calls status() to see their LAN IP, then tells you
|
|
736
|
+
|
|
737
|
+
AFTER CONNECTING:
|
|
738
|
+
- Use peers() to confirm connection
|
|
739
|
+
- Use ask() to send questions to connected peers`;
|
|
740
|
+
function registerConnectTool(server, client) {
|
|
741
|
+
server.tool(
|
|
742
|
+
"connect",
|
|
743
|
+
CONNECT_DESCRIPTION,
|
|
744
|
+
{
|
|
745
|
+
ip: z.string().describe("The peer's LAN IP address (e.g. 192.168.1.5)")
|
|
746
|
+
},
|
|
747
|
+
async ({ ip }) => {
|
|
748
|
+
if (!client.isConnected) {
|
|
749
|
+
return {
|
|
750
|
+
content: [{ type: "text", text: "Node is not ready yet. Wait a moment and try again." }],
|
|
751
|
+
isError: true
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const peerName = await client.connectByIp(ip);
|
|
756
|
+
const info = client.getInfo();
|
|
757
|
+
const allPeers = info.connectedPeers;
|
|
758
|
+
const others = allPeers.filter((n) => n !== peerName);
|
|
759
|
+
const lines = [
|
|
760
|
+
`Connected to "${peerName}" (${ip}).`
|
|
761
|
+
];
|
|
762
|
+
if (others.length > 0) {
|
|
763
|
+
lines.push(``, `Mesh peers also connecting: ${others.map((n) => `"${n}"`).join(", ")}`);
|
|
764
|
+
}
|
|
765
|
+
lines.push(``, `Use peers() to see all connected peers.`);
|
|
766
|
+
return {
|
|
767
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
768
|
+
};
|
|
769
|
+
} catch (err) {
|
|
770
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
771
|
+
return {
|
|
772
|
+
content: [{
|
|
773
|
+
type: "text",
|
|
774
|
+
text: [
|
|
775
|
+
`Failed to connect to ${ip}: ${msg}`,
|
|
776
|
+
``,
|
|
777
|
+
`Make sure:`,
|
|
778
|
+
` \u2022 The peer is running claude-collab (--name <name>)`,
|
|
779
|
+
` \u2022 The IP address is correct (they can check with status())`,
|
|
780
|
+
` \u2022 Port 12345 is not blocked on their machine`
|
|
781
|
+
].join("\n")
|
|
782
|
+
}],
|
|
783
|
+
isError: true
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
);
|
|
788
|
+
}
|
|
743
789
|
var ASK_DESCRIPTION = `Send a question to another Claude instance on the LAN and wait for their answer.
|
|
744
790
|
|
|
745
791
|
WHEN TO USE:
|
|
@@ -885,22 +931,20 @@ function registerReplyTool(server, client) {
|
|
|
885
931
|
}
|
|
886
932
|
|
|
887
933
|
// src/presentation/mcp/tools/peers.tool.ts
|
|
888
|
-
var PEERS_DESCRIPTION = `
|
|
934
|
+
var PEERS_DESCRIPTION = `List all currently active peer connections.
|
|
889
935
|
|
|
890
936
|
WHEN TO USE:
|
|
891
937
|
- Before calling ask() \u2014 to confirm the target peer is online and get their exact name
|
|
892
|
-
- After
|
|
938
|
+
- After connect() \u2014 to verify the connection succeeded and the mesh formed
|
|
893
939
|
- When a peer seems unreachable \u2014 to check if they are still connected
|
|
894
940
|
|
|
895
941
|
WHAT IT SHOWS:
|
|
896
|
-
- Your own name and
|
|
897
|
-
- All peers
|
|
942
|
+
- Your own name and port
|
|
943
|
+
- All peers with an active direct connection
|
|
898
944
|
|
|
899
945
|
IF NO PEERS ARE LISTED:
|
|
900
|
-
-
|
|
901
|
-
-
|
|
902
|
-
- Call firewall_open() so peers on other machines can connect inbound to you
|
|
903
|
-
- If still no peers, verify all machines are on the same LAN/subnet`;
|
|
946
|
+
- Use connect(ip) to connect to a peer
|
|
947
|
+
- Ask your teammate to run status() to get their IP`;
|
|
904
948
|
function registerPeersTool(server, client) {
|
|
905
949
|
server.tool("peers", PEERS_DESCRIPTION, {}, async () => {
|
|
906
950
|
const info = client.getInfo();
|
|
@@ -927,13 +971,17 @@ function registerPeersTool(server, client) {
|
|
|
927
971
|
`You are "${myName}" (listening on port ${myPort}).`,
|
|
928
972
|
`No peers connected yet.`,
|
|
929
973
|
``,
|
|
930
|
-
`
|
|
931
|
-
`
|
|
974
|
+
`Use connect(ip) to connect to a peer.`,
|
|
975
|
+
`Ask your teammate to run status() to get their IP.`
|
|
932
976
|
].join("\n")
|
|
933
977
|
}]
|
|
934
978
|
};
|
|
935
979
|
}
|
|
936
|
-
const
|
|
980
|
+
const peerIPs = info.peerIPs ?? {};
|
|
981
|
+
const list = connected.map((name) => {
|
|
982
|
+
const ip = peerIPs[name] ? ` (${peerIPs[name]})` : "";
|
|
983
|
+
return ` \u2022 ${name}${ip}`;
|
|
984
|
+
}).join("\n");
|
|
937
985
|
return {
|
|
938
986
|
content: [{
|
|
939
987
|
type: "text",
|
|
@@ -986,142 +1034,51 @@ ${answerLine}`;
|
|
|
986
1034
|
};
|
|
987
1035
|
});
|
|
988
1036
|
}
|
|
989
|
-
function runNetshElevated(argArray) {
|
|
990
|
-
const argList = argArray.map((a) => `"${a}"`).join(",");
|
|
991
|
-
const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
|
|
992
|
-
return new Promise((resolve, reject) => {
|
|
993
|
-
const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
|
|
994
|
-
ps.on("close", (code) => {
|
|
995
|
-
if (code === 0) resolve();
|
|
996
|
-
else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
|
|
997
|
-
});
|
|
998
|
-
ps.on("error", (err) => {
|
|
999
|
-
reject(new Error(`Failed to launch PowerShell: ${err.message}`));
|
|
1000
|
-
});
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
async function runNetsh(argArray) {
|
|
1004
|
-
await runNetshElevated(argArray);
|
|
1005
|
-
}
|
|
1006
|
-
async function addFirewallRule(port) {
|
|
1007
|
-
await runNetsh([
|
|
1008
|
-
"advfirewall",
|
|
1009
|
-
"firewall",
|
|
1010
|
-
"add",
|
|
1011
|
-
"rule",
|
|
1012
|
-
`name=claude-collab-${port}`,
|
|
1013
|
-
"protocol=TCP",
|
|
1014
|
-
"dir=in",
|
|
1015
|
-
`localport=${port}`,
|
|
1016
|
-
"action=allow"
|
|
1017
|
-
]);
|
|
1018
|
-
try {
|
|
1019
|
-
await runNetsh([
|
|
1020
|
-
"advfirewall",
|
|
1021
|
-
"firewall",
|
|
1022
|
-
"add",
|
|
1023
|
-
"rule",
|
|
1024
|
-
"name=claude-collab-discovery",
|
|
1025
|
-
"protocol=UDP",
|
|
1026
|
-
"dir=in",
|
|
1027
|
-
"localport=11776",
|
|
1028
|
-
"action=allow"
|
|
1029
|
-
]);
|
|
1030
|
-
} catch {
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
async function removeFirewallRule(port) {
|
|
1034
|
-
await runNetsh([
|
|
1035
|
-
"advfirewall",
|
|
1036
|
-
"firewall",
|
|
1037
|
-
"delete",
|
|
1038
|
-
"rule",
|
|
1039
|
-
`name=claude-collab-${port}`
|
|
1040
|
-
]);
|
|
1041
|
-
try {
|
|
1042
|
-
await runNetsh(["advfirewall", "firewall", "delete", "rule", "name=claude-collab-discovery"]);
|
|
1043
|
-
} catch {
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// src/presentation/mcp/tools/peer-find.tool.ts
|
|
1048
|
-
var PEER_FIND_DESCRIPTION = `Discover and connect to peers on the LAN automatically.
|
|
1049
1037
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
2. Waits 30 seconds while multicast discovery finds peers
|
|
1053
|
-
3. Closes the firewall (UAC popup) \u2014 established connections persist
|
|
1038
|
+
// src/presentation/mcp/tools/status.tool.ts
|
|
1039
|
+
var STATUS_DESCRIPTION = `Show your identity and network address on the collaboration network.
|
|
1054
1040
|
|
|
1055
1041
|
WHEN TO USE:
|
|
1056
|
-
-
|
|
1057
|
-
-
|
|
1058
|
-
|
|
1059
|
-
- After a disconnect/restart: the reconnecting peer calls peer_find
|
|
1042
|
+
- To find your LAN IP so teammates can connect to you via connect(ip)
|
|
1043
|
+
- To verify your node started correctly
|
|
1044
|
+
- To see how many peers are currently connected
|
|
1060
1045
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
server.tool(
|
|
1070
|
-
"peer_find",
|
|
1071
|
-
PEER_FIND_DESCRIPTION,
|
|
1072
|
-
{
|
|
1073
|
-
wait_seconds: z.number().min(10).max(120).optional().describe("How long to wait for peers in seconds (default: 30)")
|
|
1074
|
-
},
|
|
1075
|
-
async ({ wait_seconds = 30 }) => {
|
|
1076
|
-
const port = client.getInfo().port;
|
|
1077
|
-
if (!port) {
|
|
1078
|
-
return {
|
|
1079
|
-
content: [{ type: "text", text: "P2P node is not running yet. Try again in a moment." }],
|
|
1080
|
-
isError: true
|
|
1081
|
-
};
|
|
1082
|
-
}
|
|
1083
|
-
try {
|
|
1084
|
-
await addFirewallRule(port);
|
|
1085
|
-
} catch (err) {
|
|
1086
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1087
|
-
return {
|
|
1088
|
-
content: [{ type: "text", text: `Failed to open firewall: ${msg}` }],
|
|
1089
|
-
isError: true
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
const peersAtStart = new Set(client.getInfo().connectedPeers);
|
|
1093
|
-
await new Promise((resolve) => setTimeout(resolve, wait_seconds * 1e3));
|
|
1094
|
-
try {
|
|
1095
|
-
await removeFirewallRule(port);
|
|
1096
|
-
} catch {
|
|
1097
|
-
}
|
|
1098
|
-
const allPeers = client.getInfo().connectedPeers;
|
|
1099
|
-
const newPeers = allPeers.filter((p) => !peersAtStart.has(p));
|
|
1100
|
-
if (allPeers.length === 0) {
|
|
1101
|
-
return {
|
|
1102
|
-
content: [{
|
|
1103
|
-
type: "text",
|
|
1104
|
-
text: [
|
|
1105
|
-
`No peers found after ${wait_seconds}s.`,
|
|
1106
|
-
``,
|
|
1107
|
-
`Make sure other peers are also running peer_find at the same time,`,
|
|
1108
|
-
`and that all machines are on the same LAN.`
|
|
1109
|
-
].join("\n")
|
|
1110
|
-
}]
|
|
1111
|
-
};
|
|
1112
|
-
}
|
|
1113
|
-
const lines = [
|
|
1114
|
-
`Firewall closed. Connected peers (${allPeers.length}):`,
|
|
1115
|
-
...allPeers.map((p) => ` \u2022 ${p}${newPeers.includes(p) ? " (new)" : ""}`),
|
|
1116
|
-
``,
|
|
1117
|
-
`Connections will persist until a peer disconnects or restarts.`,
|
|
1118
|
-
`If a peer disconnects, they call peer_find again \u2014 no action needed from you.`
|
|
1119
|
-
];
|
|
1046
|
+
SHARE YOUR IP:
|
|
1047
|
+
- Tell your teammate the IP shown here, they call connect("<your IP>")`;
|
|
1048
|
+
function registerStatusTool(server, client) {
|
|
1049
|
+
server.tool("status", STATUS_DESCRIPTION, {}, async () => {
|
|
1050
|
+
const info = client.getInfo();
|
|
1051
|
+
const myName = info.teamName ?? "(starting...)";
|
|
1052
|
+
const port = info.port ?? "?";
|
|
1053
|
+
if (!client.isConnected) {
|
|
1120
1054
|
return {
|
|
1121
|
-
content: [{
|
|
1055
|
+
content: [{
|
|
1056
|
+
type: "text",
|
|
1057
|
+
text: `Node is not running yet. Port ${port} may be in use \u2014 check MCP process logs.`
|
|
1058
|
+
}],
|
|
1059
|
+
isError: true
|
|
1122
1060
|
};
|
|
1123
1061
|
}
|
|
1124
|
-
|
|
1062
|
+
let ips = [];
|
|
1063
|
+
if (client instanceof P2PNode) {
|
|
1064
|
+
ips = client.getLocalIps();
|
|
1065
|
+
}
|
|
1066
|
+
const ipLine = ips.length > 0 ? ips.join(", ") : "(could not detect \u2014 check your network interface)";
|
|
1067
|
+
const peerCount = info.connectedPeers.length;
|
|
1068
|
+
const peerLine = peerCount === 0 ? "No peers connected yet. Share your IP so others can connect(ip)." : `Connected to ${peerCount} peer(s): ${info.connectedPeers.map((n) => `"${n}"`).join(", ")}`;
|
|
1069
|
+
return {
|
|
1070
|
+
content: [{
|
|
1071
|
+
type: "text",
|
|
1072
|
+
text: [
|
|
1073
|
+
`Name: ${myName}`,
|
|
1074
|
+
`IP: ${ipLine}`,
|
|
1075
|
+
`Port: ${port}`,
|
|
1076
|
+
``,
|
|
1077
|
+
peerLine
|
|
1078
|
+
].join("\n")
|
|
1079
|
+
}]
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1125
1082
|
}
|
|
1126
1083
|
|
|
1127
1084
|
// src/presentation/mcp/server.ts
|
|
@@ -1131,11 +1088,12 @@ function createMcpServer(options) {
|
|
|
1131
1088
|
name: "claude-collab",
|
|
1132
1089
|
version: "0.1.0"
|
|
1133
1090
|
});
|
|
1091
|
+
registerConnectTool(server, client);
|
|
1092
|
+
registerStatusTool(server, client);
|
|
1134
1093
|
registerAskTool(server, client);
|
|
1135
1094
|
registerReplyTool(server, client);
|
|
1136
1095
|
registerPeersTool(server, client);
|
|
1137
1096
|
registerHistoryTool(server, client);
|
|
1138
|
-
registerPeerFindTool(server, client);
|
|
1139
1097
|
return server;
|
|
1140
1098
|
}
|
|
1141
1099
|
async function startMcpServer(options) {
|
|
@@ -1146,11 +1104,12 @@ async function startMcpServer(options) {
|
|
|
1146
1104
|
|
|
1147
1105
|
// src/cli.ts
|
|
1148
1106
|
var program = new Command();
|
|
1149
|
-
program.name("claude-collab").description("
|
|
1107
|
+
program.name("claude-collab").description("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) => {
|
|
1150
1108
|
const node = new P2PNode();
|
|
1151
1109
|
const mcpReady = startMcpServer({ client: node });
|
|
1152
1110
|
node.join(options.name, options.name).catch((err) => {
|
|
1153
|
-
console.error(`[cli]
|
|
1111
|
+
console.error(`[cli] Failed to start on port 12345: ${err.message}`);
|
|
1112
|
+
console.error(`[cli] Make sure port 12345 is not already in use.`);
|
|
1154
1113
|
});
|
|
1155
1114
|
await mcpReady;
|
|
1156
1115
|
});
|