@dolusoft/claude-collab 1.8.6 → 1.9.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
@@ -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;
515
444
  }
516
445
  // ---------------------------------------------------------------------------
517
- // Private: connection management
446
+ // Private: server startup
518
447
  // ---------------------------------------------------------------------------
519
- async connectAndHello() {
520
- const ws = new WebSocket(this.serverUrl);
521
- this.ws = ws;
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
+ });
483
+ }
484
+ // ---------------------------------------------------------------------------
485
+ // Private: discovery + outbound connections
486
+ // ---------------------------------------------------------------------------
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,108 @@ ${answerLine}`;
953
772
  };
954
773
  });
955
774
  }
956
- function registerStartHubTool(server, client, hubManager) {
775
+ function runElevated(argArray) {
776
+ const argList = argArray.map((a) => `"${a}"`).join(",");
777
+ const psCommand = `Start-Process -FilePath "netsh" -ArgumentList @(${argList}) -Verb RunAs -Wait`;
778
+ return new Promise((resolve, reject) => {
779
+ const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand]);
780
+ ps.on("close", (code) => {
781
+ if (code === 0) resolve();
782
+ else reject(new Error(`Firewall UAC prompt was cancelled or denied (exit code ${code}).`));
783
+ });
784
+ ps.on("error", (err) => {
785
+ reject(new Error(`Failed to launch PowerShell: ${err.message}`));
786
+ });
787
+ });
788
+ }
789
+ async function addFirewallRule(port) {
790
+ await runElevated([
791
+ "advfirewall",
792
+ "firewall",
793
+ "add",
794
+ "rule",
795
+ `name=claude-collab-${port}`,
796
+ "protocol=TCP",
797
+ "dir=in",
798
+ `localport=${port}`,
799
+ "action=allow"
800
+ ]);
801
+ }
802
+ async function removeFirewallRule(port) {
803
+ await runElevated([
804
+ "advfirewall",
805
+ "firewall",
806
+ "delete",
807
+ "rule",
808
+ `name=claude-collab-${port}`
809
+ ]);
810
+ }
811
+
812
+ // src/presentation/mcp/tools/firewall-open.tool.ts
813
+ function registerFirewallOpenTool(server, client) {
957
814
  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);
815
+ "firewall_open",
816
+ "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.",
817
+ {
818
+ port: z.number().min(1024).max(65535).optional().describe("Port to open (defaults to your current listen port)")
819
+ },
820
+ async ({ port }) => {
821
+ const targetPort = port ?? client.getInfo().port;
822
+ if (!targetPort) {
976
823
  return {
977
- content: [{ type: "text", text: `Failed to start hub: ${msg}` }]
824
+ content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
825
+ isError: true
978
826
  };
979
827
  }
980
828
  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) {
829
+ await addFirewallRule(targetPort);
1009
830
  return {
1010
831
  content: [{
1011
832
  type: "text",
1012
- text: "No hub is currently running on this machine."
833
+ text: [
834
+ `Firewall rule opened for port ${targetPort} (rule name: claude-collab-${targetPort}).`,
835
+ `Peers on the LAN can now connect to you directly.`
836
+ ].join("\n")
1013
837
  }]
1014
838
  };
1015
- }
1016
- const port = hubManager.port;
1017
- let firewallRemoved = false;
1018
- try {
1019
- const result = await hubManager.stop();
1020
- firewallRemoved = result.firewallRemoved;
1021
839
  } catch (err) {
1022
840
  const msg = err instanceof Error ? err.message : String(err);
1023
841
  return {
1024
- content: [{ type: "text", text: `Failed to stop hub: ${msg}` }]
842
+ content: [{ type: "text", text: `Failed to open firewall: ${msg}` }],
843
+ isError: true
1025
844
  };
1026
845
  }
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
846
  }
1036
847
  );
1037
848
  }
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) {
849
+ function registerFirewallCloseTool(server, client) {
1091
850
  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();
851
+ "firewall_close",
852
+ "Remove the Windows Firewall inbound rule for your P2P listen port. A UAC popup will appear \u2014 accept it to close the rule.",
853
+ {
854
+ port: z.number().min(1024).max(65535).optional().describe("Port to close (defaults to your current listen port)")
855
+ },
856
+ async ({ port }) => {
857
+ const targetPort = port ?? client.getInfo().port;
858
+ if (!targetPort) {
1098
859
  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
- }]
860
+ content: [{ type: "text", text: "Could not determine port. Pass port explicitly." }],
861
+ isError: true
1109
862
  };
1110
863
  }
1111
864
  try {
1112
- await client.connectToHub(`ws://${hub.host}:${hub.port}`);
1113
- const info = client.getInfo();
865
+ await removeFirewallRule(targetPort);
1114
866
  return {
1115
867
  content: [{
1116
868
  type: "text",
1117
- text: `Connected to hub at ${hub.host}:${hub.port} as "${info.teamName}".`
869
+ text: `Firewall rule removed for port ${targetPort} (rule name: claude-collab-${targetPort}).`
1118
870
  }]
1119
871
  };
1120
872
  } catch (err) {
1121
873
  const msg = err instanceof Error ? err.message : String(err);
1122
874
  return {
1123
- content: [{ type: "text", text: `Hub found at ${hub.host}:${hub.port} but connection failed: ${msg}` }]
875
+ content: [{ type: "text", text: `Failed to close firewall: ${msg}` }],
876
+ isError: true
1124
877
  };
1125
878
  }
1126
879
  }
@@ -1129,7 +882,7 @@ function registerConnectTool(server, client) {
1129
882
 
1130
883
  // src/presentation/mcp/server.ts
1131
884
  function createMcpServer(options) {
1132
- const { client, hubManager } = options;
885
+ const { client } = options;
1133
886
  const server = new McpServer({
1134
887
  name: "claude-collab",
1135
888
  version: "0.1.0"
@@ -1138,9 +891,8 @@ function createMcpServer(options) {
1138
891
  registerReplyTool(server, client);
1139
892
  registerPeersTool(server, client);
1140
893
  registerHistoryTool(server, client);
1141
- registerStartHubTool(server, client, hubManager);
1142
- registerStopHubTool(server, hubManager);
1143
- registerConnectTool(server, client);
894
+ registerFirewallOpenTool(server, client);
895
+ registerFirewallCloseTool(server, client);
1144
896
  return server;
1145
897
  }
1146
898
  async function startMcpServer(options) {
@@ -1150,59 +902,12 @@ async function startMcpServer(options) {
1150
902
  }
1151
903
 
1152
904
  // src/cli.ts
905
+ var P2P_PORT = 9999;
1153
906
  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
- }
907
+ 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) => {
908
+ const node = new P2PNode(P2P_PORT);
909
+ await node.join(options.name, options.name);
910
+ await startMcpServer({ client: node });
1206
911
  });
1207
912
  program.parse();
1208
913
  //# sourceMappingURL=cli.js.map