@dolusoft/claude-collab 1.7.1 → 1.8.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/mcp-main.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { WebSocket } from 'ws';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
3
  import { v4 } from 'uuid';
4
4
  import { EventEmitter } from 'events';
5
- import { execFile } from 'child_process';
5
+ import { execFile, spawn } from 'child_process';
6
6
  import { unlinkSync } from 'fs';
7
7
  import { tmpdir } from 'os';
8
8
  import { join } from 'path';
9
+ import { Bonjour } from 'bonjour-service';
9
10
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
12
  import { z } from 'zod';
@@ -259,7 +260,9 @@ var HubClient = class {
259
260
  }
260
261
  async join(name, displayName) {
261
262
  this.myName = name;
262
- await this.connectAndHello();
263
+ if (this.serverUrl) {
264
+ await this.connectAndHello();
265
+ }
263
266
  return {
264
267
  memberId: v4(),
265
268
  teamId: name,
@@ -269,6 +272,10 @@ var HubClient = class {
269
272
  port: 0
270
273
  };
271
274
  }
275
+ async connectToHub(url) {
276
+ this.serverUrl = url;
277
+ await this.connectAndHello();
278
+ }
272
279
  async ask(toPeer, content, format) {
273
280
  const questionId = v4();
274
281
  const requestId = v4();
@@ -549,6 +556,195 @@ var HubClient = class {
549
556
  });
550
557
  }
551
558
  };
