@dolusoft/claude-collab 1.3.0 → 1.4.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 +102 -254
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +101 -249
- package/dist/mcp-main.js.map +1 -1
- package/package.json +1 -3
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
-
import { createServer } from 'net';
|
|
5
4
|
import { v4 } from 'uuid';
|
|
6
|
-
import multicastDns from 'multicast-dns';
|
|
7
5
|
import { EventEmitter } from 'events';
|
|
8
6
|
import { execFile } from 'child_process';
|
|
9
7
|
import { unlinkSync } from 'fs';
|
|
@@ -13,147 +11,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
13
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
12
|
import { z } from 'zod';
|
|
15
13
|
|
|
16
|
-
var SERVICE_TYPE = "_claude-collab._tcp.local";
|
|
17
|
-
var MdnsDiscovery = class {
|
|
18
|
-
mdns;
|
|
19
|
-
announced = false;
|
|
20
|
-
port = 0;
|
|
21
|
-
teamName = "";
|
|
22
|
-
memberId = "";
|
|
23
|
-
peersByTeam = /* @__PURE__ */ new Map();
|
|
24
|
-
peersByMemberId = /* @__PURE__ */ new Map();
|
|
25
|
-
onPeerFoundCb;
|
|
26
|
-
onPeerLostCb;
|
|
27
|
-
constructor() {
|
|
28
|
-
this.mdns = multicastDns();
|
|
29
|
-
this.setupHandlers();
|
|
30
|
-
}
|
|
31
|
-
get serviceName() {
|
|
32
|
-
return `${this.memberId}.${SERVICE_TYPE}`;
|
|
33
|
-
}
|
|
34
|
-
buildAnswers() {
|
|
35
|
-
return [
|
|
36
|
-
{
|
|
37
|
-
name: SERVICE_TYPE,
|
|
38
|
-
type: "PTR",
|
|
39
|
-
ttl: 300,
|
|
40
|
-
data: this.serviceName
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
name: this.serviceName,
|
|
44
|
-
type: "SRV",
|
|
45
|
-
ttl: 300,
|
|
46
|
-
data: {
|
|
47
|
-
priority: 0,
|
|
48
|
-
weight: 0,
|
|
49
|
-
port: this.port,
|
|
50
|
-
target: "localhost"
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: this.serviceName,
|
|
55
|
-
type: "TXT",
|
|
56
|
-
ttl: 300,
|
|
57
|
-
data: [
|
|
58
|
-
Buffer.from(`team=${this.teamName}`),
|
|
59
|
-
Buffer.from(`memberId=${this.memberId}`),
|
|
60
|
-
Buffer.from("ver=1")
|
|
61
|
-
]
|
|
62
|
-
}
|
|
63
|
-
];
|
|
64
|
-
}
|
|
65
|
-
setupHandlers() {
|
|
66
|
-
this.mdns.on("query", (query) => {
|
|
67
|
-
if (!this.announced) return;
|
|
68
|
-
const questions = query.questions ?? [];
|
|
69
|
-
const ptrQuery = questions.find(
|
|
70
|
-
(q) => q.type === "PTR" && q.name === SERVICE_TYPE
|
|
71
|
-
);
|
|
72
|
-
if (!ptrQuery) return;
|
|
73
|
-
this.mdns.respond({ answers: this.buildAnswers() });
|
|
74
|
-
});
|
|
75
|
-
this.mdns.on("response", (response) => {
|
|
76
|
-
this.parseResponse(response);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
parseResponse(response) {
|
|
80
|
-
const allRecords = [
|
|
81
|
-
...response.answers ?? [],
|
|
82
|
-
...response.additionals ?? []
|
|
83
|
-
];
|
|
84
|
-
const ptrRecords = allRecords.filter(
|
|
85
|
-
(r) => r.type === "PTR" && r.name === SERVICE_TYPE
|
|
86
|
-
);
|
|
87
|
-
for (const ptr of ptrRecords) {
|
|
88
|
-
const instanceName = ptr.data;
|
|
89
|
-
const srv = allRecords.find(
|
|
90
|
-
(r) => r.type === "SRV" && r.name === instanceName
|
|
91
|
-
);
|
|
92
|
-
const txt = allRecords.find(
|
|
93
|
-
(r) => r.type === "TXT" && r.name === instanceName
|
|
94
|
-
);
|
|
95
|
-
if (!srv) continue;
|
|
96
|
-
const port = srv.data.port;
|
|
97
|
-
const host = "localhost";
|
|
98
|
-
let teamName = "";
|
|
99
|
-
let memberId = "";
|
|
100
|
-
if (txt) {
|
|
101
|
-
const txtData = txt.data ?? [];
|
|
102
|
-
for (const entry of txtData) {
|
|
103
|
-
const str = Buffer.isBuffer(entry) ? entry.toString() : String(entry);
|
|
104
|
-
if (str.startsWith("team=")) teamName = str.slice(5);
|
|
105
|
-
if (str.startsWith("memberId=")) memberId = str.slice(9);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (!teamName || !memberId) continue;
|
|
109
|
-
if (memberId === this.memberId) continue;
|
|
110
|
-
const ptrTtl = ptr.ttl ?? 300;
|
|
111
|
-
const srvTtl = srv.ttl ?? 300;
|
|
112
|
-
if (ptrTtl === 0 || srvTtl === 0) {
|
|
113
|
-
this.peersByTeam.delete(teamName);
|
|
114
|
-
this.peersByMemberId.delete(memberId);
|
|
115
|
-
this.onPeerLostCb?.(memberId);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const peer = { host, port, teamName, memberId };
|
|
119
|
-
this.peersByTeam.set(teamName, peer);
|
|
120
|
-
this.peersByMemberId.set(memberId, peer);
|
|
121
|
-
this.onPeerFoundCb?.(peer);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Announce this node's service via mDNS.
|
|
126
|
-
* Sends an unsolicited response so existing peers notice immediately.
|
|
127
|
-
*/
|
|
128
|
-
announce(port, teamName, memberId) {
|
|
129
|
-
this.port = port;
|
|
130
|
-
this.teamName = teamName;
|
|
131
|
-
this.memberId = memberId;
|
|
132
|
-
this.announced = true;
|
|
133
|
-
this.mdns.respond({ answers: this.buildAnswers() });
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Send a PTR query to discover existing peers.
|
|
137
|
-
*/
|
|
138
|
-
discover() {
|
|
139
|
-
this.mdns.query({
|
|
140
|
-
questions: [{ name: SERVICE_TYPE, type: "PTR" }]
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
getPeerByTeam(teamName) {
|
|
144
|
-
return this.peersByTeam.get(teamName);
|
|
145
|
-
}
|
|
146
|
-
onPeerFound(cb) {
|
|
147
|
-
this.onPeerFoundCb = cb;
|
|
148
|
-
}
|
|
149
|
-
onPeerLost(cb) {
|
|
150
|
-
this.onPeerLostCb = cb;
|
|
151
|
-
}
|
|
152
|
-
destroy() {
|
|
153
|
-
this.mdns.destroy();
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
14
|
// src/infrastructure/p2p/p2p-message-protocol.ts
|
|
158
15
|
function serializeP2PMsg(msg) {
|
|
159
16
|
return JSON.stringify(msg);
|
|
@@ -354,35 +211,18 @@ var config = {
|
|
|
354
211
|
*/
|
|
355
212
|
p2p: {
|
|
356
213
|
/**
|
|
357
|
-
*
|
|
358
|
-
*/
|
|
359
|
-
portRangeMin: 1e4,
|
|
360
|
-
/**
|
|
361
|
-
* Maximum port for the random WS server port range
|
|
214
|
+
* Fixed port for the WS server. Override with CLAUDE_COLLAB_PORT env var.
|
|
362
215
|
*/
|
|
363
|
-
|
|
216
|
+
port: Number(process.env["CLAUDE_COLLAB_PORT"] ?? 11777)
|
|
364
217
|
}};
|
|
365
218
|
|
|
366
219
|
// src/infrastructure/p2p/p2p-node.ts
|
|
367
|
-
function getRandomPort(min, max) {
|
|
368
|
-
return new Promise((resolve) => {
|
|
369
|
-
const port = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
370
|
-
const server = createServer();
|
|
371
|
-
server.listen(port, () => {
|
|
372
|
-
server.close(() => resolve(port));
|
|
373
|
-
});
|
|
374
|
-
server.on("error", () => {
|
|
375
|
-
resolve(getRandomPort(min, max));
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
220
|
var P2PNode = class {
|
|
380
221
|
wss = null;
|
|
381
|
-
mdnsDiscovery = null;
|
|
382
222
|
port = 0;
|
|
383
223
|
// Connections indexed by remote team name
|
|
384
224
|
peerConns = /* @__PURE__ */ new Map();
|
|
385
|
-
// Reverse lookup: ws → teamName (for cleanup
|
|
225
|
+
// Reverse lookup: ws → teamName (for cleanup)
|
|
386
226
|
wsToTeam = /* @__PURE__ */ new Map();
|
|
387
227
|
// Questions we received from remote peers (our inbox)
|
|
388
228
|
incomingQuestions = /* @__PURE__ */ new Map();
|
|
@@ -401,14 +241,13 @@ var P2PNode = class {
|
|
|
401
241
|
return this.localMember?.teamName;
|
|
402
242
|
}
|
|
403
243
|
/**
|
|
404
|
-
* Starts the WS server on
|
|
244
|
+
* Starts the WS server on the configured fixed port.
|
|
405
245
|
* Called automatically from join() if not yet started.
|
|
406
246
|
*/
|
|
407
247
|
async start() {
|
|
408
|
-
this.port =
|
|
248
|
+
this.port = config.p2p.port;
|
|
409
249
|
this.wss = new WebSocketServer({ port: this.port });
|
|
410
250
|
this.setupWssHandlers();
|
|
411
|
-
this.mdnsDiscovery = new MdnsDiscovery();
|
|
412
251
|
this._isStarted = true;
|
|
413
252
|
console.error(`P2P node started on port ${this.port}`);
|
|
414
253
|
}
|
|
@@ -418,17 +257,60 @@ var P2PNode = class {
|
|
|
418
257
|
}
|
|
419
258
|
const memberId = v4();
|
|
420
259
|
this.localMember = { memberId, teamName, displayName };
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
260
|
+
return { memberId, teamId: teamName, teamName, displayName, status: "ONLINE" };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Connects to a peer at the given IP and port.
|
|
264
|
+
* Performs a bidirectional HELLO handshake and returns the peer's team name.
|
|
265
|
+
*/
|
|
266
|
+
async connectPeer(ip, port) {
|
|
267
|
+
if (!this.localMember) {
|
|
268
|
+
throw new Error("Must call join() before connectPeer()");
|
|
269
|
+
}
|
|
270
|
+
const targetPort = port ?? config.p2p.port;
|
|
271
|
+
const ws = new WebSocket(`ws://${ip}:${targetPort}`);
|
|
272
|
+
ws.on("message", (data) => {
|
|
273
|
+
try {
|
|
274
|
+
const msg = parseP2PMsg(data.toString());
|
|
275
|
+
this.handleMessage(ws, msg);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error("Failed to parse P2P message:", err);
|
|
427
278
|
}
|
|
428
279
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
280
|
+
ws.on("close", () => {
|
|
281
|
+
const team = this.wsToTeam.get(ws);
|
|
282
|
+
if (team) {
|
|
283
|
+
if (this.peerConns.get(team) === ws) {
|
|
284
|
+
this.peerConns.delete(team);
|
|
285
|
+
}
|
|
286
|
+
this.wsToTeam.delete(ws);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
await new Promise((resolve, reject) => {
|
|
290
|
+
const timeout = setTimeout(
|
|
291
|
+
() => reject(new Error(`Connection timeout to ${ip}:${targetPort}`)),
|
|
292
|
+
5e3
|
|
293
|
+
);
|
|
294
|
+
ws.on("open", () => {
|
|
295
|
+
clearTimeout(timeout);
|
|
296
|
+
const hello = {
|
|
297
|
+
type: "P2P_HELLO",
|
|
298
|
+
fromTeam: this.localMember.teamName,
|
|
299
|
+
fromMemberId: this.localMember.memberId
|
|
300
|
+
};
|
|
301
|
+
ws.send(serializeP2PMsg(hello));
|
|
302
|
+
resolve();
|
|
303
|
+
});
|
|
304
|
+
ws.on("error", (err) => {
|
|
305
|
+
clearTimeout(timeout);
|
|
306
|
+
reject(err);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
const helloMsg = await this.waitForResponse(
|
|
310
|
+
(m) => m.type === "P2P_HELLO",
|
|
311
|
+
1e4
|
|
312
|
+
);
|
|
313
|
+
return helloMsg.fromTeam;
|
|
432
314
|
}
|
|
433
315
|
async ask(toTeam, content, format) {
|
|
434
316
|
const ws = await this.getPeerConnection(toTeam);
|
|
@@ -534,7 +416,6 @@ var P2PNode = class {
|
|
|
534
416
|
};
|
|
535
417
|
}
|
|
536
418
|
async disconnect() {
|
|
537
|
-
this.mdnsDiscovery?.destroy();
|
|
538
419
|
for (const ws of this.peerConns.values()) {
|
|
539
420
|
ws.close();
|
|
540
421
|
}
|
|
@@ -556,6 +437,14 @@ var P2PNode = class {
|
|
|
556
437
|
ws.on("message", (data) => {
|
|
557
438
|
try {
|
|
558
439
|
const msg = parseP2PMsg(data.toString());
|
|
440
|
+
if (msg.type === "P2P_HELLO" && this.localMember) {
|
|
441
|
+
const hello = {
|
|
442
|
+
type: "P2P_HELLO",
|
|
443
|
+
fromTeam: this.localMember.teamName,
|
|
444
|
+
fromMemberId: this.localMember.memberId
|
|
445
|
+
};
|
|
446
|
+
ws.send(serializeP2PMsg(hello));
|
|
447
|
+
}
|
|
559
448
|
this.handleMessage(ws, msg);
|
|
560
449
|
} catch (err) {
|
|
561
450
|
console.error("Failed to parse incoming P2P message:", err);
|
|
@@ -573,7 +462,7 @@ var P2PNode = class {
|
|
|
573
462
|
});
|
|
574
463
|
}
|
|
575
464
|
// ---------------------------------------------------------------------------
|
|
576
|
-
// Private: unified message handler
|
|
465
|
+
// Private: unified message handler
|
|
577
466
|
// ---------------------------------------------------------------------------
|
|
578
467
|
handleMessage(ws, msg) {
|
|
579
468
|
for (const handler of this.pendingHandlers) {
|
|
@@ -668,78 +557,9 @@ var P2PNode = class {
|
|
|
668
557
|
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
669
558
|
return existing;
|
|
670
559
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
await this.waitForMdnsPeer(teamName, 1e4);
|
|
675
|
-
peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
676
|
-
}
|
|
677
|
-
if (!peer) {
|
|
678
|
-
throw new Error(
|
|
679
|
-
`Peer for team '${teamName}' not found via mDNS. Make sure the other terminal has joined with that team name.`
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
return this.connectToPeer(teamName, peer.host, peer.port);
|
|
683
|
-
}
|
|
684
|
-
async connectToPeer(teamName, host, port) {
|
|
685
|
-
const existing = this.peerConns.get(teamName);
|
|
686
|
-
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
687
|
-
return existing;
|
|
688
|
-
}
|
|
689
|
-
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
690
|
-
await new Promise((resolve, reject) => {
|
|
691
|
-
const timeout = setTimeout(
|
|
692
|
-
() => reject(new Error(`Connection timeout to team '${teamName}'`)),
|
|
693
|
-
5e3
|
|
694
|
-
);
|
|
695
|
-
ws.on("open", () => {
|
|
696
|
-
clearTimeout(timeout);
|
|
697
|
-
const hello = {
|
|
698
|
-
type: "P2P_HELLO",
|
|
699
|
-
fromTeam: this.localMember?.teamName ?? "unknown",
|
|
700
|
-
fromMemberId: this.localMember?.memberId ?? "unknown"
|
|
701
|
-
};
|
|
702
|
-
ws.send(serializeP2PMsg(hello));
|
|
703
|
-
resolve();
|
|
704
|
-
});
|
|
705
|
-
ws.on("error", (err) => {
|
|
706
|
-
clearTimeout(timeout);
|
|
707
|
-
reject(err);
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
ws.on("message", (data) => {
|
|
711
|
-
try {
|
|
712
|
-
const msg = parseP2PMsg(data.toString());
|
|
713
|
-
this.handleMessage(ws, msg);
|
|
714
|
-
} catch (err) {
|
|
715
|
-
console.error("Failed to parse P2P message:", err);
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
ws.on("close", () => {
|
|
719
|
-
if (this.peerConns.get(teamName) === ws) {
|
|
720
|
-
this.peerConns.delete(teamName);
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
this.peerConns.set(teamName, ws);
|
|
724
|
-
return ws;
|
|
725
|
-
}
|
|
726
|
-
waitForMdnsPeer(teamName, timeoutMs) {
|
|
727
|
-
return new Promise((resolve, reject) => {
|
|
728
|
-
const deadline = Date.now() + timeoutMs;
|
|
729
|
-
const check = () => {
|
|
730
|
-
if (this.mdnsDiscovery?.getPeerByTeam(teamName)) {
|
|
731
|
-
resolve();
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
if (Date.now() >= deadline) {
|
|
735
|
-
reject(new Error(`mDNS timeout: team '${teamName}' not found`));
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
this.mdnsDiscovery?.discover();
|
|
739
|
-
setTimeout(check, 500);
|
|
740
|
-
};
|
|
741
|
-
check();
|
|
742
|
-
});
|
|
560
|
+
throw new Error(
|
|
561
|
+
`No connection to team '${teamName}'. Use the connect_peer tool to connect first.`
|
|
562
|
+
);
|
|
743
563
|
}
|
|
744
564
|
waitForResponse(filter, timeoutMs) {
|
|
745
565
|
return new Promise((resolve, reject) => {
|
|
@@ -794,6 +614,37 @@ Status: ${member.status}`
|
|
|
794
614
|
}
|
|
795
615
|
});
|
|
796
616
|
}
|
|
617
|
+
var connectPeerSchema = {
|
|
618
|
+
ip: z.string().describe('IP address of the peer to connect to (e.g., "172.16.40.137")'),
|
|
619
|
+
port: z.number().optional().describe(`Port the peer is listening on (default: ${config.p2p.port})`)
|
|
620
|
+
};
|
|
621
|
+
function registerConnectPeerTool(server, client) {
|
|
622
|
+
server.tool("connect_peer", connectPeerSchema, async (args) => {
|
|
623
|
+
const targetPort = args.port ?? config.p2p.port;
|
|
624
|
+
try {
|
|
625
|
+
const peerTeam = await client.connectPeer(args.ip, args.port);
|
|
626
|
+
return {
|
|
627
|
+
content: [
|
|
628
|
+
{
|
|
629
|
+
type: "text",
|
|
630
|
+
text: `Connected to peer "${peerTeam}" at ${args.ip}:${targetPort}.`
|
|
631
|
+
}
|
|
632
|
+
]
|
|
633
|
+
};
|
|
634
|
+
} catch (error) {
|
|
635
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
636
|
+
return {
|
|
637
|
+
content: [
|
|
638
|
+
{
|
|
639
|
+
type: "text",
|
|
640
|
+
text: `Failed to connect to ${args.ip}:${targetPort}: ${errorMessage}`
|
|
641
|
+
}
|
|
642
|
+
],
|
|
643
|
+
isError: true
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
797
648
|
var askSchema = {
|
|
798
649
|
team: z.string().describe('Target team name to ask (e.g., "backend", "frontend")'),
|
|
799
650
|
question: z.string().describe("The question to ask (supports markdown)")
|
|
@@ -1040,6 +891,7 @@ function createMcpServer(options) {
|
|
|
1040
891
|
}
|
|
1041
892
|
);
|
|
1042
893
|
registerJoinTool(server, client);
|
|
894
|
+
registerConnectPeerTool(server, client);
|
|
1043
895
|
registerAskTool(server, client);
|
|
1044
896
|
registerCheckAnswerTool(server, client);
|
|
1045
897
|
registerInboxTool(server, client);
|
|
@@ -1077,14 +929,10 @@ async function startMcpServer(options) {
|
|
|
1077
929
|
// src/cli.ts
|
|
1078
930
|
var program = new Command();
|
|
1079
931
|
program.name("claude-collab").description("Real-time P2P team collaboration between Claude Code terminals").version("0.1.0");
|
|
1080
|
-
program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").
|
|
932
|
+
program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").action(async () => {
|
|
1081
933
|
const p2pNode = new P2PNode();
|
|
1082
934
|
try {
|
|
1083
935
|
await p2pNode.start();
|
|
1084
|
-
if (options.team) {
|
|
1085
|
-
await p2pNode.join(options.team, `${options.team} Claude`);
|
|
1086
|
-
console.error(`Auto-joined team: ${options.team}`);
|
|
1087
|
-
}
|
|
1088
936
|
} catch (error) {
|
|
1089
937
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1090
938
|
console.error(`Failed to start P2P node: ${errorMessage}`);
|