@dolusoft/claude-collab 1.3.1 → 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 CHANGED
@@ -1,170 +1,16 @@
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
- import { networkInterfaces, tmpdir } from 'os';
8
5
  import { EventEmitter } from 'events';
9
6
  import { execFile } from 'child_process';
10
7
  import { unlinkSync } from 'fs';
8
+ import { tmpdir } from 'os';
11
9
  import { join } from 'path';
12
10
  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
- function getLocalIp() {
17
- const nets = networkInterfaces();
18
- for (const name of Object.keys(nets)) {
19
- for (const net of nets[name] ?? []) {
20
- if (net.family === "IPv4" && !net.internal) {
21
- return net.address;
22
- }
23
- }
24
- }
25
- return "127.0.0.1";
26
- }
27
- var SERVICE_TYPE = "_claude-collab._tcp.local";
28
- var MdnsDiscovery = class {
29
- mdns;
30
- announced = false;
31
- port = 0;
32
- teamName = "";
33
- memberId = "";
34
- peersByTeam = /* @__PURE__ */ new Map();
35
- peersByMemberId = /* @__PURE__ */ new Map();
36
- onPeerFoundCb;
37
- onPeerLostCb;
38
- constructor() {
39
- this.mdns = multicastDns();
40
- this.setupHandlers();
41
- }
42
- get serviceName() {
43
- return `${this.memberId}.${SERVICE_TYPE}`;
44
- }
45
- buildAnswers() {
46
- return [
47
- {
48
- name: SERVICE_TYPE,
49
- type: "PTR",
50
- ttl: 300,
51
- data: this.serviceName
52
- },
53
- {
54
- name: this.serviceName,
55
- type: "SRV",
56
- ttl: 300,
57
- data: {
58
- priority: 0,
59
- weight: 0,
60
- port: this.port,
61
- target: getLocalIp()
62
- }
63
- },
64
- {
65
- name: this.serviceName,
66
- type: "TXT",
67
- ttl: 300,
68
- data: [
69
- Buffer.from(`team=${this.teamName}`),
70
- Buffer.from(`memberId=${this.memberId}`),
71
- Buffer.from("ver=1")
72
- ]
73
- }
74
- ];
75
- }
76
- setupHandlers() {
77
- this.mdns.on("query", (query) => {
78
- if (!this.announced) return;
79
- const questions = query.questions ?? [];
80
- const ptrQuery = questions.find(
81
- (q) => q.type === "PTR" && q.name === SERVICE_TYPE
82
- );
83
- if (!ptrQuery) return;
84
- this.mdns.respond({ answers: this.buildAnswers() });
85
- });
86
- this.mdns.on("response", (response) => {
87
- this.parseResponse(response);
88
- });
89
- }
90
- parseResponse(response) {
91
- const allRecords = [
92
- ...response.answers ?? [],
93
- ...response.additionals ?? []
94
- ];
95
- const ptrRecords = allRecords.filter(
96
- (r) => r.type === "PTR" && r.name === SERVICE_TYPE
97
- );
98
- for (const ptr of ptrRecords) {
99
- const instanceName = ptr.data;
100
- const srv = allRecords.find(
101
- (r) => r.type === "SRV" && r.name === instanceName
102
- );
103
- const txt = allRecords.find(
104
- (r) => r.type === "TXT" && r.name === instanceName
105
- );
106
- if (!srv) continue;
107
- const port = srv.data.port;
108
- const host = srv.data.target || "127.0.0.1";
109
- let teamName = "";
110
- let memberId = "";
111
- if (txt) {
112
- const txtData = txt.data ?? [];
113
- for (const entry of txtData) {
114
- const str = Buffer.isBuffer(entry) ? entry.toString() : String(entry);
115
- if (str.startsWith("team=")) teamName = str.slice(5);
116
- if (str.startsWith("memberId=")) memberId = str.slice(9);
117
- }
118
- }
119
- if (!teamName || !memberId) continue;
120
- if (memberId === this.memberId) continue;
121
- const ptrTtl = ptr.ttl ?? 300;
122
- const srvTtl = srv.ttl ?? 300;
123
- if (ptrTtl === 0 || srvTtl === 0) {
124
- this.peersByTeam.delete(teamName);
125
- this.peersByMemberId.delete(memberId);
126
- this.onPeerLostCb?.(memberId);
127
- continue;
128
- }
129
- const peer = { host, port, teamName, memberId };
130
- this.peersByTeam.set(teamName, peer);
131
- this.peersByMemberId.set(memberId, peer);
132
- this.onPeerFoundCb?.(peer);
133
- }
134
- }
135
- /**
136
- * Announce this node's service via mDNS.
137
- * Sends an unsolicited response so existing peers notice immediately.
138
- */
139
- announce(port, teamName, memberId) {
140
- this.port = port;
141
- this.teamName = teamName;
142
- this.memberId = memberId;
143
- this.announced = true;
144
- this.mdns.respond({ answers: this.buildAnswers() });
145
- }
146
- /**
147
- * Send a PTR query to discover existing peers.
148
- */
149
- discover() {
150
- this.mdns.query({
151
- questions: [{ name: SERVICE_TYPE, type: "PTR" }]
152
- });
153
- }
154
- getPeerByTeam(teamName) {
155
- return this.peersByTeam.get(teamName);
156
- }
157
- onPeerFound(cb) {
158
- this.onPeerFoundCb = cb;
159
- }
160
- onPeerLost(cb) {
161
- this.onPeerLostCb = cb;
162
- }
163
- destroy() {
164
- this.mdns.destroy();
165
- }
166
- };
167
-
168
14
  // src/infrastructure/p2p/p2p-message-protocol.ts
