@dolusoft/claude-collab 1.8.6 → 1.9.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,136 +1,83 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { WebSocketServer, WebSocket } from 'ws';
3
+ import { WebSocket, WebSocketServer } from 'ws';
4
4
  import { v4 } from 'uuid';
5
+ import { createSocket } from 'dgram';
5
6
  import { EventEmitter } from 'events';
6
7
  import { execFile, spawn } from 'child_process';
7
8
  import { unlinkSync } from 'fs';
8
9
  import { tmpdir } from 'os';
9
10
  import { join } from 'path';
10
- import { createSocket } from 'dgram';
11
11
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
13
  import { z } from 'zod';
14
14
 
15
- // src/infrastructure/hub/hub-protocol.ts
15
+ // src/infrastructure/p2p/p2p-protocol.ts
16
16
  function serialize(msg) {
17
17
  return JSON.stringify(msg);
18
18
  }
19
19
  function parse(data) {
20
20
  return JSON.parse(data);
21
21
  }
22
-
23
- // src/infrastructure/hub/hub-server.ts
24
- var HubServer = class {
25
- wss = null;
26
- clients = /* @__PURE__ */ new Map();
27
- // name → ws
28
- wsToName = /* @__PURE__ */ new Map();
29
- // ws → name
30
- start(port) {
31
- this.wss = new WebSocketServer({ port });
32
- this.wss.on("listening", () => {
33
- console.log(`claude-collab hub running on port ${port}`);
34
- console.log("Waiting for peers...\n");
35
- });
36
- this.wss.on("connection", (ws) => {
37
- ws.on("message", (data) => {
38
- try {
39
- const msg = parse(data.toString());
40
- this.handleMessage(ws, msg);
41
- } catch {
42
- }
43
- });
44
- ws.on("close", () => {
45
- const name = this.wsToName.get(ws);
46
- if (name) {
47
- this.clients.delete(name);
48
- this.wsToName.delete(ws);
49
- this.broadcast({ type: "PEER_LEFT", name });
50
- console.log(`\u2190 ${name} left (${this.clients.size} online: ${[...this.clients.keys()].join(", ") || "none"})`);
51
- }
52
- });
53
- ws.on("error", (err) => {
54
- console.error("[hub] ws error:", err.message);
55
- });
22
+ var PEER_DISCOVERY_PORT = 9998;
23
+ var BROADCAST_INTERVAL_MS = 3e3;
24
+ var BROADCAST_ADDRESS = "255.255.255.255";
25
+ var PeerBroadcaster = class {
26
+ socket = null;
27
+ timer = null;
28
+ start(name, port) {
29
+ if (this.socket) return;
30
+ const socket = createSocket("udp4");
31
+ this.socket = socket;
32
+ socket.on("error", (err) => {
33
+ console.error("[peer-broadcaster] error:", err.message);
56
34
  });
57
- this.wss.on("error", (err) => {
58
- console.error("[hub] server error:", err.message);
35
+ socket.bind(0, () => {
36
+ socket.setBroadcast(true);
37
+ const send = () => {
38
+ if (!this.socket) return;
39
+ const msg = Buffer.from(JSON.stringify({ type: "claude-collab-peer", name, port }));
40
+ socket.send(msg, 0, msg.length, PEER_DISCOVERY_PORT, BROADCAST_ADDRESS, (err) => {
41
+ if (err) console.error("[peer-broadcaster] send error:", err.message);
42
+ });
43
+ };
44
+ send();
45
+ this.timer = setInterval(send, BROADCAST_INTERVAL_MS);
59
46
  });
60
47
  }
61
48
  stop() {
62
- if (!this.wss) return;
63
- for (const ws of this.clients.values()) ws.terminate();
64
- this.clients.clear();
65
- this.wsToName.clear();
66
- this.wss.close();
67
- this.wss = null;
68
- console.log("claude-collab hub stopped");
69
- }
70
- handleMessage(ws, msg) {
71
- switch (msg.type) {
72
- case "HELLO": {
73
- const existing = this.clients.get(msg.name);
74
- const isReconnect = existing != null && existing !== ws;
75
- if (isReconnect) {
76
- this.wsToName.delete(existing);
77
- existing.close();
78
- }
79
- this.clients.set(msg.name, ws);
80
- this.wsToName.set(ws, msg.name);
81
- const peers = [...this.clients.keys()].filter((n) => n !== msg.name);
82
- const ack = { type: "HELLO_ACK", peers };
83
- this.send(ws, ack);
84
- if (!isReconnect) {
85
- this.broadcast({ type: "PEER_JOINED", name: msg.name }, ws);
86
- }
87
- console.log(`\u2192 ${msg.name} joined (${this.clients.size} online: ${[...this.clients.keys()].join(", ")})`);
88
- break;
89
- }
90
- case "ASK": {
91
- const target = this.clients.get(msg.to);
92
- if (!target) {
93
- const err = {
94
- type: "HUB_ERROR",
95
- code: "PEER_NOT_FOUND",
96
- message: `'${msg.to}' is not connected to the hub`
97
- };
98
- this.send(ws, err);
99
- return;
100
- }
101
- this.send(target, msg);
102
- const ack = { type: "ASK_ACK", questionId: msg.questionId, requestId: msg.requestId };
103
- this.send(ws, ack);
104
- break;
105
- }
106
- case "GET_ANSWER": {
107
- const target = this.clients.get(msg.to);
108
- if (!target) {
109
- this.send(ws, { type: "ANSWER_PENDING", to: msg.from, questionId: msg.questionId, requestId: msg.requestId });
110
- return;
111
- }
112
- this.send(target, msg);
113
- break;
114
- }
115
- case "ANSWER":
116
- case "ANSWER_PENDING": {
117
- const target = this.clients.get(msg.to);
118
- if (target) this.send(target, msg);
119
- break;
120
- }
121
- }
122
- }
123
- send(ws, msg) {
124
- if (ws.readyState === WebSocket.OPEN) {
125
- ws.send(serialize(msg));
49
+ if (this.timer) {
50
+ clearInterval(this.timer);
51
+ this.timer = null;
126
52
  }
127
- }
128
- broadcast(msg, except) {
129
- for (const ws of this.clients.values()) {
130
- if (ws !== except) this.send(ws, msg);
53
+ if (this.socket) {
54
+ this.socket.close();
55
+ this.socket = null;
131
56
  }
132
57
  }
133
58
  };
59
+ function watchForPeer(onFound) {
60
+ const socket = createSocket("udp4");
61
+ socket.on("error", (err) => {
62
+ console.error("[peer-listener] error:", err.message);
63
+ });
64
+ socket.on("message", (msg, rinfo) => {
65
+ try {
66
+ const data = JSON.parse(msg.toString());
67
+ if (data.type === "claude-collab-peer" && typeof data.name === "string" && typeof data.port === "number") {
68
+ onFound({ name: data.name, host: rinfo.address, port: data.port });
69
+ }
70
+ } catch {
71
+ }
72
+ });
73
+ socket.bind(PEER_DISCOVERY_PORT, "0.0.0.0");
74
+ return () => {
75
+ try {
76
+ socket.close();
77
+ } catch {
78
+ }
79
+ };
80
+ }
134
81
  var CS_CONINJECT = `
135
82
  using System;
136
83
  using System.Collections.Generic;
@@ -349,93 +296,73 @@ var InjectionQueue = class extends EventEmitter {
349
296
  };
350
297
  var injectionQueue = new InjectionQueue();
351
298
 
352
- // src/infrastructure/hub/hub-client.ts
353
- var HubClient = class {
354
- ws = null;
299
+ // src/infrastructure/p2p/p2p-node.ts
300
+ var P2PNode = class {
301
+ constructor(port) {
302
+ this.port = port;
303
+ }
304
+ server = null;
355
305
  myName = "";
356
- serverUrl = "";
357
- reconnectTimer = null;
358
- connectedPeers = /* @__PURE__ */ new Set();
306
+ running = false;
307
+ // One connection per peer (inbound or outbound — whichever was established first)
308
+ peerConnections = /* @__PURE__ */ new Map();
309
+ // Reverse map: ws → peer name (only for registered connections)
310
+ wsToName = /* @__PURE__ */ new Map();
311
+ // Prevent duplicate outbound connect attempts
312
+ connectingPeers = /* @__PURE__ */ new Set();
359
313
  incomingQuestions = /* @__PURE__ */ new Map();
360
314
  receivedAnswers = /* @__PURE__ */ new Map();
361
- questionToName = /* @__PURE__ */ new Map();
362
- questionToSender = /* @__PURE__ */ new Map();
363
315
  sentQuestions = /* @__PURE__ */ new Map();
316
+ questionToSender = /* @__PURE__ */ new Map();
364
317
  pendingHandlers = /* @__PURE__ */ new Set();
365
- setServerUrl(url) {
366
- this.serverUrl = url;
367
- }
318
+ broadcaster = null;
319
+ stopPeerWatcher = null;
320
+ // ---------------------------------------------------------------------------
321
+ // ICollabClient implementation
322
+ // ---------------------------------------------------------------------------
368
323
  get isConnected() {
369
- return this.ws?.readyState === WebSocket.OPEN;
324
+ return this.running;
370
325
  }
371
326
  get currentTeamId() {
372
327
  return this.myName || void 0;
373
328
  }
374
329
  async join(name, displayName) {
375
330
  this.myName = name;
376
- if (this.serverUrl) {
377
- await this.connectAndHello();
378
- }
331
+ await this.startServer();
332
+ this.startDiscovery();
379
333
  return {
380
334
  memberId: v4(),
381
335
  teamId: name,
382
336
  teamName: name,
383
337
  displayName,
384
338
  status: "ONLINE",
385
- port: 0
339
+ port: this.port
386
340
  };
387
341
  }
388
- async connectToHub(url) {
389
- this.serverUrl = url;
390
- await this.connectAndHello();
391
- }
392
342
  async ask(toPeer, content, format) {
343
+ const ws = this.peerConnections.get(toPeer);
344
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
345
+ throw new Error(`Peer "${toPeer}" is not connected. Use peers() to see who's online.`);
346
+ }
393
347
  const questionId = v4();
394
- const requestId = v4();
395
- this.questionToName.set(questionId, toPeer);
396
348
  this.sentQuestions.set(questionId, { toPeer, content, askedAt: (/* @__PURE__ */ new Date()).toISOString() });
397
349
  const ackPromise = this.waitForResponse(
398
- (m) => m.type === "ASK_ACK" && m.requestId === requestId,
350
+ (m) => m.type === "ASK_ACK" && m.questionId === questionId,
399
351
  5e3
400
352
  );
401
- this.sendToHub({ type: "ASK", from: this.myName, to: toPeer, questionId, requestId, content, format });
353
+ this.sendToWs(ws, { type: "ASK", from: this.myName, questionId, content, format });
402
354
  await ackPromise;
403
355
  return questionId;
404
356
  }
405
357
  async checkAnswer(questionId) {
406
358
  const cached = this.receivedAnswers.get(questionId);
407
- if (cached) {
408
- return {
409
- questionId,
410
- from: { displayName: `${cached.fromName} Claude`, teamName: cached.fromName },
411
- content: cached.content,
412
- format: cached.format,
413
- answeredAt: cached.answeredAt
414
- };
415
- }
416
- const toPeer = this.questionToName.get(questionId);
417
- if (!toPeer || !this.isConnected) return null;
418
- const requestId = v4();
419
- const responsePromise = this.waitForResponse(
420
- (m) => m.type === "ANSWER" && m.questionId === questionId || m.type === "ANSWER_PENDING" && m.requestId === requestId,
421
- 5e3
422
- );
423
- this.sendToHub({ type: "GET_ANSWER", from: this.myName, to: toPeer, questionId, requestId });
424
- const response = await responsePromise;
425
- if (response.type === "ANSWER_PENDING") return null;
426
- const answer = response;
427
- this.receivedAnswers.set(questionId, {
428
- content: answer.content,
429
- format: answer.format,
430
- answeredAt: answer.answeredAt,
431
- fromName: answer.from
432
- });
359
+ if (!cached) return null;
433
360
  return {
434
361
  questionId,
435
- from: { displayName: `${answer.from} Claude`, teamName: answer.from },
436
- content: answer.content,
437
- format: answer.format,
438
- answeredAt: answer.answeredAt
362
+ from: { displayName: `${cached.fromName} Claude`, teamName: cached.fromName },
363
+ content: cached.content,
364
+ format: cached.format,
365
+ answeredAt: cached.answeredAt
439
366
  };
440
367
  }
441
368
  async reply(questionId, content, format) {
@@ -446,15 +373,17 @@ var HubClient = class {
446
373
  question.answerFormat = format;
447
374
  const senderName = this.questionToSender.get(questionId);
448
375
  if (senderName) {
449
- this.sendToHub({
450
- type: "ANSWER",
451
- from: this.myName,
452
- to: senderName,
453
- questionId,
454
- content,
455
- format,
456
- answeredAt: (/* @__PURE__ */ new Date()).toISOString()
457
- });
376
+ const ws = this.peerConnections.get(senderName);
377
+ if (ws && ws.readyState === WebSocket.OPEN) {
378
+ this.sendToWs(ws, {
379
+ type: "ANSWER",
380
+ from: this.myName,
381
+ questionId,
382
+ content,
383
+ format,
384
+ answeredAt: (/* @__PURE__ */ new Date()).toISOString()
385
+ });
386
+ }
458
387
  }
459
388
  injectionQueue.notifyReplied();
460
389
  }
@@ -474,8 +403,8 @@ var HubClient = class {
474
403
  getInfo() {
475
404
  return {
476
405
  teamName: this.myName,
477
- port: void 0,
478
- connectedPeers: [...this.connectedPeers]
406
+ port: this.port,
407
+ connectedPeers: [...this.peerConnections.keys()]
479
408
  };
480
409
  }
481
410
  getHistory() {
@@ -487,107 +416,143 @@ var HubClient = class {
487
416
  questionId,
488
417
  peer: sent.toPeer,
489
418
  question: sent.content,
490
- answer: answer?.content,
491
419
  askedAt: sent.askedAt,
492
- answeredAt: answer?.answeredAt
420
+ ...answer ? { answer: answer.content, answeredAt: answer.answeredAt } : {}
493
421
  });
494
422
  }
495
- for (const [questionId, incoming] of this.incomingQuestions) {
423
+ for (const [, incoming] of this.incomingQuestions) {
496
424
  entries.push({
497
425
  direction: "received",
498
- questionId,
426
+ questionId: incoming.questionId,
499
427
  peer: incoming.fromName,
500
428
  question: incoming.content,
501
- answer: incoming.answered ? incoming.answerContent : void 0,
502
429
  askedAt: incoming.createdAt.toISOString(),
503
- answeredAt: incoming.answered ? (/* @__PURE__ */ new Date()).toISOString() : void 0
430
+ ...incoming.answered && incoming.answerContent ? { answer: incoming.answerContent, answeredAt: (/* @__PURE__ */ new Date()).toISOString() } : {}
504
431
  });
505
432
  }
506
433
  return entries.sort((a, b) => a.askedAt.localeCompare(b.askedAt));
507
434
  }
508
435
  async disconnect() {
509
- if (this.reconnectTimer) {
510
- clearTimeout(this.reconnectTimer);
511
- this.reconnectTimer = null;
512
- }
513
- this.ws?.close();
514
- this.ws = null;
436
+ this.stopPeerWatcher?.();
437
+ this.broadcaster?.stop();
438
+ for (const ws of this.peerConnections.values()) ws.close();
439
+ this.peerConnections.clear();
440
+ this.wsToName.clear();
441
+ this.server?.close();
442
+ this.server = null;
443
+ this.running = false;
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // Private: server startup
447
+ // ---------------------------------------------------------------------------
448
+ startServer() {
449
+ return new Promise((resolve, reject) => {
450
+ const wss = new WebSocketServer({ port: this.port });
451
+ this.server = wss;
452
+ wss.on("listening", () => {
453
+ this.running = true;
454
+ console.error(`[p2p] listening on port ${this.port} as "${this.myName}"`);
455
+ resolve();
456
+ });
457
+ wss.on("error", (err) => {
458
+ if (!this.running) reject(err);
459
+ else console.error("[p2p] server error:", err.message);
460
+ });
461
+ wss.on("connection", (ws) => {
462
+ ws.on("message", (data) => {
463
+ try {
464
+ this.handleMessage(ws, parse(data.toString()));
465
+ } catch {
466
+ }
467
+ });
468
+ ws.on("close", () => {
469
+ const name = this.wsToName.get(ws);
470
+ if (name) {
471
+ this.wsToName.delete(ws);
472
+ if (this.peerConnections.get(name) === ws) {
473
+ this.peerConnections.delete(name);
474
+ console.error(`[p2p] peer disconnected (inbound): ${name}`);
475
+ }
476
+ }
477
+ });
478
+ ws.on("error", (err) => {
479
+ console.error("[p2p] inbound ws error:", err.message);
480
+ });
481
+ });
482
+ });
515
483
  }
516
484
  // ---------------------------------------------------------------------------
517
- // Private: connection management
485
+ // Private: discovery + outbound connections
518
486
  // ---------------------------------------------------------------------------
519
- async connectAndHello() {
520
- const ws = new WebSocket(this.serverUrl);
521
- this.ws = ws;
487
+ startDiscovery() {
488
+ this.broadcaster = new PeerBroadcaster();
489
+ this.broadcaster.start(this.myName, this.port);
490
+ this.stopPeerWatcher = watchForPeer((peer) => {
491
+ if (peer.name === this.myName) return;
492
+ if (this.peerConnections.has(peer.name)) return;
493
+ if (this.connectingPeers.has(peer.name)) return;
494
+ this.connectToPeer(peer.name, peer.host, peer.port);
495
+ });
496
+ }
497
+ connectToPeer(peerName, host, port) {
498
+ this.connectingPeers.add(peerName);
499
+ const ws = new WebSocket(`ws://${host}:${port}`);
500
+ ws.on("open", () => {
501
+ this.sendToWs(ws, { type: "HELLO", name: this.myName });
502
+ });
522
503
  ws.on("message", (data) => {
523
504
  try {
524
- const msg = parse(data.toString());
525
- this.handleMessage(msg);
505
+ this.handleMessage(ws, parse(data.toString()));
526
506
  } catch {
527
507
  }
528
508
  });
529
509
  ws.on("close", () => {
530
- this.connectedPeers.clear();
531
- this.scheduleReconnect();
510
+ this.connectingPeers.delete(peerName);
511
+ const name = this.wsToName.get(ws);
512
+ if (name) {
513
+ this.wsToName.delete(ws);
514
+ if (this.peerConnections.get(name) === ws) {
515
+ this.peerConnections.delete(name);
516
+ console.error(`[p2p] disconnected from peer: ${name}`);
517
+ }
518
+ }
532
519
  });
533
520
  ws.on("error", (err) => {
534
- console.error("[hub-client] error:", err.message);
535
- });
536
- await new Promise((resolve, reject) => {
537
- const timeout = setTimeout(
538
- () => reject(new Error(`Cannot connect to hub at ${this.serverUrl}`)),
539
- 1e4
540
- );
541
- ws.on("open", () => {
542
- clearTimeout(timeout);
543
- ws.send(serialize({ type: "HELLO", name: this.myName }));
544
- resolve();
545
- });
546
- ws.on("error", (err) => {
547
- clearTimeout(timeout);
548
- reject(err);
549
- });
521
+ console.error(`[p2p] connect to "${peerName}" failed: ${err.message}`);
522
+ this.connectingPeers.delete(peerName);
550
523
  });
551
- const ack = await this.waitForResponse(
552
- (m) => m.type === "HELLO_ACK",
553
- 1e4
554
- );
555
- for (const peer of ack.peers) this.connectedPeers.add(peer);
556
- console.error(`[hub-client] connected as "${this.myName}", peers: [${ack.peers.join(", ")}]`);
557
- }
558
- scheduleReconnect() {
559
- if (this.reconnectTimer) return;
560
- this.reconnectTimer = setTimeout(async () => {
561
- this.reconnectTimer = null;
562
- try {
563
- await this.connectAndHello();
564
- console.error("[hub-client] reconnected to hub");
565
- } catch (err) {
566
- const msg = err instanceof Error ? err.message : String(err);
567
- console.error(`[hub-client] reconnect failed: ${msg}, retrying in 5s...`);
568
- this.scheduleReconnect();
569
- }
570
- }, 5e3);
571
524
  }
572
525
  // ---------------------------------------------------------------------------
573
526
  // Private: message handling
574
527
  // ---------------------------------------------------------------------------
575
- handleMessage(msg) {
528
+ handleMessage(ws, msg) {
576
529
  for (const handler of this.pendingHandlers) handler(msg);
577
530
  switch (msg.type) {
578
- case "PEER_JOINED":
579
- this.connectedPeers.add(msg.name);
580
- console.error(`[hub-client] peer joined: ${msg.name}`);
531
+ case "HELLO": {
532
+ if (this.peerConnections.has(msg.name)) {
533
+ ws.terminate();
534
+ return;
535
+ }
536
+ this.peerConnections.set(msg.name, ws);
537
+ this.wsToName.set(ws, msg.name);
538
+ this.connectingPeers.delete(msg.name);
539
+ this.sendToWs(ws, { type: "HELLO_ACK", name: this.myName });
540
+ console.error(`[p2p] peer joined (inbound): ${msg.name}`);
581
541
  break;
582
- case "PEER_LEFT":
583
- this.connectedPeers.delete(msg.name);
584
- console.error(`[hub-client] peer left: ${msg.name}`);
542
+ }
543
+ case "HELLO_ACK": {
544
+ if (this.peerConnections.has(msg.name)) {
545
+ ws.terminate();
546
+ return;
547
+ }
548
+ this.peerConnections.set(msg.name, ws);
549
+ this.wsToName.set(ws, msg.name);
550
+ this.connectingPeers.delete(msg.name);
551
+ console.error(`[p2p] connected to peer: ${msg.name}`);
585
552
  break;
553
+ }
586
554
  case "ASK":
587
- this.handleIncomingAsk(msg);
588
- break;
589
- case "GET_ANSWER":
590
- this.handleGetAnswer(msg);
555
+ this.handleIncomingAsk(ws, msg);
591
556
  break;
592
557
  case "ANSWER":
593
558
  if (!this.receivedAnswers.has(msg.questionId)) {
@@ -599,12 +564,9 @@ var HubClient = class {
599
564
  });
600
565
  }
601
566
  break;
602
- case "HUB_ERROR":
603
- console.error(`[hub-client] hub error: ${msg.message}`);
604
- break;
605
567
  }
606
568
  }
607
- handleIncomingAsk(msg) {
569
+ handleIncomingAsk(ws, msg) {
608
570
  this.questionToSender.set(msg.questionId, msg.from);
609
571
  this.incomingQuestions.set(msg.questionId, {
610
572
  questionId: msg.questionId,
@@ -614,6 +576,7 @@ var HubClient = class {
614
576
  createdAt: /* @__PURE__ */ new Date(),
615
577
  answered: false
616
578
  });
579
+ this.sendToWs(ws, { type: "ASK_ACK", questionId: msg.questionId });
617
580
  injectionQueue.enqueue({
618
581
  questionId: msg.questionId,
619
582
  from: { displayName: `${msg.from} Claude`, teamName: msg.from },
@@ -624,39 +587,16 @@ var HubClient = class {
624
587
  ageMs: 0
625
588
  });
626
589
  }
627
- handleGetAnswer(msg) {
628
- const question = this.incomingQuestions.get(msg.questionId);
629
- if (!question?.answered) {
630
- this.sendToHub({
631
- type: "ANSWER_PENDING",
632
- to: msg.from,
633
- questionId: msg.questionId,
634
- requestId: msg.requestId
635
- });
636
- return;
637
- }
638
- this.sendToHub({
639
- type: "ANSWER",
640
- from: this.myName,
641
- to: msg.from,
642
- questionId: msg.questionId,
643
- content: question.answerContent,
644
- format: question.answerFormat,
645
- answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
646
- requestId: msg.requestId
647
- });
648
- }
649
- sendToHub(msg) {
650
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
651
- throw new Error("Not connected to hub. Will retry automatically.");
590
+ sendToWs(ws, msg) {
591
+ if (ws.readyState === WebSocket.OPEN) {
592
+ ws.send(serialize(msg));
652
593
  }
653
- this.ws.send(serialize(msg));
654
594
  }
655
595
  waitForResponse(filter, timeoutMs) {
656
596
  return new Promise((resolve, reject) => {
657
597
  const timeout = setTimeout(() => {
658
598
  this.pendingHandlers.delete(handler);
659
- reject(new Error("Hub request timed out"));
599
+ reject(new Error("Request timed out"));
660
600
  }, timeoutMs);
661
601
  const handler = (msg) => {
662
602
  if (filter(msg)) {
@@ -669,129 +609,6 @@ var HubClient = class {
669
609
  });
670
610
  }
671
611
  };
672
- var DISCOVERY_PORT = 9998;
673
- var BROADCAST_INTERVAL_MS = 3e3;
674
- var BROADCAST_ADDRESS = "255.255.255.255";
675
- var HubBroadcaster = class {
676
- socket = null;
677
- timer = null;
678
- start(hubPort) {
679
- if (this.socket) return;
680
- const socket = createSocket("udp4");
681
- this.socket = socket;
682
- socket.on("error", (err) => {
683
- console.error("[hub-broadcaster] error:", err.message);
684
- });
685
- socket.bind(0, () => {
686
- socket.setBroadcast(true);
687
- const send = () => {
688
- if (!this.socket) return;
689
- const msg = Buffer.from(JSON.stringify({ type: "claude-collab-hub", port: hubPort }));
690
- socket.send(msg, 0, msg.length, DISCOVERY_PORT, BROADCAST_ADDRESS, (err) => {
691
- if (err) console.error("[hub-broadcaster] send error:", err.message);
692
- });
693
- };
694
- send();
695
- this.timer = setInterval(send, BROADCAST_INTERVAL_MS);
696
- });
697
- }
698
- stop() {
699
- if (this.timer) {
700
- clearInterval(this.timer);
701
- this.timer = null;
702
- }
703
- if (this.socket) {
704
- this.socket.close();
705
- this.socket = null;
706
- }
707
- }
708
- };
709
-
710
- // src/infrastructure/hub/hub-manager.ts
711
- var HubManager = class {
712
- hubServer = null;
713
- broadcaster = null;
714
- currentPort = null;
715
- get isRunning() {
716
- return this.hubServer !== null;
717
- }
718
- get port() {
719
- return this.currentPort;
720
- }
721
- async start(port) {
722
- if (this.isRunning) throw new Error("Hub is already running");
723
- const server = new HubServer();
724
- server.start(port);
725
- this.hubServer = server;
726
- this.currentPort = port;
727
- const broadcaster = new HubBroadcaster();
728
- broadcaster.start(port);
729
- this.broadcaster = broadcaster;
730
- let firewallAdded = false;
731
- try {
732
- await addFirewallRule(port);
733
- firewallAdded = true;
734
- } catch (err) {
735
- console.error("[hub-manager] firewall rule failed:", err);
736
- }
737
- return { firewallAdded };
738
- }
739
- async stop() {
740
- if (!this.isRunning) throw new Error("Hub is not running");
741
- if (this.broadcaster) {
742
- this.broadcaster.stop();
743
- this.broadcaster = null;
744
- }
745
- const port = this.currentPort;
746
- this.hubServer.stop();
747
- this.hubServer = null;
748
- this.currentPort = null;
749
- let firewallRemoved = false;
750
- try {
751
- await removeFirewallRule(port);
752
- firewallRemoved = true;
753
- } catch (err) {
754
- console.error("[hub-manager] firewall rule removal failed:", err);
755
- }
756
- return { firewallRemoved };
757
- }
758
- };
759
- function runElevated(argArray) {
760
- const argList = argArray.map((a) => `"${a}"`).join(",");
761
- const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
762
- return new Promise((resolve, reject) => {
763
- const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
764
- ps.on("close", (code) => {
765
- if (code === 0) resolve();
766
- else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
767
- });
768
- ps.on("error", (err) => {
769
- reject(new Error(`Failed to launch PowerShell: ${err.message}`));
770
- });
771
- });
772
- }
773
- async function addFirewallRule(port) {
774
- await runElevated([
775
- "advfirewall",
776
- "firewall",
777
- "add",
778
- "rule",
779
- `name=claude-collab-${port}`,
780
- "protocol=TCP",
781
- "dir=in",
782
- `localport=${port}`,
783
- "action=allow"
784
- ]);
785
- }
786
- async function removeFirewallRule(port) {
787
- await runElevated([
788
- "advfirewall",
789
- "firewall",
790
- "delete",
791
- "rule",
792
- `name=claude-collab-${port}`
793
- ]);
794
- }
795
612
  var askSchema = {
796
613
  peer: z.string().describe('Name of the peer to ask (e.g., "alice", "backend")'),
797
614
  question: z.string().describe("The question to ask (supports markdown)")
@@ -907,12 +724,14 @@ function registerPeersTool(server, client) {
907
724
  server.tool("peers", {}, async () => {
908
725
  const info = client.getInfo();
909
726
  const myName = info.teamName ?? "(starting...)";
727
+ const myPort = info.port ?? "?";
910
728
  const connected = info.connectedPeers;
911
729
  if (connected.length === 0) {
912
730
  return {
913
731
  content: [{
914
732
  type: "text",
915
- text: `You are "${myName}". No peers connected yet \u2014 they will appear automatically when they come online.`
733
+ text: `You are "${myName}" (listening on port ${myPort}). No peers connected yet.
734
+ Use firewall_open to allow inbound connections, or wait for peers to connect to you.`
916
735
  }]
917
736
  };
918
737
  }
@@ -920,7 +739,7 @@ function registerPeersTool(server, client) {
920
739
  return {
921
740
  content: [{
922
741
  type: "text",
923
- text: `You are "${myName}". Connected peers (${connected.length}):
742
+ text: `You are "${myName}" (port ${myPort}). Connected peers (${connected.length}):
924
743
  ${list}`
925
744
  }]
926
745
  };
@@ -953,174 +772,130 @@ ${answerLine}`;
953
772
  };
954
773
  });
955
774
  }
956
- function registerStartHubTool(server, client, hubManager) {
775
+ function runDirect(argArray) {
776
+ return new Promise((resolve, reject) => {
777
+ const proc = spawn("netsh", argArray, { stdio: "ignore" });
778
+ proc.on("close", (code) => {
779
+ if (code === 0) resolve();
780
+ else reject(new Error(`netsh exited with code ${code}`));
781
+ });
782
+ proc.on("error", (err) => reject(new Error(`netsh not found: ${err.message}`)));
783
+ });
784
+ }
785
+ function runElevated(argArray) {
786
+ const argList = argArray.map((a) => `"${a}"`).join(",");
787
+ const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
788
+ return new Promise((resolve, reject) => {
789
+ const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
790
+ ps.on("close", (code) => {
791
+ if (code === 0) resolve();
792
+ else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
793
+ });
794
+ ps.on("error", (err) => {
795
+ reject(new Error(`Failed to launch PowerShell: ${err.message}`));
796
+ });
797
+ });
798
+ }
799
+ async function runNetsh(argArray) {
800
+ try {
801
+ await runDirect(argArray);
802
+ return { method: "direct" };
803
+ } catch {
804
+ await runElevated(argArray);
805
+ return { method: "elevated" };
806
+ }
807
+ }
808
+ async function addFirewallRule(port) {
809
+ return runNetsh([
810
+ "advfirewall",
811
+ "firewall",
812
+ "add",
813
+ "rule",
814
+ `name=claude-collab-${port}`,
815
+ "protocol=TCP",
816
+ "dir=in",
817
+ `localport=${port}`,
818
+ "action=allow"
819
+ ]);
820
+ }
821
+ async function removeFirewallRule(port) {
822
+ return runNetsh([
823
+ "advfirewall",
824
+ "firewall",
825
+ "delete",
826
+ "rule",
827
+ `name=claude-collab-${port}`
828
+ ]);
829
+ }
830
+
831
+ // src/presentation/mcp/tools/firewall-open.tool.ts
832
+ function registerFirewallOpenTool(server, client) {
957
833
  server.tool(
958
- "start_hub",
959
- "Start a hub server on this machine. Opens a firewall rule (UAC prompt) and advertises on LAN via mDNS \u2014 peers connect automatically, no IP sharing needed.",
960
- { port: z.number().min(1024).max(65535).optional().describe("Port to listen on (default: 9999)") },
961
- async ({ port = 9999 }) => {
962
- if (hubManager.isRunning) {
963
- return {
964
- content: [{
965
- type: "text",
966
- text: `Hub is already running on port ${hubManager.port}.`
967
- }]
968
- };
969
- }
970
- let firewallAdded = false;
971
- try {
972
- const result = await hubManager.start(port);
973
- firewallAdded = result.firewallAdded;
974
- } catch (err) {
975
- const msg = err instanceof Error ? err.message : String(err);
834
+ "firewall_open",
835
+ "Open a Windows Firewall inbound rule for your P2P listen port. A UAC popup will appear \u2014 accept it to allow peers to connect to you directly.",
836
+ {
837
+ port: z.number().min(1024).max(65535).optional().describe("Port to open (defaults to your current listen port)")
838
+ },
839
+ async ({ port }) => {
840
+ const targetPort = port ?? client.getInfo().port;
841
+ if (!targetPort) {
976
842
  return {
977
- content: [{ type: "text", text: `Failed to start hub: ${msg}` }]
843
+ content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
844
+ isError: true
978
845
  };
979
846
  }
980
847
  try {
981
- await client.connectToHub(`ws://localhost:${port}`);
982
- } catch (err) {
983
- const msg = err instanceof Error ? err.message : String(err);
984
- return {
985
- content: [{ type: "text", text: `Hub started on port ${port}, but failed to self-connect: ${msg}` }]
986
- };
987
- }
988
- const lines = [
989
- `Hub started on port ${port}.`,
990
- firewallAdded ? `Firewall rule added (claude-collab-${port}) \u2014 LAN peers can connect.` : `WARNING: Firewall rule could not be added (UAC was cancelled or denied). Peers on other machines may be blocked by Windows Firewall. Run start_hub again and accept the UAC prompt to fix this.`,
991
- `Others on the LAN will auto-discover and connect via mDNS.`,
992
- `Use stop_hub when you are done.`
993
- ];
994
- return {
995
- content: [{ type: "text", text: lines.join("\n") }]
996
- };
997
- }
998
- );
999
- }
1000
-
1001
- // src/presentation/mcp/tools/stop-hub.tool.ts
1002
- function registerStopHubTool(server, hubManager) {
1003
- server.tool(
1004
- "stop_hub",
1005
- "Stop the running hub server. Removes the firewall rule (UAC prompt) and stops LAN advertising. Connected peers will be disconnected.",
1006
- {},
1007
- async () => {
1008
- if (!hubManager.isRunning) {
848
+ const { method } = await addFirewallRule(targetPort);
849
+ const how = method === "direct" ? "applied directly (process already elevated)" : "applied via UAC elevation popup";
1009
850
  return {
1010
851
  content: [{
1011
852
  type: "text",
1012
- text: "No hub is currently running on this machine."
853
+ text: [
854
+ `Firewall rule opened for port ${targetPort} (rule name: claude-collab-${targetPort}).`,
855
+ `Method: ${how}.`,
856
+ `Peers on the LAN can now connect to you directly.`
857
+ ].join("\n")
1013
858
  }]
1014
859
  };
1015
- }
1016
- const port = hubManager.port;
1017
- let firewallRemoved = false;
1018
- try {
1019
- const result = await hubManager.stop();
1020
- firewallRemoved = result.firewallRemoved;
1021
860
  } catch (err) {
1022
861
  const msg = err instanceof Error ? err.message : String(err);
1023
862
  return {
1024
- content: [{ type: "text", text: `Failed to stop hub: ${msg}` }]
863
+ content: [{ type: "text", text: `Failed to open firewall: ${msg}` }],
864
+ isError: true
1025
865
  };
1026
866
  }
1027
- const lines = [
1028
- `Hub stopped (was on port ${port}).`,
1029
- firewallRemoved ? `Firewall rule removed (claude-collab-${port}).` : `WARNING: Firewall rule could not be removed (UAC was cancelled). Remove it manually: netsh advfirewall firewall delete rule name="claude-collab-${port}"`,
1030
- `All peers have been disconnected.`
1031
- ];
1032
- return {
1033
- content: [{ type: "text", text: lines.join("\n") }]
1034
- };
1035
867
  }
1036
868
  );
1037
869
  }
1038
- function discoverHub(timeoutMs = 1e4) {
1039
- return new Promise((resolve) => {
1040
- const socket = createSocket("udp4");
1041
- let settled = false;
1042
- const finish = (result) => {
1043
- if (settled) return;
1044
- settled = true;
1045
- clearTimeout(timer);
1046
- try {
1047
- socket.close();
1048
- } catch {
1049
- }
1050
- resolve(result);
1051
- };
1052
- const timer = setTimeout(() => finish(null), timeoutMs);
1053
- socket.on("error", () => finish(null));
1054
- socket.on("message", (msg, rinfo) => {
1055
- try {
1056
- const data = JSON.parse(msg.toString());
1057
- if (data.type === "claude-collab-hub" && typeof data.port === "number") {
1058
- finish({ host: rinfo.address, port: data.port });
1059
- }
1060
- } catch {
1061
- }
1062
- });
1063
- socket.bind(DISCOVERY_PORT, "0.0.0.0");
1064
- });
1065
- }
1066
- function watchForHub(onFound) {
1067
- const socket = createSocket("udp4");
1068
- socket.on("error", (err) => {
1069
- console.error("[hub-listener] error:", err.message);
1070
- });
1071
- socket.on("message", (msg, rinfo) => {
1072
- try {
1073
- const data = JSON.parse(msg.toString());
1074
- if (data.type === "claude-collab-hub" && typeof data.port === "number") {
1075
- onFound({ host: rinfo.address, port: data.port });
1076
- }
1077
- } catch {
1078
- }
1079
- });
1080
- socket.bind(DISCOVERY_PORT, "0.0.0.0");
1081
- return () => {
1082
- try {
1083
- socket.close();
1084
- } catch {
1085
- }
1086
- };
1087
- }
1088
-
1089
- // src/presentation/mcp/tools/connect.tool.ts
1090
- function registerConnectTool(server, client) {
870
+ function registerFirewallCloseTool(server, client) {
1091
871
  server.tool(
1092
- "connect",
1093
- "Find and connect to the hub on the LAN automatically. No IP or port needed.",
1094
- {},
1095
- async () => {
1096
- if (client.isConnected) {
1097
- const info = client.getInfo();
872
+ "firewall_close",
873
+ "Remove the Windows Firewall inbound rule for your P2P listen port. A UAC popup will appear \u2014 accept it to close the rule.",
874
+ {
875
+ port: z.number().min(1024).max(65535).optional().describe("Port to close (defaults to your current listen port)")
876
+ },
877
+ async ({ port }) => {
878
+ const targetPort = port ?? client.getInfo().port;
879
+ if (!targetPort) {
1098
880
  return {
1099
- content: [{ type: "text", text: `Already connected to hub as "${info.teamName}".` }]
1100
- };
1101
- }
1102
- const hub = await discoverHub(1e4);
1103
- if (!hub) {
1104
- return {
1105
- content: [{
1106
- type: "text",
1107
- text: "No hub found on the LAN. Make sure someone has called start_hub on the host machine."
1108
- }]
881
+ content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
882
+ isError: true
1109
883
  };
1110
884
  }
1111
885
  try {
1112
- await client.connectToHub(`ws://${hub.host}:${hub.port}`);
1113
- const info = client.getInfo();
886
+ const { method } = await removeFirewallRule(targetPort);
887
+ const how = method === "direct" ? "applied directly (process already elevated)" : "applied via UAC elevation popup";
1114
888
  return {
1115
889
  content: [{
1116
890
  type: "text",
1117
- text: `Connected to hub at ${hub.host}:${hub.port} as "${info.teamName}".`
891
+ text: `Firewall rule removed for port ${targetPort} (rule name: claude-collab-${targetPort}). Method: ${how}.`
1118
892
  }]
1119
893
  };
1120
894
  } catch (err) {
1121
895
  const msg = err instanceof Error ? err.message : String(err);
1122
896
  return {
1123
- content: [{ type: "text", text: `Hub found at ${hub.host}:${hub.port} but connection failed: ${msg}` }]
897
+ content: [{ type: "text", text: `Failed to close firewall: ${msg}` }],
898
+ isError: true
1124
899
  };
1125
900
  }
1126
901
  }
@@ -1129,7 +904,7 @@ function registerConnectTool(server, client) {
1129
904
 
1130
905
  // src/presentation/mcp/server.ts
1131
906
  function createMcpServer(options) {
1132
- const { client, hubManager } = options;
907
+ const { client } = options;
1133
908
  const server = new McpServer({
1134
909
  name: "claude-collab",
1135
910
  version: "0.1.0"
@@ -1138,9 +913,8 @@ function createMcpServer(options) {
1138
913
  registerReplyTool(server, client);
1139
914
  registerPeersTool(server, client);
1140
915
  registerHistoryTool(server, client);
1141
- registerStartHubTool(server, client, hubManager);
1142
- registerStopHubTool(server, hubManager);
1143
- registerConnectTool(server, client);
916
+ registerFirewallOpenTool(server, client);
917
+ registerFirewallCloseTool(server, client);
1144
918
  return server;
1145
919
  }
1146
920
  async function startMcpServer(options) {
@@ -1150,59 +924,12 @@ async function startMcpServer(options) {
1150
924
  }
1151
925
 
1152
926
  // src/cli.ts
927
+ var P2P_PORT = 9999;
1153
928
  var program = new Command();
1154
- program.name("claude-collab").description("P2P collaboration between Claude Code terminals via a shared hub").version("0.1.0").option("--hub", "Run as hub server (one machine per team)").option("--port <port>", "Hub server port (hub mode, default: 9999)", "9999").option("--name <name>", 'Your name on the network (client mode, e.g. "alice")').option("--server <address>", 'Hub address to connect to (client mode, e.g. "192.168.1.5:9999")').action(async (options) => {
1155
- if (options.hub) {
1156
- const port = parseInt(options.port, 10);
1157
- if (isNaN(port)) {
1158
- console.error(`Invalid port: ${options.port}`);
1159
- process.exit(1);
1160
- }
1161
- const hub = new HubServer();
1162
- hub.start(port);
1163
- process.on("SIGINT", () => {
1164
- console.log("\nHub stopped.");
1165
- process.exit(0);
1166
- });
1167
- } else {
1168
- if (!options.name) {
1169
- console.error("--name is required");
1170
- process.exit(1);
1171
- }
1172
- const client = new HubClient();
1173
- const hubManager = new HubManager();
1174
- if (options.server) {
1175
- const url = options.server.startsWith("ws") ? options.server : `ws://${options.server}`;
1176
- client.setServerUrl(url);
1177
- await client.join(options.name, options.name);
1178
- } else {
1179
- await client.join(options.name, options.name);
1180
- }
1181
- const mcpReady = startMcpServer({ client, hubManager });
1182
- if (!options.server) {
1183
- discoverHub(5e3).then(async (hub) => {
1184
- if (!hub || client.isConnected) return;
1185
- try {
1186
- await client.connectToHub(`ws://${hub.host}:${hub.port}`);
1187
- } catch {
1188
- }
1189
- });
1190
- const stopWatch = watchForHub(async (hub) => {
1191
- if (client.isConnected) {
1192
- stopWatch();
1193
- return;
1194
- }
1195
- try {
1196
- await client.connectToHub(`ws://${hub.host}:${hub.port}`);
1197
- stopWatch();
1198
- } catch (err) {
1199
- const msg = err instanceof Error ? err.message : String(err);
1200
- console.error(`[cli] auto-connect failed: ${msg}`);
1201
- }
1202
- });
1203
- }
1204
- await mcpReady;
1205
- }
929
+ 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) => {
930
+ const node = new P2PNode(P2P_PORT);
931
+ await node.join(options.name, options.name);
932
+ await startMcpServer({ client: node });
1206
933
  });
1207
934
  program.parse();
1208
935
  //# sourceMappingURL=cli.js.map