559
+ var HubServer = class {
560
+ wss = null;
561
+ clients = /* @__PURE__ */ new Map();
562
+ // name → ws
563
+ wsToName = /* @__PURE__ */ new Map();
564
+ // ws → name
565
+ start(port) {
566
+ this.wss = new WebSocketServer({ port });
567
+ this.wss.on("listening", () => {
568
+ console.log(`claude-collab hub running on port ${port}`);
569
+ console.log("Waiting for peers...\n");
570
+ });
571
+ this.wss.on("connection", (ws) => {
572
+ ws.on("message", (data) => {
573
+ try {
574
+ const msg = parse(data.toString());
575
+ this.handleMessage(ws, msg);
576
+ } catch {
577
+ }
578
+ });
579
+ ws.on("close", () => {
580
+ const name = this.wsToName.get(ws);
581
+ if (name) {
582
+ this.clients.delete(name);
583
+ this.wsToName.delete(ws);
584
+ this.broadcast({ type: "PEER_LEFT", name });
585
+ console.log(`\u2190 ${name} left (${this.clients.size} online: ${[...this.clients.keys()].join(", ") || "none"})`);
586
+ }
587
+ });
588
+ ws.on("error", (err) => {
589
+ console.error("[hub] ws error:", err.message);
590
+ });
591
+ });
592
+ this.wss.on("error", (err) => {
593
+ console.error("[hub] server error:", err.message);
594
+ });
595
+ }
596
+ stop() {
597
+ if (!this.wss) return;
598
+ for (const ws of this.clients.values()) ws.terminate();
599
+ this.clients.clear();
600
+ this.wsToName.clear();
601
+ this.wss.close();
602
+ this.wss = null;
603
+ console.log("claude-collab hub stopped");
604
+ }
605
+ handleMessage(ws, msg) {
606
+ switch (msg.type) {
607
+ case "HELLO": {
608
+ const existing = this.clients.get(msg.name);
609
+ const isReconnect = existing != null && existing !== ws;
610
+ if (isReconnect) {
611
+ this.wsToName.delete(existing);
612
+ existing.close();
613
+ }
614
+ this.clients.set(msg.name, ws);
615
+ this.wsToName.set(ws, msg.name);
616
+ const peers = [...this.clients.keys()].filter((n) => n !== msg.name);
617
+ const ack = { type: "HELLO_ACK", peers };
618
+ this.send(ws, ack);
619
+ if (!isReconnect) {
620
+ this.broadcast({ type: "PEER_JOINED", name: msg.name }, ws);
621
+ }
622
+ console.log(`\u2192 ${msg.name} joined (${this.clients.size} online: ${[...this.clients.keys()].join(", ")})`);
623
+ break;
624
+ }
625
+ case "ASK": {
626
+ const target = this.clients.get(msg.to);
627
+ if (!target) {
628
+ const err = {
629
+ type: "HUB_ERROR",
630
+ code: "PEER_NOT_FOUND",
631
+ message: `'${msg.to}' is not connected to the hub`
632
+ };
633
+ this.send(ws, err);
634
+ return;
635
+ }
636
+ this.send(target, msg);
637
+ const ack = { type: "ASK_ACK", questionId: msg.questionId, requestId: msg.requestId };
638
+ this.send(ws, ack);
639
+ break;
640
+ }
641
+ case "GET_ANSWER": {
642
+ const target = this.clients.get(msg.to);
643
+ if (!target) {
644
+ this.send(ws, { type: "ANSWER_PENDING", to: msg.from, questionId: msg.questionId, requestId: msg.requestId });
645
+ return;
646
+ }
647
+ this.send(target, msg);
648
+ break;
649
+ }
650
+ case "ANSWER":
651
+ case "ANSWER_PENDING": {
652
+ const target = this.clients.get(msg.to);
653
+ if (target) this.send(target, msg);
654
+ break;
655
+ }
656
+ }
657
+ }
658
+ send(ws, msg) {
659
+ if (ws.readyState === WebSocket.OPEN) {
660
+ ws.send(serialize(msg));
661
+ }
662
+ }
663
+ broadcast(msg, except) {
664
+ for (const ws of this.clients.values()) {
665
+ if (ws !== except) this.send(ws, msg);
666
+ }
667
+ }
668
+ };
669
+ var SERVICE_TYPE = "claude-collab";
670
+ var MdnsAdvertiser = class {
671
+ bonjour = null;
672
+ start(port) {
673
+ this.bonjour = new Bonjour();
674
+ this.bonjour.publish({ name: "claude-collab-hub", type: SERVICE_TYPE, port });
675
+ console.error(`[mdns] advertising hub on port ${port}`);
676
+ }
677
+ stop() {
678
+ if (!this.bonjour) return;
679
+ this.bonjour.unpublishAll(() => {
680
+ this.bonjour?.destroy();
681
+ });
682
+ this.bonjour = null;
683
+ console.error("[mdns] stopped advertising");
684
+ }
685
+ };
686
+
687
+ // src/infrastructure/hub/hub-manager.ts
688
+ var HubManager = class {
689
+ hubServer = null;
690
+ advertiser = null;
691
+ currentPort = null;
692
+ get isRunning() {
693
+ return this.hubServer !== null;
694
+ }
695
+ get port() {
696
+ return this.currentPort;
697
+ }
698
+ async start(port) {
699
+ if (this.isRunning) throw new Error("Hub is already running");
700
+ const server = new HubServer();
701
+ server.start(port);
702
+ this.hubServer = server;
703
+ this.currentPort = port;
704
+ await addFirewallRule(port);
705
+ const advertiser = new MdnsAdvertiser();
706
+ advertiser.start(port);
707
+ this.advertiser = advertiser;
708
+ }
709
+ async stop() {
710
+ if (!this.isRunning) throw new Error("Hub is not running");
711
+ if (this.advertiser) {
712
+ this.advertiser.stop();
713
+ this.advertiser = null;
714
+ }
715
+ const port = this.currentPort;
716
+ this.hubServer.stop();
717
+ this.hubServer = null;
718
+ this.currentPort = null;
719
+ await removeFirewallRule(port);
720
+ }
721
+ };
722
+ function runElevated(netshArgs) {
723
+ return new Promise((resolve, reject) => {
724
+ const ps = spawn("powershell", [
725
+ "-NoProfile",
726
+ "-Command",
727
+ `Start-Process -FilePath "netsh" -ArgumentList "${netshArgs}" -Verb RunAs -Wait`
728
+ ]);
729
+ ps.on("close", (code) => {
730
+ if (code === 0) resolve();
731
+ else reject(new Error(`Firewall command failed (exit code ${code}). User may have cancelled the UAC prompt.`));
732
+ });
733
+ ps.on("error", (err) => {
734
+ reject(new Error(`Failed to launch PowerShell for firewall elevation: ${err.message}`));
735
+ });
736
+ });
737
+ }
738
+ async function addFirewallRule(port) {
739
+ await runElevated(
740
+ `advfirewall firewall add rule name="claude-collab-${port}" protocol=TCP dir=in localport=${port} action=allow`
741
+ );
742
+ }
743
+ async function removeFirewallRule(port) {
744
+ await runElevated(
745
+ `advfirewall firewall delete rule name="claude-collab-${port}"`
746
+ );
747
+ }
552
748
  var askSchema = {
553
749
  peer: z.string().describe('Name of the peer to ask (e.g., "alice", "backend")'),
554
750
  question: z.string().describe("The question to ask (supports markdown)")
@@ -710,10 +906,101 @@ ${answerLine}`;
710
906
  };
711
907
  });
712
908
  }
909
+ function registerStartHubTool(server, client, hubManager) {
910
+ server.tool(
911
+ "start_hub",
912
+ "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.",
913
+ { port: z.number().min(1024).max(65535).optional().describe("Port to listen on (default: 9999)") },
914
+ async ({ port = 9999 }) => {
915
+ if (hubManager.isRunning) {
916
+ return {
917
+ content: [{
918
+ type: "text",
919
+ text: `Hub is already running on port ${hubManager.port}.`
920
+ }]
921
+ };
922
+ }
923
+ try {
924
+ await hubManager.start(port);
925
+ } catch (err) {
926
+ const msg = err instanceof Error ? err.message : String(err);
927
+ return {
928
+ content: [{
929
+ type: "text",
930
+ text: `Failed to start hub: ${msg}`
931
+ }]
932
+ };
933
+ }
934
+ try {
935
+ await client.connectToHub(`ws://localhost:${port}`);
936
+ } catch (err) {
937
+ const msg = err instanceof Error ? err.message : String(err);
938
+ return {
939
+ content: [{
940
+ type: "text",
941
+ text: `Hub started on port ${port}, but failed to self-connect: ${msg}`
942
+ }]
943
+ };
944
+ }
945
+ return {
946
+ content: [{
947
+ type: "text",
948
+ text: [
949
+ `Hub started on port ${port}.`,
950
+ `Firewall rule added (claude-collab-${port}).`,
951
+ `Others on the LAN will auto-discover and connect \u2014 no IP sharing needed.`,
952
+ `Use stop_hub when you are done to close the hub and remove the firewall rule.`
953
+ ].join("\n")
954
+ }]
955
+ };
956
+ }
957
+ );
958
+ }
959
+
960
+ // src/presentation/mcp/tools/stop-hub.tool.ts
961
+ function registerStopHubTool(server, hubManager) {
962
+ server.tool(
963
+ "stop_hub",
964
+ "Stop the running hub server. Removes the firewall rule (UAC prompt) and stops LAN advertising. Connected peers will be disconnected.",
965
+ {},
966
+ async () => {
967
+ if (!hubManager.isRunning) {
968
+ return {
969
+ content: [{
970
+ type: "text",
971
+ text: "No hub is currently running on this machine."
972
+ }]
973
+ };
974
+ }
975
+ const port = hubManager.port;
976
+ try {
977
+ await hubManager.stop();
978
+ } catch (err) {
979
+ const msg = err instanceof Error ? err.message : String(err);
980
+ return {
981
+ content: [{
982
+ type: "text",
983
+ text: `Failed to stop hub: ${msg}`
984
+ }]
985
+ };
986
+ }
987
+ return {
988
+ content: [{
989
+ type: "text",
990
+ text: [
991
+ `Hub stopped (was on port ${port}).`,
992
+ `Firewall rule removed (claude-collab-${port}).`,
993
+ `All peers have been disconnected.`
994
+ ].join("\n")
995
+ }]
996
+ };
997
+ }
998
+ );
999
+ }
713
1000
 