169
15
  function serializeP2PMsg(msg) {
170
16
  return JSON.stringify(msg);
@@ -365,35 +211,18 @@ var config = {
365
211
  */
366
212
  p2p: {
367
213
  /**
368
- * Minimum port for the random WS server port range
369
- */
370
- portRangeMin: 1e4,
371
- /**
372
- * Maximum port for the random WS server port range
214
+ * Fixed port for the WS server. Override with CLAUDE_COLLAB_PORT env var.
373
215
  */
374
- portRangeMax: 19999
216
+ port: Number(process.env["CLAUDE_COLLAB_PORT"] ?? 11777)
375
217
  }};
376
218
 
377
219
  // src/infrastructure/p2p/p2p-node.ts
378
- function getRandomPort(min, max) {
379
- return new Promise((resolve) => {
380
- const port = Math.floor(Math.random() * (max - min + 1)) + min;
381
- const server = createServer();
382
- server.listen(port, () => {
383
- server.close(() => resolve(port));
384
- });
385
- server.on("error", () => {
386
- resolve(getRandomPort(min, max));
387
- });
388
- });
389
- }
390
220
  var P2PNode = class {
391
221
  wss = null;
392
- mdnsDiscovery = null;
393
222
  port = 0;
394
223
  // Connections indexed by remote team name
395
224
  peerConns = /* @__PURE__ */ new Map();
396
- // Reverse lookup: ws → teamName (for cleanup on incoming connections)
225
+ // Reverse lookup: ws → teamName (for cleanup)
397
226
  wsToTeam = /* @__PURE__ */ new Map();
398
227
  // Questions we received from remote peers (our inbox)
399
228
  incomingQuestions = /* @__PURE__ */ new Map();
@@ -412,14 +241,13 @@ var P2PNode = class {
412
241
  return this.localMember?.teamName;
413
242
  }
414
243
  /**
415
- * Starts the WS server on a random port and initialises mDNS.
244
+ * Starts the WS server on the configured fixed port.
416
245
  * Called automatically from join() if not yet started.
417
246
  */
418
247
  async start() {
419
- this.port = await getRandomPort(config.p2p.portRangeMin, config.p2p.portRangeMax);
248
+ this.port = config.p2p.port;
420
249
  this.wss = new WebSocketServer({ port: this.port });
421
250
  this.setupWssHandlers();
422
- this.mdnsDiscovery = new MdnsDiscovery();
423
251
  this._isStarted = true;
424
252
  console.error(`P2P node started on port ${this.port}`);
425
253
  }
@@ -429,17 +257,60 @@ var P2PNode = class {
429
257
  }
430
258
  const memberId = v4();
431
259
  this.localMember = { memberId, teamName, displayName };
432
- this.mdnsDiscovery.onPeerFound((peer) => {
433
- if (peer.teamName !== teamName) {
434
- console.error(`Discovered peer '${peer.teamName}' at ${peer.host}:${peer.port}`);
435
- this.connectToPeer(peer.teamName, peer.host, peer.port).catch((err) => {
436
- console.error(`Could not eagerly connect to ${peer.teamName}:`, err);
437
- });
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);
438
278
  }
439
279
  });
440
- this.mdnsDiscovery.announce(this.port, teamName, memberId);
441
- this.mdnsDiscovery.discover();
442
- return { memberId, teamId: teamName, teamName, displayName, status: "ONLINE" };
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;
443
314
  }
444
315
  async ask(toTeam, content, format) {
445
316
  const ws = await this.getPeerConnection(toTeam);
@@ -545,7 +416,6 @@ var P2PNode = class {
545
416
  };
546
417
  }
