@dolusoft/claude-collab 1.4.4 → 1.5.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/README.md +70 -156
- package/dist/cli.js +348 -355
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +357 -353
- package/dist/mcp-main.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp-main.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
3
|
import { v4 } from 'uuid';
|
|
4
|
+
import dgram from 'dgram';
|
|
5
|
+
import os, { tmpdir } from 'os';
|
|
4
6
|
import { EventEmitter } from 'events';
|
|
5
7
|
import { execFile } from 'child_process';
|
|
6
8
|
import { unlinkSync } from 'fs';
|
|
7
|
-
import { tmpdir, networkInterfaces } from 'os';
|
|
8
9
|
import { join } from 'path';
|
|
9
10
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -17,6 +18,122 @@ function serializeP2PMsg(msg) {
|
|
|
17
18
|
function parseP2PMsg(data) {
|
|
18
19
|
return JSON.parse(data);
|
|
19
20
|
}
|
|
21
|
+
var BROADCAST_ADDR = "255.255.255.255";
|
|
22
|
+
var BROADCAST_PORT = 11776;
|
|
23
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
24
|
+
var PEER_TIMEOUT_MS = 95e3;
|
|
25
|
+
var MulticastDiscovery = class extends EventEmitter {
|
|
26
|
+
socket = null;
|
|
27
|
+
heartbeatTimer = null;
|
|
28
|
+
timeoutTimer = null;
|
|
29
|
+
peers = /* @__PURE__ */ new Map();
|
|
30
|
+
myName = "";
|
|
31
|
+
myWsPort = 0;
|
|
32
|
+
myIp = "";
|
|
33
|
+
start(name, wsPort) {
|
|
34
|
+
this.myName = name;
|
|
35
|
+
this.myWsPort = wsPort;
|
|
36
|
+
this.myIp = this.resolveLocalIp();
|
|
37
|
+
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
38
|
+
this.socket = socket;
|
|
39
|
+
socket.on("error", (err) => {
|
|
40
|
+
console.error("[discovery] socket error:", err.message);
|
|
41
|
+
});
|
|
42
|
+
socket.on("message", (buf, rinfo) => {
|
|
43
|
+
try {
|
|
44
|
+
const msg = JSON.parse(buf.toString());
|
|
45
|
+
this.handleMessage(msg, rinfo.address);
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
socket.bind(BROADCAST_PORT, () => {
|
|
50
|
+
socket.setBroadcast(true);
|
|
51
|
+
this.announce();
|
|
52
|
+
this.heartbeatTimer = setInterval(() => this.announce(), HEARTBEAT_INTERVAL_MS);
|
|
53
|
+
this.timeoutTimer = setInterval(() => this.checkTimeouts(), 1e4);
|
|
54
|
+
console.error(`[discovery] broadcasting on port ${BROADCAST_PORT}`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
stop() {
|
|
58
|
+
if (this.heartbeatTimer) {
|
|
59
|
+
clearInterval(this.heartbeatTimer);
|
|
60
|
+
this.heartbeatTimer = null;
|
|
61
|
+
}
|
|
62
|
+
if (this.timeoutTimer) {
|
|
63
|
+
clearInterval(this.timeoutTimer);
|
|
64
|
+
this.timeoutTimer = null;
|
|
65
|
+
}
|
|
66
|
+
if (this.socket) {
|
|
67
|
+
this.sendMessage({ type: "LEAVE", name: this.myName });
|
|
68
|
+
try {
|
|
69
|
+
this.socket.close();
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
this.socket = null;
|
|
73
|
+
}
|
|
74
|
+
this.peers.clear();
|
|
75
|
+
}
|
|
76
|
+
getMyIp() {
|
|
77
|
+
return this.myIp;
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Private
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
announce() {
|
|
83
|
+
this.sendMessage({ type: "ANNOUNCE", name: this.myName, wsPort: this.myWsPort });
|
|
84
|
+
}
|
|
85
|
+
sendMessage(msg) {
|
|
86
|
+
if (!this.socket) return;
|
|
87
|
+
const buf = Buffer.from(JSON.stringify(msg));
|
|
88
|
+
this.socket.send(buf, BROADCAST_PORT, BROADCAST_ADDR, (err) => {
|
|
89
|
+
if (err) console.error("[discovery] send error:", err.message);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
handleMessage(msg, fromIp) {
|
|
93
|
+
if (msg.type === "ANNOUNCE") {
|
|
94
|
+
if (msg.name === this.myName && fromIp === this.myIp) return;
|
|
95
|
+
const existing = this.peers.get(msg.name);
|
|
96
|
+
if (!existing) {
|
|
97
|
+
const peer = { name: msg.name, ip: fromIp, wsPort: msg.wsPort, lastSeen: Date.now() };
|
|
98
|
+
this.peers.set(msg.name, peer);
|
|
99
|
+
this.emit("peer-found", { name: peer.name, ip: peer.ip, wsPort: peer.wsPort });
|
|
100
|
+
console.error(`[discovery] found peer: ${msg.name} @ ${fromIp}:${msg.wsPort}`);
|
|
101
|
+
} else {
|
|
102
|
+
existing.lastSeen = Date.now();
|
|
103
|
+
existing.ip = fromIp;
|
|
104
|
+
existing.wsPort = msg.wsPort;
|
|
105
|
+
}
|
|
106
|
+
} else if (msg.type === "LEAVE") {
|
|
107
|
+
if (this.peers.has(msg.name)) {
|
|
108
|
+
this.peers.delete(msg.name);
|
|
109
|
+
this.emit("peer-lost", msg.name);
|
|
110
|
+
console.error(`[discovery] peer left: ${msg.name}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
checkTimeouts() {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
for (const [name, peer] of this.peers) {
|
|
117
|
+
if (now - peer.lastSeen > PEER_TIMEOUT_MS) {
|
|
118
|
+
this.peers.delete(name);
|
|
119
|
+
this.emit("peer-lost", name);
|
|
120
|
+
console.error(`[discovery] peer timed out: ${name}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
resolveLocalIp() {
|
|
125
|
+
const interfaces = os.networkInterfaces();
|
|
126
|
+
for (const iface of Object.values(interfaces)) {
|
|
127
|
+
if (!iface) continue;
|
|
128
|
+
for (const addr of iface) {
|
|
129
|
+
if (addr.family === "IPv4" && !addr.internal) {
|
|
130
|
+
return addr.address;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return "127.0.0.1";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
20
137
|
var CS_CONINJECT = `
|
|
21
138
|
using System;
|
|
22
139
|
using System.Collections.Generic;
|
|
@@ -252,16 +369,23 @@ var config = {
|
|
|
252
369
|
var P2PNode = class {
|
|
253
370
|
wss = null;
|
|
254
371
|
port = 0;
|
|
255
|
-
|
|
372
|
+
discovery = new MulticastDiscovery();
|
|
373
|
+
// Connections indexed by remote peer name
|
|
256
374
|
peerConns = /* @__PURE__ */ new Map();
|
|
257
|
-
// Reverse lookup: ws →
|
|
258
|
-
|
|
375
|
+
// Reverse lookup: ws → peerName (for cleanup)
|
|
376
|
+
wsToName = /* @__PURE__ */ new Map();
|
|
377
|
+
// Track which connections we initiated (for dedup tiebreaker)
|
|
378
|
+
wsOutgoing = /* @__PURE__ */ new Set();
|
|
379
|
+
// Remote IP per connection (for dedup tiebreaker)
|
|
380
|
+
wsToIp = /* @__PURE__ */ new Map();
|
|
259
381
|
// Questions we received from remote peers (our inbox)
|
|
260
382
|
incomingQuestions = /* @__PURE__ */ new Map();
|
|
261
383
|
// Answers we received for questions we asked
|
|
262
384
|
receivedAnswers = /* @__PURE__ */ new Map();
|
|
263
|
-
// Maps questionId → remote
|
|
264
|
-
|
|
385
|
+
// Maps questionId → remote peer name (so we know who to poll)
|
|
386
|
+
questionToName = /* @__PURE__ */ new Map();
|
|
387
|
+
// Questions we sent — for history
|
|
388
|
+
sentQuestions = /* @__PURE__ */ new Map();
|
|
265
389
|
// Pending response handlers (request-response correlation by filter)
|
|
266
390
|
pendingHandlers = /* @__PURE__ */ new Set();
|
|
267
391
|
localMember = null;
|
|
@@ -270,7 +394,7 @@ var P2PNode = class {
|
|
|
270
394
|
return this._isStarted;
|
|
271
395
|
}
|
|
272
396
|
get currentTeamId() {
|
|
273
|
-
return this.localMember?.
|
|
397
|
+
return this.localMember?.name;
|
|
274
398
|
}
|
|
275
399
|
/**
|
|
276
400
|
* Starts the WS server on a random available port within the configured range.
|
|
@@ -307,71 +431,31 @@ var P2PNode = class {
|
|
|
307
431
|
wss.once("error", () => resolve(false));
|
|
308
432
|
});
|
|
309
433
|
}
|
|
310
|
-
async join(
|
|
434
|
+
async join(name, displayName) {
|
|
311
435
|
if (!this._isStarted) {
|
|
312
436
|
await this.start();
|
|
313
437
|
}
|
|
314
438
|
const memberId = v4();
|
|
315
|
-
this.localMember = { memberId,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
* Connects to a peer at the given IP and port.
|
|
320
|
-
* Performs a bidirectional HELLO handshake and returns the peer's team name.
|
|
321
|
-
*/
|
|
322
|
-
async connectPeer(ip, port) {
|
|
323
|
-
if (!this.localMember) {
|
|
324
|
-
throw new Error("Must call join() before connectPeer()");
|
|
325
|
-
}
|
|
326
|
-
const ws = new WebSocket(`ws://${ip}:${port}`);
|
|
327
|
-
ws.on("message", (data) => {
|
|
328
|
-
try {
|
|
329
|
-
const msg = parseP2PMsg(data.toString());
|
|
330
|
-
this.handleMessage(ws, msg);
|
|
331
|
-
} catch (err) {
|
|
332
|
-
console.error("Failed to parse P2P message:", err);
|
|
333
|
-
}
|
|
439
|
+
this.localMember = { memberId, name, displayName };
|
|
440
|
+
this.discovery.start(name, this.port);
|
|
441
|
+
this.discovery.on("peer-found", ({ name: peerName, ip, wsPort }) => {
|
|
442
|
+
void this.autoConnect(peerName, ip, wsPort);
|
|
334
443
|
});
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
this.wsToTeam.delete(ws);
|
|
444
|
+
this.discovery.on("peer-lost", (peerName) => {
|
|
445
|
+
const ws = this.peerConns.get(peerName);
|
|
446
|
+
if (ws) {
|
|
447
|
+
ws.close();
|
|
448
|
+
this.peerConns.delete(peerName);
|
|
342
449
|
}
|
|
343
450
|
});
|
|
344
|
-
|
|
345
|
-
const timeout = setTimeout(
|
|
346
|
-
() => reject(new Error(`Connection timeout to ${ip}:${port}`)),
|
|
347
|
-
5e3
|
|
348
|
-
);
|
|
349
|
-
ws.on("open", () => {
|
|
350
|
-
clearTimeout(timeout);
|
|
351
|
-
const hello = {
|
|
352
|
-
type: "P2P_HELLO",
|
|
353
|
-
fromTeam: this.localMember.teamName,
|
|
354
|
-
fromMemberId: this.localMember.memberId
|
|
355
|
-
};
|
|
356
|
-
ws.send(serializeP2PMsg(hello));
|
|
357
|
-
resolve();
|
|
358
|
-
});
|
|
359
|
-
ws.on("error", (err) => {
|
|
360
|
-
clearTimeout(timeout);
|
|
361
|
-
reject(err);
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
const helloMsg = await this.waitForResponse(
|
|
365
|
-
(m) => m.type === "P2P_HELLO",
|
|
366
|
-
1e4
|
|
367
|
-
);
|
|
368
|
-
return helloMsg.fromTeam;
|
|
451
|
+
return { memberId, teamId: name, teamName: name, displayName, status: "ONLINE", port: this.port };
|
|
369
452
|
}
|
|
370
|
-
async ask(
|
|
371
|
-
const ws = await this.getPeerConnection(
|
|
453
|
+
async ask(toName, content, format) {
|
|
454
|
+
const ws = await this.getPeerConnection(toName);
|
|
372
455
|
const questionId = v4();
|
|
373
456
|
const requestId = v4();
|
|
374
|
-
this.
|
|
457
|
+
this.questionToName.set(questionId, toName);
|
|
458
|
+
this.sentQuestions.set(questionId, { toPeer: toName, content, askedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
375
459
|
const ackPromise = this.waitForResponse(
|
|
376
460
|
(m) => m.type === "P2P_ASK_ACK" && m.requestId === requestId,
|
|
377
461
|
5e3
|
|
@@ -380,8 +464,8 @@ var P2PNode = class {
|
|
|
380
464
|
type: "P2P_ASK",
|
|
381
465
|
questionId,
|
|
382
466
|
fromMemberId: this.localMember.memberId,
|
|
383
|
-
fromTeam: this.localMember.
|
|
384
|
-
toTeam,
|
|
467
|
+
fromTeam: this.localMember.name,
|
|
468
|
+
toTeam: toName,
|
|
385
469
|
content,
|
|
386
470
|
format,
|
|
387
471
|
requestId
|
|
@@ -401,9 +485,9 @@ var P2PNode = class {
|
|
|
401
485
|
answeredAt: cached.answeredAt
|
|
402
486
|
};
|
|
403
487
|
}
|
|
404
|
-
const
|
|
405
|
-
if (!
|
|
406
|
-
const ws = this.peerConns.get(
|
|
488
|
+
const toName = this.questionToName.get(questionId);
|
|
489
|
+
if (!toName) return null;
|
|
490
|
+
const ws = this.peerConns.get(toName);
|
|
407
491
|
if (!ws || ws.readyState !== WebSocket.OPEN) return null;
|
|
408
492
|
const requestId = v4();
|
|
409
493
|
const responsePromise = this.waitForResponse(
|
|
@@ -446,7 +530,7 @@ var P2PNode = class {
|
|
|
446
530
|
content,
|
|
447
531
|
format,
|
|
448
532
|
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
449
|
-
fromTeam: this.localMember.
|
|
533
|
+
fromTeam: this.localMember.name,
|
|
450
534
|
fromMemberId: this.localMember.memberId
|
|
451
535
|
};
|
|
452
536
|
if (question.ws.readyState === WebSocket.OPEN) {
|
|
@@ -472,12 +556,40 @@ var P2PNode = class {
|
|
|
472
556
|
}
|
|
473
557
|
getInfo() {
|
|
474
558
|
return {
|
|
475
|
-
teamName: this.localMember?.
|
|
559
|
+
teamName: this.localMember?.name,
|
|
476
560
|
port: this._isStarted ? this.port : void 0,
|
|
477
561
|
connectedPeers: [...this.peerConns.keys()]
|
|
478
562
|
};
|
|
479
563
|
}
|
|
564
|
+
getHistory() {
|
|
565
|
+
const entries = [];
|
|
566
|
+
for (const [questionId, sent] of this.sentQuestions) {
|
|
567
|
+
const answer = this.receivedAnswers.get(questionId);
|
|
568
|
+
entries.push({
|
|
569
|
+
direction: "sent",
|
|
570
|
+
questionId,
|
|
571
|
+
peer: sent.toPeer,
|
|
572
|
+
question: sent.content,
|
|
573
|
+
answer: answer?.content,
|
|
574
|
+
askedAt: sent.askedAt,
|
|
575
|
+
answeredAt: answer?.answeredAt
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
for (const [questionId, incoming] of this.incomingQuestions) {
|
|
579
|
+
entries.push({
|
|
580
|
+
direction: "received",
|
|
581
|
+
questionId,
|
|
582
|
+
peer: incoming.fromTeam,
|
|
583
|
+
question: incoming.content,
|
|
584
|
+
answer: incoming.answered ? incoming.answerContent : void 0,
|
|
585
|
+
askedAt: incoming.createdAt.toISOString(),
|
|
586
|
+
answeredAt: incoming.answered ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return entries.sort((a, b) => a.askedAt.localeCompare(b.askedAt));
|
|
590
|
+
}
|
|
480
591
|
async disconnect() {
|
|
592
|
+
this.discovery.stop();
|
|
481
593
|
for (const ws of this.peerConns.values()) {
|
|
482
594
|
ws.close();
|
|
483
595
|
}
|
|
@@ -492,35 +604,88 @@ var P2PNode = class {
|
|
|
492
604
|
this._isStarted = false;
|
|
493
605
|
}
|
|
494
606
|
// ---------------------------------------------------------------------------
|
|
607
|
+
// Private: auto-connect from multicast discovery
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
async autoConnect(peerName, ip, wsPort) {
|
|
610
|
+
const existing = this.peerConns.get(peerName);
|
|
611
|
+
if (existing && existing.readyState === WebSocket.OPEN) return;
|
|
612
|
+
try {
|
|
613
|
+
await this.connectToPeer(ip, wsPort);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
616
|
+
console.error(`[p2p] auto-connect to ${peerName} @ ${ip}:${wsPort} failed: ${msg}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Internal: open a WebSocket connection to a peer and perform HELLO handshake.
|
|
621
|
+
*/
|
|
622
|
+
async connectToPeer(ip, port) {
|
|
623
|
+
if (!this.localMember) {
|
|
624
|
+
throw new Error("Must call join() before connecting to peers");
|
|
625
|
+
}
|
|
626
|
+
const ws = new WebSocket(`ws://${ip}:${port}`);
|
|
627
|
+
this.wsOutgoing.add(ws);
|
|
628
|
+
this.wsToIp.set(ws, ip);
|
|
629
|
+
ws.on("message", (data) => {
|
|
630
|
+
try {
|
|
631
|
+
const msg = parseP2PMsg(data.toString());
|
|
632
|
+
this.handleMessage(ws, msg);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.error("[p2p] Failed to parse message:", err);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
ws.on("close", () => this.cleanupWs(ws));
|
|
638
|
+
ws.on("error", (err) => console.error("[p2p] ws error:", err.message));
|
|
639
|
+
await new Promise((resolve, reject) => {
|
|
640
|
+
const timeout = setTimeout(
|
|
641
|
+
() => reject(new Error(`Connection timeout to ${ip}:${port}`)),
|
|
642
|
+
5e3
|
|
643
|
+
);
|
|
644
|
+
ws.on("open", () => {
|
|
645
|
+
clearTimeout(timeout);
|
|
646
|
+
const hello = {
|
|
647
|
+
type: "P2P_HELLO",
|
|
648
|
+
fromTeam: this.localMember.name,
|
|
649
|
+
fromMemberId: this.localMember.memberId
|
|
650
|
+
};
|
|
651
|
+
ws.send(serializeP2PMsg(hello));
|
|
652
|
+
resolve();
|
|
653
|
+
});
|
|
654
|
+
ws.on("error", (err) => {
|
|
655
|
+
clearTimeout(timeout);
|
|
656
|
+
reject(err);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
const helloMsg = await this.waitForResponse(
|
|
660
|
+
(m) => m.type === "P2P_HELLO",
|
|
661
|
+
1e4
|
|
662
|
+
);
|
|
663
|
+
return helloMsg.fromTeam;
|
|
664
|
+
}
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
495
666
|
// Private: WebSocket server setup
|
|
496
667
|
// ---------------------------------------------------------------------------
|
|
497
668
|
setupWssHandlers() {
|
|
498
|
-
this.wss.on("connection", (ws) => {
|
|
669
|
+
this.wss.on("connection", (ws, req) => {
|
|
670
|
+
const remoteIp = (req.socket.remoteAddress ?? "").replace("::ffff:", "");
|
|
671
|
+
this.wsToIp.set(ws, remoteIp);
|
|
499
672
|
ws.on("message", (data) => {
|
|
500
673
|
try {
|
|
501
674
|
const msg = parseP2PMsg(data.toString());
|
|
502
675
|
if (msg.type === "P2P_HELLO" && this.localMember) {
|
|
503
676
|
const hello = {
|
|
504
677
|
type: "P2P_HELLO",
|
|
505
|
-
fromTeam: this.localMember.
|
|
678
|
+
fromTeam: this.localMember.name,
|
|
506
679
|
fromMemberId: this.localMember.memberId
|
|
507
680
|
};
|
|
508
681
|
ws.send(serializeP2PMsg(hello));
|
|
509
682
|
}
|
|
510
683
|
this.handleMessage(ws, msg);
|
|
511
684
|
} catch (err) {
|
|
512
|
-
console.error("Failed to parse incoming
|
|
513
|
-
}
|
|
514
|
-
});
|
|
515
|
-
ws.on("close", () => {
|
|
516
|
-
const team = this.wsToTeam.get(ws);
|
|
517
|
-
if (team) {
|
|
518
|
-
if (this.peerConns.get(team) === ws) {
|
|
519
|
-
this.peerConns.delete(team);
|
|
520
|
-
}
|
|
521
|
-
this.wsToTeam.delete(ws);
|
|
685
|
+
console.error("[p2p] Failed to parse incoming message:", err);
|
|
522
686
|
}
|
|
523
687
|
});
|
|
688
|
+
ws.on("close", () => this.cleanupWs(ws));
|
|
524
689
|
});
|
|
525
690
|
}
|
|
526
691
|
// ---------------------------------------------------------------------------
|
|
@@ -532,9 +697,7 @@ var P2PNode = class {
|
|
|
532
697
|
}
|
|
533
698
|
switch (msg.type) {
|
|
534
699
|
case "P2P_HELLO":
|
|
535
|
-
this.
|
|
536
|
-
this.peerConns.set(msg.fromTeam, ws);
|
|
537
|
-
console.error(`Peer identified: ${msg.fromTeam}`);
|
|
700
|
+
this.handleHello(ws, msg);
|
|
538
701
|
break;
|
|
539
702
|
case "P2P_ASK":
|
|
540
703
|
this.handleIncomingAsk(ws, msg);
|
|
@@ -558,6 +721,28 @@ var P2PNode = class {
|
|
|
558
721
|
break;
|
|
559
722
|
}
|
|
560
723
|
}
|
|
724
|
+
handleHello(ws, msg) {
|
|
725
|
+
const peerName = msg.fromTeam;
|
|
726
|
+
const existing = this.peerConns.get(peerName);
|
|
727
|
+
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
728
|
+
const myIp = this.discovery.getMyIp();
|
|
729
|
+
const peerIp = this.wsToIp.get(ws) ?? "";
|
|
730
|
+
const iShouldInitiate = myIp < peerIp;
|
|
731
|
+
const thisIsOutgoing = this.wsOutgoing.has(ws);
|
|
732
|
+
if (iShouldInitiate && !thisIsOutgoing) {
|
|
733
|
+
ws.close();
|
|
734
|
+
return;
|
|
735
|
+
} else if (!iShouldInitiate && thisIsOutgoing) {
|
|
736
|
+
ws.close();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
ws.close();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.wsToName.set(ws, peerName);
|
|
743
|
+
this.peerConns.set(peerName, ws);
|
|
744
|
+
console.error(`[p2p] connected to peer: ${peerName}`);
|
|
745
|
+
}
|
|
561
746
|
handleIncomingAsk(ws, msg) {
|
|
562
747
|
this.incomingQuestions.set(msg.questionId, {
|
|
563
748
|
questionId: msg.questionId,
|
|
@@ -605,7 +790,7 @@ var P2PNode = class {
|
|
|
605
790
|
content: question.answerContent,
|
|
606
791
|
format: question.answerFormat,
|
|
607
792
|
answeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
608
|
-
fromTeam: this.localMember.
|
|
793
|
+
fromTeam: this.localMember.name,
|
|
609
794
|
fromMemberId: this.localMember.memberId,
|
|
610
795
|
requestId: msg.requestId
|
|
611
796
|
};
|
|
@@ -614,13 +799,24 @@ var P2PNode = class {
|
|
|
614
799
|
// ---------------------------------------------------------------------------
|
|
615
800
|
// Private: peer connection management
|
|
616
801
|
// ---------------------------------------------------------------------------
|
|
617
|
-
|
|
618
|
-
const
|
|
802
|
+
cleanupWs(ws) {
|
|
803
|
+
const name = this.wsToName.get(ws);
|
|
804
|
+
if (name) {
|
|
805
|
+
if (this.peerConns.get(name) === ws) {
|
|
806
|
+
this.peerConns.delete(name);
|
|
807
|
+
}
|
|
808
|
+
this.wsToName.delete(ws);
|
|
809
|
+
}
|
|
810
|
+
this.wsOutgoing.delete(ws);
|
|
811
|
+
this.wsToIp.delete(ws);
|
|
812
|
+
}
|
|
813
|
+
async getPeerConnection(name) {
|
|
814
|
+
const existing = this.peerConns.get(name);
|
|
619
815
|
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
620
816
|
return existing;
|
|
621
817
|
}
|
|
622
818
|
throw new Error(
|
|
623
|
-
`No connection to
|
|
819
|
+
`No connection to peer '${name}'. They may not be on the network yet \u2014 wait a moment and try again.`
|
|
624
820
|
);
|
|
625
821
|
}
|
|
626
822
|
waitForResponse(filter, timeoutMs) {
|
|
@@ -640,115 +836,13 @@ var P2PNode = class {
|
|
|
640
836
|
});
|
|
641
837
|
}
|
|
642
838
|
};
|
|
643
|
-
var joinSchema = {
|
|
644
|
-
team: z.string().describe('Team name to join (e.g., "frontend", "backend", "devops")'),
|
|
645
|
-
displayName: z.string().optional().describe('Display name for this terminal (default: team + " Claude")')
|
|
646
|
-
};
|
|
647
|
-
function registerJoinTool(server, client) {
|
|
648
|
-
server.tool("join", joinSchema, async (args) => {
|
|
649
|
-
const teamName = args.team;
|
|
650
|
-
const displayName = args.displayName ?? `${teamName} Claude`;
|
|
651
|
-
try {
|
|
652
|
-
const member = await client.join(teamName, displayName);
|
|
653
|
-
return {
|
|
654
|
-
content: [
|
|
655
|
-
{
|
|
656
|
-
type: "text",
|
|
657
|
-
text: `Successfully joined team "${member.teamName}" as "${member.displayName}".
|
|
658
|
-
|
|
659
|
-
Your member ID: ${member.memberId}
|
|
660
|
-
Status: ${member.status}
|
|
661
|
-
Listening on port: ${member.port}
|
|
662
|
-
|
|
663
|
-
Share this port with the other terminal so they can run: connect_peer("<your-ip>", ${member.port})`
|
|
664
|
-
}
|
|
665
|
-
]
|
|
666
|
-
};
|
|
667
|
-
} catch (error) {
|
|
668
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
669
|
-
return {
|
|
670
|
-
content: [
|
|
671
|
-
{
|
|
672
|
-
type: "text",
|
|
673
|
-
text: `Failed to join team: ${errorMessage}`
|
|
674
|
-
}
|
|
675
|
-
],
|
|
676
|
-
isError: true
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
var connectPeerSchema = {
|
|
682
|
-
ip: z.string().describe('IP address of the peer to connect to (e.g., "172.16.40.137")'),
|
|
683
|
-
port: z.number().describe("Port the peer is listening on (shown in their join response)")
|
|
684
|
-
};
|
|
685
|
-
function registerConnectPeerTool(server, client) {
|
|
686
|
-
server.tool("connect_peer", connectPeerSchema, async (args) => {
|
|
687
|
-
try {
|
|
688
|
-
const peerTeam = await client.connectPeer(args.ip, args.port);
|
|
689
|
-
return {
|
|
690
|
-
content: [
|
|
691
|
-
{
|
|
692
|
-
type: "text",
|
|
693
|
-
text: `Connected to peer "${peerTeam}" at ${args.ip}:${args.port}.`
|
|
694
|
-
}
|
|
695
|
-
]
|
|
696
|
-
};
|
|
697
|
-
} catch (error) {
|
|
698
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
699
|
-
return {
|
|
700
|
-
content: [
|
|
701
|
-
{
|
|
702
|
-
type: "text",
|
|
703
|
-
text: `Failed to connect to ${args.ip}:${args.port}: ${errorMessage}`
|
|
704
|
-
}
|
|
705
|
-
],
|
|
706
|
-
isError: true
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
function getLocalIPs() {
|
|
712
|
-
const ips = [];
|
|
713
|
-
for (const iface of Object.values(networkInterfaces())) {
|
|
714
|
-
for (const net of iface ?? []) {
|
|
715
|
-
if (net.family === "IPv4" && !net.internal) {
|
|
716
|
-
ips.push(net.address);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
return ips;
|
|
721
|
-
}
|
|
722
|
-
function registerGetInfoTool(server, client) {
|
|
723
|
-
server.tool("get_info", {}, async () => {
|
|
724
|
-
const info = client.getInfo();
|
|
725
|
-
const ips = getLocalIPs();
|
|
726
|
-
const lines = [];
|
|
727
|
-
lines.push(`Team: ${info.teamName ?? "(not joined yet)"}`);
|
|
728
|
-
lines.push(`Port: ${info.port ?? "(not started yet)"}`);
|
|
729
|
-
lines.push(`Local IPs: ${ips.length > 0 ? ips.join(", ") : "(none found)"}`);
|
|
730
|
-
if (info.connectedPeers.length > 0) {
|
|
731
|
-
lines.push(`Connected peers: ${info.connectedPeers.join(", ")}`);
|
|
732
|
-
} else {
|
|
733
|
-
lines.push(`Connected peers: (none)`);
|
|
734
|
-
}
|
|
735
|
-
if (info.port && ips.length > 0) {
|
|
736
|
-
lines.push(`
|
|
737
|
-
Other terminal can connect with:
|
|
738
|
-
connect_peer("${ips[0]}", ${info.port})`);
|
|
739
|
-
}
|
|
740
|
-
return {
|
|
741
|
-
content: [{ type: "text", text: lines.join("\n") }]
|
|
742
|
-
};
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
839
|
var askSchema = {
|
|
746
|
-
|
|
840
|
+
peer: z.string().describe('Name of the peer to ask (e.g., "alice", "backend")'),
|
|
747
841
|
question: z.string().describe("The question to ask (supports markdown)")
|
|
748
842
|
};
|
|
749
843
|
function registerAskTool(server, client) {
|
|
750
844
|
server.tool("ask", askSchema, async (args) => {
|
|
751
|
-
const
|
|
845
|
+
const targetPeer = args.peer;
|
|
752
846
|
const question = args.question;
|
|
753
847
|
try {
|
|
754
848
|
if (!client.currentTeamId) {
|
|
@@ -756,13 +850,13 @@ function registerAskTool(server, client) {
|
|
|
756
850
|
content: [
|
|
757
851
|
{
|
|
758
852
|
type: "text",
|
|
759
|
-
text:
|
|
853
|
+
text: "Node is not ready yet. Wait a moment and try again."
|
|
760
854
|
}
|
|
761
855
|
],
|
|
762
856
|
isError: true
|
|
763
857
|
};
|
|
764
858
|
}
|
|
765
|
-
const questionId = await client.ask(
|
|
859
|
+
const questionId = await client.ask(targetPeer, question, "markdown");
|
|
766
860
|
const POLL_INTERVAL_MS = 5e3;
|
|
767
861
|
const MAX_WAIT_MS = 5 * 60 * 1e3;
|
|
768
862
|
const deadline = Date.now() + MAX_WAIT_MS;
|
|
@@ -807,124 +901,6 @@ Manuel kontrol i\xE7in "check_answer" tool'unu kullanabilirsin.`
|
|
|
807
901
|
}
|
|
808
902
|
});
|
|
809
903
|
}
|
|
810
|
-
var checkAnswerSchema = {
|
|
811
|
-
question_id: z.string().describe('The question ID returned by the "ask" tool')
|
|
812
|
-
};
|
|
813
|
-
function registerCheckAnswerTool(server, client) {
|
|
814
|
-
server.tool("check_answer", checkAnswerSchema, async (args) => {
|
|
815
|
-
const questionId = args.question_id;
|
|
816
|
-
try {
|
|
817
|
-
if (!client.currentTeamId) {
|
|
818
|
-
return {
|
|
819
|
-
content: [
|
|
820
|
-
{
|
|
821
|
-
type: "text",
|
|
822
|
-
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
823
|
-
}
|
|
824
|
-
],
|
|
825
|
-
isError: true
|
|
826
|
-
};
|
|
827
|
-
}
|
|
828
|
-
const answer = await client.checkAnswer(questionId);
|
|
829
|
-
if (!answer) {
|
|
830
|
-
return {
|
|
831
|
-
content: [
|
|
832
|
-
{
|
|
833
|
-
type: "text",
|
|
834
|
-
text: `No answer yet for question \`${questionId}\`. The other team hasn't replied yet. You can continue working and check again later.`
|
|
835
|
-
}
|
|
836
|
-
]
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
return {
|
|
840
|
-
content: [
|
|
841
|
-
{
|
|
842
|
-
type: "text",
|
|
843
|
-
text: `**Answer from ${answer.from.displayName} (${answer.from.teamName}):**
|
|
844
|
-
|
|
845
|
-
${answer.content}`
|
|
846
|
-
}
|
|
847
|
-
]
|
|
848
|
-
};
|
|
849
|
-
} catch (error) {
|
|
850
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
851
|
-
return {
|
|
852
|
-
content: [
|
|
853
|
-
{
|
|
854
|
-
type: "text",
|
|
855
|
-
text: `Failed to check answer: ${errorMessage}`
|
|
856
|
-
}
|
|
857
|
-
],
|
|
858
|
-
isError: true
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// src/presentation/mcp/tools/inbox.tool.ts
|
|
865
|
-
var inboxSchema = {};
|
|
866
|
-
function registerInboxTool(server, client) {
|
|
867
|
-
server.tool("inbox", inboxSchema, async () => {
|
|
868
|
-
try {
|
|
869
|
-
if (!client.currentTeamId) {
|
|
870
|
-
return {
|
|
871
|
-
content: [
|
|
872
|
-
{
|
|
873
|
-
type: "text",
|
|
874
|
-
text: 'You must join a team first. Use the "join" tool to join a team.'
|
|
875
|
-
}
|
|
876
|
-
],
|
|
877
|
-
isError: true
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
const inbox = await client.getInbox();
|
|
881
|
-
if (inbox.questions.length === 0) {
|
|
882
|
-
return {
|
|
883
|
-
content: [
|
|
884
|
-
{
|
|
885
|
-
type: "text",
|
|
886
|
-
text: "No pending questions in your inbox."
|
|
887
|
-
}
|
|
888
|
-
]
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
const questionsList = inbox.questions.map((q, i) => {
|
|
892
|
-
const ageSeconds = Math.floor(q.ageMs / 1e3);
|
|
893
|
-
const ageStr = ageSeconds < 60 ? `${ageSeconds}s ago` : `${Math.floor(ageSeconds / 60)}m ago`;
|
|
894
|
-
return `### ${i + 1}. Question from ${q.from.displayName} (${q.from.teamName}) - ${ageStr}
|
|
895
|
-
**ID:** \`${q.questionId}\`
|
|
896
|
-
**Status:** ${q.status}
|
|
897
|
-
|
|
898
|
-
${q.content}
|
|
899
|
-
|
|
900
|
-
---`;
|
|
901
|
-
}).join("\n\n");
|
|
902
|
-
return {
|
|
903
|
-
content: [
|
|
904
|
-
{
|
|
905
|
-
type: "text",
|
|
906
|
-
text: `# Inbox (${inbox.pendingCount} pending, ${inbox.totalCount} total)
|
|
907
|
-
|
|
908
|
-
${questionsList}
|
|
909
|
-
|
|
910
|
-
Use the "reply" tool with the question ID to answer a question.`
|
|
911
|
-
}
|
|
912
|
-
]
|
|
913
|
-
};
|
|
914
|
-
} catch (error) {
|
|
915
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
916
|
-
return {
|
|
917
|
-
content: [
|
|
918
|
-
{
|
|
919
|
-
type: "text",
|
|
920
|
-
text: `Failed to get inbox: ${errorMessage}`
|
|
921
|
-
}
|
|
922
|
-
],
|
|
923
|
-
isError: true
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
904
|
var replySchema = {
|
|
929
905
|
questionId: z.string().describe("The ID of the question to reply to (from inbox)"),
|
|
930
906
|
answer: z.string().describe("Your answer to the question (supports markdown)")
|
|
@@ -970,52 +946,69 @@ function registerReplyTool(server, client) {
|
|
|
970
946
|
});
|
|
971
947
|
}
|
|
972
948
|
|
|
949
|
+
// src/presentation/mcp/tools/peers.tool.ts
|
|
950
|
+
function registerPeersTool(server, client) {
|
|
951
|
+
server.tool("peers", {}, async () => {
|
|
952
|
+
const info = client.getInfo();
|
|
953
|
+
const myName = info.teamName ?? "(starting...)";
|
|
954
|
+
const connected = info.connectedPeers;
|
|
955
|
+
if (connected.length === 0) {
|
|
956
|
+
return {
|
|
957
|
+
content: [{
|
|
958
|
+
type: "text",
|
|
959
|
+
text: `You are "${myName}". No peers connected yet \u2014 they will appear automatically when they come online.`
|
|
960
|
+
}]
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const list = connected.map((name) => ` \u2022 ${name}`).join("\n");
|
|
964
|
+
return {
|
|
965
|
+
content: [{
|
|
966
|
+
type: "text",
|
|
967
|
+
text: `You are "${myName}". Connected peers (${connected.length}):
|
|
968
|
+
${list}`
|
|
969
|
+
}]
|
|
970
|
+
};
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/presentation/mcp/tools/history.tool.ts
|
|
975
|
+
function registerHistoryTool(server, client) {
|
|
976
|
+
server.tool("history", {}, async () => {
|
|
977
|
+
const entries = client.getHistory();
|
|
978
|
+
if (entries.length === 0) {
|
|
979
|
+
return {
|
|
980
|
+
content: [{ type: "text", text: "No questions yet this session." }]
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const lines = entries.map((e) => {
|
|
984
|
+
const time = new Date(e.askedAt).toLocaleTimeString();
|
|
985
|
+
if (e.direction === "sent") {
|
|
986
|
+
const answerLine = e.answer ? ` \u21B3 ${e.peer}: ${e.answer}` : ` \u21B3 (no answer yet)`;
|
|
987
|
+
return `[${time}] \u2192 ${e.peer}: ${e.question}
|
|
988
|
+
${answerLine}`;
|
|
989
|
+
} else {
|
|
990
|
+
const answerLine = e.answer ? ` \u21B3 you: ${e.answer}` : ` \u21B3 (not replied yet)`;
|
|
991
|
+
return `[${time}] \u2190 ${e.peer}: ${e.question}
|
|
992
|
+
${answerLine}`;
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
return {
|
|
996
|
+
content: [{ type: "text", text: lines.join("\n\n") }]
|
|
997
|
+
};
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
973
1001
|
// src/presentation/mcp/server.ts
|
|
974
1002
|
function createMcpServer(options) {
|
|
975
1003
|
const { client } = options;
|
|
976
|
-
const server = new McpServer(
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
},
|
|
981
|
-
{
|
|
982
|
-
capabilities: {
|
|
983
|
-
resources: {
|
|
984
|
-
subscribe: true,
|
|
985
|
-
listChanged: true
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
);
|
|
990
|
-
registerJoinTool(server, client);
|
|
991
|
-
registerConnectPeerTool(server, client);
|
|
992
|
-
registerGetInfoTool(server, client);
|
|
1004
|
+
const server = new McpServer({
|
|
1005
|
+
name: "claude-collab",
|
|
1006
|
+
version: "0.1.0"
|
|
1007
|
+
});
|
|
993
1008
|
registerAskTool(server, client);
|
|
994
|
-
registerCheckAnswerTool(server, client);
|
|
995
|
-
registerInboxTool(server, client);
|
|
996
1009
|
registerReplyTool(server, client);
|
|
997
|
-
server
|
|
998
|
-
|
|
999
|
-
"inbox://questions",
|
|
1000
|
-
{ description: "Your inbox of pending questions from other teams", mimeType: "application/json" },
|
|
1001
|
-
async () => {
|
|
1002
|
-
try {
|
|
1003
|
-
const inbox = await client.getInbox();
|
|
1004
|
-
return {
|
|
1005
|
-
contents: [
|
|
1006
|
-
{
|
|
1007
|
-
uri: "inbox://questions",
|
|
1008
|
-
mimeType: "application/json",
|
|
1009
|
-
text: JSON.stringify(inbox, null, 2)
|
|
1010
|
-
}
|
|
1011
|
-
]
|
|
1012
|
-
};
|
|
1013
|
-
} catch (error) {
|
|
1014
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1015
|
-
throw new Error(`Failed to read inbox: ${errorMessage}`);
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
);
|
|
1010
|
+
registerPeersTool(server, client);
|
|
1011
|
+
registerHistoryTool(server, client);
|
|
1019
1012
|
return server;
|
|
1020
1013
|
}
|
|
1021
1014
|
async function startMcpServer(options) {
|
|
@@ -1025,8 +1018,19 @@ async function startMcpServer(options) {
|
|
|
1025
1018
|
}
|
|
1026
1019
|
|
|
1027
1020
|
// src/mcp-main.ts
|
|
1021
|
+
function parseName() {
|
|
1022
|
+
const idx = process.argv.indexOf("--name");
|
|
1023
|
+
const value = process.argv[idx + 1];
|
|
1024
|
+
if (idx !== -1 && value) {
|
|
1025
|
+
return value;
|
|
1026
|
+
}
|
|
1027
|
+
console.error("Usage: claude-collab --name <your-name>");
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1028
1030
|
async function main() {
|
|
1031
|
+
const name = parseName();
|
|
1029
1032
|
const p2pNode = new P2PNode();
|
|
1033
|
+
await p2pNode.join(name, name);
|
|
1030
1034
|
await startMcpServer({ client: p2pNode });
|
|
1031
1035
|
}
|
|
1032
1036
|
main().catch((error) => {
|