714
1001
  // src/presentation/mcp/server.ts
715
1002
  function createMcpServer(options) {
716
- const { client } = options;
1003
+ const { client, hubManager } = options;
717
1004
  const server = new McpServer({
718
1005
  name: "claude-collab",
719
1006
  version: "0.1.0"
@@ -722,6 +1009,8 @@ function createMcpServer(options) {
722
1009
  registerReplyTool(server, client);
723
1010
  registerPeersTool(server, client);
724
1011
  registerHistoryTool(server, client);
1012
+ registerStartHubTool(server, client, hubManager);
1013
+ registerStopHubTool(server, hubManager);
725
1014
  return server;
726
1015
  }
727
1016
  async function startMcpServer(options) {
@@ -729,6 +1018,37 @@ async function startMcpServer(options) {
729
1018
  const transport = new StdioServerTransport();
730
1019
  await server.connect(transport);
731
1020
  }
1021
+ var SERVICE_TYPE2 = "claude-collab";
1022
+ function discoverHub(timeoutMs = 5e3) {
1023
+ return new Promise((resolve) => {
1024
+ const bonjour = new Bonjour();
1025
+ const browser = bonjour.find({ type: SERVICE_TYPE2 });
1026
+ let settled = false;
1027
+ const finish = (result) => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ clearTimeout(timer);
1031
+ browser.stop();
1032
+ bonjour.destroy();
1033
+ resolve(result);
1034
+ };
1035
+ const timer = setTimeout(() => finish(null), timeoutMs);
1036
+ browser.on("up", (svc) => {
1037
+ finish({ host: svc.host, port: svc.port });
1038
+ });
1039
+ });
1040
+ }
1041
+ function watchForHub(onFound) {
1042
+ const bonjour = new Bonjour();
1043
+ const browser = bonjour.find({ type: SERVICE_TYPE2 });
1044
+ browser.on("up", (svc) => {
1045
+ onFound({ host: svc.host, port: svc.port });
1046
+ });
1047
+ return () => {
1048
+ browser.stop();
1049
+ bonjour.destroy();
1050
+ };
1051
+ }
732
1052
 
733
1053
  // src/mcp-main.ts
734
1054
  function getArg(flag) {
@@ -742,15 +1062,38 @@ async function main() {
742
1062
  console.error("--name is required");
743
1063
  process.exit(1);
744
1064
  }
745
- if (!server) {
746
- console.error("--server is required");
747
- process.exit(1);
748
- }
749
- const url = server.startsWith("ws") ? server : `ws://${server}`;
1065
+ const hubManager = new HubManager();
750
1066
  const client = new HubClient();
751
- client.setServerUrl(url);
752
- await client.join(name, name);
753
- await startMcpServer({ client });
1067
+ if (server) {
1068
+ const url = server.startsWith("ws") ? server : `ws://${server}`;
1069
+ client.setServerUrl(url);
1070
+ await client.join(name, name);
1071
+ } else {
1072
+ await client.join(name, name);
1073
+ console.error("[mcp-main] no --server given, searching for hub on LAN (5s)...");
1074
+ const hub = await discoverHub(5e3);
1075
+ if (hub) {
1076
+ console.error(`[mcp-main] hub found at ${hub.host}:${hub.port}, connecting...`);
1077
+ await client.connectToHub(`ws://${hub.host}:${hub.port}`);
1078
+ } else {
1079
+ console.error("[mcp-main] no hub found. Use start_hub tool to host, or wait \u2014 auto-connect will happen when a hub appears.");
1080
+ const stopWatch = watchForHub(async (hub2) => {
1081
+ if (client.isConnected) {
1082
+ stopWatch();
1083
+ return;
1084
+ }
1085
+ try {
1086
+ console.error(`[mcp-main] hub appeared at ${hub2.host}:${hub2.port}, connecting...`);
1087
+ await client.connectToHub(`ws://${hub2.host}:${hub2.port}`);
1088
+ stopWatch();
1089
+ } catch (err) {
1090
+ const msg = err instanceof Error ? err.message : String(err);
1091
+ console.error(`[mcp-main] auto-connect failed: ${msg}`);
1092
+ }
1093
+ });
1094
+ }
1095
+ }
1096
+ await startMcpServer({ client, hubManager });
754
1097
  }
755
1098
  main().catch((error) => {
756
1099
  console.error("Unexpected error:", error);