@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 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
- * Minimum port for the random WS server port range
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
- portRangeMax: 19999
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 on incoming connections)
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 a random port and initialises mDNS.
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 = await getRandomPort(config.p2p.portRangeMin, config.p2p.portRangeMax);
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
- this.mdnsDiscovery.onPeerFound((peer) => {
422
- if (peer.teamName !== teamName) {
423
- console.error(`Discovered peer '${peer.teamName}' at ${peer.host}:${peer.port}`);
424
- this.connectToPeer(peer.teamName, peer.host, peer.port).catch((err) => {
425
- console.error(`Could not eagerly connect to ${peer.teamName}:`, err);
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
- this.mdnsDiscovery.announce(this.port, teamName, memberId);
430
- this.mdnsDiscovery.discover();
431
- 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;
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 (used for both incoming & outgoing sockets)
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
- let peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
672
- if (!peer) {
673
- this.mdnsDiscovery?.discover();
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)").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 () => {
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}`);