@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/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
- var MULTICAST_ADDR = "239.255.42.42";
23
- var MULTICAST_PORT = 11776;
24
- var HEARTBEAT_INTERVAL_MS = 5e3;
25
- var PEER_TIMEOUT_MS = 2e4;
26
- var MulticastDiscovery = class extends EventEmitter {
27
- socket = null;
28
- heartbeatTimer = null;
29
- timeoutTimer = null;
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
- sendUnicast(toIp) {
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
- handleMessage(msg, fromIp) {
112
- if (msg.type === "ANNOUNCE") {
113
- if (msg.name === this.myName) return;
114
- const existing = this.peers.get(msg.name);
115
- if (!existing) {
116
- const peer = { name: msg.name, ip: fromIp, wsPort: msg.wsPort, lastSeen: Date.now() };
117
- this.peers.set(msg.name, peer);
118
- console.error(`[multicast] discovered peer: ${msg.name} @ ${fromIp}:${msg.wsPort}`);
119
- } else {
120
- existing.lastSeen = Date.now();
121
- existing.ip = fromIp;
122
- existing.wsPort = msg.wsPort;
123
- }
124
- if (!msg.unicast) {
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 this.boundPort;
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: this.boundPort
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: this.boundPort,
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
- const [min, max] = this.portRange;
545
- const pick = () => Math.floor(Math.random() * (max - min + 1)) + min;
546
- const tryBind = (attemptsLeft) => {
547
- if (attemptsLeft === 0) {
548
- return Promise.reject(new Error(`No free port found in range ${min}-${max}`));
549
- }
550
- const port = pick();
551
- return new Promise((resolve, reject) => {
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
- return tryBind(20);
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.handleMessage(ws, parse(data.toString()));
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: discovery + outbound connections
548
+ // Private: mesh helpers
601
549
  // ---------------------------------------------------------------------------
602
- startDiscovery() {
603
- const discovery = new MulticastDiscovery();
604
- this.discovery = discovery;
605
- discovery.on("peer-found", (peer) => {
606
- if (this.peerConnections.has(peer.name)) return;
607
- if (this.connectingPeers.has(peer.name)) return;
608
- this.connectToPeer(peer.name, peer.ip, peer.wsPort);
609
- });
610
- discovery.start(this.myName, this.boundPort);
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
- connectToPeer(peerName, host, port) {
613
- this.connectingPeers.add(peerName);
614
- const ws = new WebSocket(`ws://${host}:${port}`);
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(peerName);
626
- const name = this.wsToName.get(ws);
627
- if (name) {
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(name) === ws) {
630
- this.peerConnections.delete(name);
631
- console.error(`[p2p] disconnected from peer: ${name}`);
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 "${peerName}" failed: ${err.message}`);
637
- this.connectingPeers.delete(peerName);
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 "HELLO": {
647
- if (this.peerConnections.has(msg.name)) {
648
- ws.terminate();
649
- return;
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
- case "HELLO_ACK": {
660
- if (this.peerConnections.has(msg.name)) {
661
- ws.terminate();
662
- return;
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 = `Show your identity on the P2P network and list all currently connected peers.
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 startup \u2014 to verify you joined the network successfully
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 the port you are listening on
897
- - All peers who have established a direct connection to you
942
+ - Your own name and port
943
+ - All peers with an active direct connection
898
944
 
899
945
  IF NO PEERS ARE LISTED:
900
- - Peers discover each other automatically via LAN broadcast every 3 seconds
901
- - If a peer just started, wait a few seconds and call peers() again
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
- `Peers auto-discover via LAN broadcast \u2014 wait a few seconds if they just started.`,
931
- `Call firewall_open() if peers on other machines cannot reach you.`
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 list = connected.map((name) => ` \u2022 ${name}`).join("\n");
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
- WHAT IT DOES:
1051
- 1. Opens your firewall so peers can connect inbound to you (UAC popup)
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
- - First time setup: everyone on the team calls peer_find
1057
- - Adding a new peer to an existing session: only the NEW peer calls peer_find
1058
- (existing peers will connect to them automatically \u2014 no action needed from others)
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
- HOW NEW PEERS JOIN AN EXISTING SESSION:
1062
- Existing peers always listen for multicast announcements in the background.
1063
- When you call peer_find, they hear your announcement and connect OUTBOUND to you.
1064
- Outbound connections do not require a firewall rule on their side.
1065
- You only need your own firewall open to accept those inbound connections.
1066
-
1067
- NOTE: Two UAC popups will appear \u2014 one to open, one to close after the wait.`;
1068
- function registerPeerFindTool(server, client) {
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: [{ type: "text", text: lines.join("\n") }]
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("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) => {
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] P2P server failed to start: ${err.message}`);
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
  });