547
418
  async disconnect() {
548
- this.mdnsDiscovery?.destroy();
549
419
  for (const ws of this.peerConns.values()) {
550
420
  ws.close();
551
421
  }
@@ -567,6 +437,14 @@ var P2PNode = class {
567
437
  ws.on("message", (data) => {
568
438
  try {
569
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
+ }
570
448
  this.handleMessage(ws, msg);
571
449
  } catch (err) {
572
450
  console.error("Failed to parse incoming P2P message:", err);
@@ -584,7 +462,7 @@ var P2PNode = class {
584
462
  });
585
463
  }
586
464
  // ---------------------------------------------------------------------------
587
- // Private: unified message handler (used for both incoming & outgoing sockets)
465
+ // Private: unified message handler
588
466
  // ---------------------------------------------------------------------------
589
467
  handleMessage(ws, msg) {
590
468
  for (const handler of this.pendingHandlers) {
@@ -679,78 +557,9 @@ var P2PNode = class {
679
557
  if (existing && existing.readyState === WebSocket.OPEN) {
680
558
  return existing;
681
559
  }
682
- let peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
683
- if (!peer) {
684
- this.mdnsDiscovery?.discover();
685
- await this.waitForMdnsPeer(teamName, 1e4);
686
- peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
687
- }
688
- if (!peer) {
689
- throw new Error(
690
- `Peer for team '${teamName}' not found via mDNS. Make sure the other terminal has joined with that team name.`
691
- );
692
- }
693
- return this.connectToPeer(teamName, peer.host, peer.port);
694
- }
695
- async connectToPeer(teamName, host, port) {
696
- const existing = this.peerConns.get(teamName);
697
- if (existing && existing.readyState === WebSocket.OPEN) {
698
- return existing;
699
- }
700
- const ws = new WebSocket(`ws://${host}:${port}`);
701
- await new Promise((resolve, reject) => {
702
- const timeout = setTimeout(
703
- () => reject(new Error(`Connection timeout to team '${teamName}'`)),
704
- 5e3
705
- );
706
- ws.on("open", () => {
707
- clearTimeout(timeout);
708
- const hello = {
709
- type: "P2P_HELLO",
710
- fromTeam: this.localMember?.teamName ?? "unknown",
711
- fromMemberId: this.localMember?.memberId ?? "unknown"
712
- };
713
- ws.send(serializeP2PMsg(hello));
714
- resolve();
715
- });
716
- ws.on("error", (err) => {
717
- clearTimeout(timeout);
718
- reject(err);
719
- });
720
- });
721
- ws.on("message", (data) => {
722
- try {
723
- const msg = parseP2PMsg(data.toString());
724
- this.handleMessage(ws, msg);
725
- } catch (err) {
726
- console.error("Failed to parse P2P message:", err);
727
- }
728
- });
729
- ws.on("close", () => {
730
- if (this.peerConns.get(teamName) === ws) {
731
- this.peerConns.delete(teamName);
732
- }
733
- });
734
- this.peerConns.set(teamName, ws);
735
- return ws;
736
- }
737
- waitForMdnsPeer(teamName, timeoutMs) {
738
- return new Promise((resolve, reject) => {
739
- const deadline = Date.now() + timeoutMs;
740
- const check = () => {
741
- if (this.mdnsDiscovery?.getPeerByTeam(teamName)) {
742
- resolve();
743
- return;
744
- }
745
- if (Date.now() >= deadline) {
746
- reject(new Error(`mDNS timeout: team '${teamName}' not found`));
747
- return;
748
- }
749
- this.mdnsDiscovery?.discover();
750
- setTimeout(check, 500);
751
- };
752
- check();
753
- });
560
+ throw new Error(
561
+ `No connection to team '${teamName}'. Use the connect_peer tool to connect first.`
562
+ );
754
563
  }
755
564
  waitForResponse(filter, timeoutMs) {
756
565
  return new Promise((resolve, reject) => {
@@ -805,6 +614,37 @@ Status: ${member.status}`
805
614
  }
806
615
  });
807
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
+ }
808
648
  var askSchema = {
809
649
  team: z.string().describe('Target team name to ask (e.g., "backend", "frontend")'),
810
650
  question: z.string().describe("The question to ask (supports markdown)")
@@ -1051,6 +891,7 @@ function createMcpServer(options) {
1051
891
  }
1052
892
  );
1053
893
  registerJoinTool(server, client);
894
+ registerConnectPeerTool(server, client);
1054
895
  registerAskTool(server, client);
1055
896
  registerCheckAnswerTool(server, client);
1056
897
  registerInboxTool(server, client);
@@ -1088,14 +929,10 @@ async function startMcpServer(options) {
1088
929
  // src/cli.ts
1089
930
  var program = new Command();
1090
931
  program.name("claude-collab").description("Real-time P2P team collaboration between Claude Code terminals").version("0.1.0");
1091
- program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").option("-t, --team <team>", "Team to auto-join (e.g., frontend, backend)").action(async (options) => {
932
+ program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").action(async () => {
1092
933
  const p2pNode = new P2PNode();
1093
934
  try {
1094
935
  await p2pNode.start();
1095
- if (options.team) {
1096
- await p2pNode.join(options.team, `${options.team} Claude`);
1097
- console.error(`Auto-joined team: ${options.team}`);
1098
- }
1099
936
  } catch (error) {
1100
937
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
1101
938
  console.error(`Failed to start P2P node: ${errorMessage}`);