@dolusoft/claude-collab 1.3.1 → 1.4.2
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 +161 -292
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +171 -328
- package/dist/mcp-main.js.map +1 -1
- package/package.json +1 -3
package/dist/cli.js
CHANGED
|
@@ -1,170 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
-
import { createServer } from 'net';
|
|
5
4
|
import { v4 } from 'uuid';
|
|
6
|
-
import multicastDns from 'multicast-dns';
|
|
7
|
-
import { networkInterfaces, tmpdir } from 'os';
|
|
8
5
|
import { EventEmitter } from 'events';
|
|
9
6
|
import { execFile } from 'child_process';
|
|
10
7
|
import { unlinkSync } from 'fs';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
11
9
|
import { join } from 'path';
|
|
12
10
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
12
|
import { z } from 'zod';
|
|
15
13
|
|
|
16
|
-
function getLocalIp() {
|
|
17
|
-
const nets = networkInterfaces();
|
|
18
|
-
for (const name of Object.keys(nets)) {
|
|
19
|
-
for (const net of nets[name] ?? []) {
|
|
20
|
-
if (net.family === "IPv4" && !net.internal) {
|
|
21
|
-
return net.address;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return "127.0.0.1";
|
|
26
|
-
}
|
|
27
|
-
var SERVICE_TYPE = "_claude-collab._tcp.local";
|
|
28
|
-
var MdnsDiscovery = class {
|
|
29
|
-
mdns;
|
|
30
|
-
announced = false;
|
|
31
|
-
port = 0;
|
|
32
|
-
teamName = "";
|
|
33
|
-
memberId = "";
|
|
34
|
-
peersByTeam = /* @__PURE__ */ new Map();
|
|
35
|
-
peersByMemberId = /* @__PURE__ */ new Map();
|
|
36
|
-
onPeerFoundCb;
|
|
37
|
-
onPeerLostCb;
|
|
38
|
-
constructor() {
|
|
39
|
-
this.mdns = multicastDns();
|
|
40
|
-
this.setupHandlers();
|
|
41
|
-
}
|
|
42
|
-
get serviceName() {
|
|
43
|
-
return `${this.memberId}.${SERVICE_TYPE}`;
|
|
44
|
-
}
|
|
45
|
-
buildAnswers() {
|
|
46
|
-
return [
|
|
47
|
-
{
|
|
48
|
-
name: SERVICE_TYPE,
|
|
49
|
-
type: "PTR",
|
|
50
|
-
ttl: 300,
|
|
51
|
-
data: this.serviceName
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: this.serviceName,
|
|
55
|
-
type: "SRV",
|
|
56
|
-
ttl: 300,
|
|
57
|
-
data: {
|
|
58
|
-
priority: 0,
|
|
59
|
-
weight: 0,
|
|
60
|
-
port: this.port,
|
|
61
|
-
target: getLocalIp()
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: this.serviceName,
|
|
66
|
-
type: "TXT",
|
|
67
|
-
ttl: 300,
|
|
68
|
-
data: [
|
|
69
|
-
Buffer.from(`team=${this.teamName}`),
|
|
70
|
-
Buffer.from(`memberId=${this.memberId}`),
|
|
71
|
-
Buffer.from("ver=1")
|
|
72
|
-
]
|
|
73
|
-
}
|
|
74
|
-
];
|
|
75
|
-
}
|
|
76
|
-
setupHandlers() {
|
|
77
|
-
this.mdns.on("query", (query) => {
|
|
78
|
-
if (!this.announced) return;
|
|
79
|
-
const questions = query.questions ?? [];
|
|
80
|
-
const ptrQuery = questions.find(
|
|
81
|
-
(q) => q.type === "PTR" && q.name === SERVICE_TYPE
|
|
82
|
-
);
|
|
83
|
-
if (!ptrQuery) return;
|
|
84
|
-
this.mdns.respond({ answers: this.buildAnswers() });
|
|
85
|
-
});
|
|
86
|
-
this.mdns.on("response", (response) => {
|
|
87
|
-
this.parseResponse(response);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
parseResponse(response) {
|
|
91
|
-
const allRecords = [
|
|
92
|
-
...response.answers ?? [],
|
|
93
|
-
...response.additionals ?? []
|
|
94
|
-
];
|
|
95
|
-
const ptrRecords = allRecords.filter(
|
|
96
|
-
(r) => r.type === "PTR" && r.name === SERVICE_TYPE
|
|
97
|
-
);
|
|
98
|
-
for (const ptr of ptrRecords) {
|
|
99
|
-
const instanceName = ptr.data;
|
|
100
|
-
const srv = allRecords.find(
|
|
101
|
-
(r) => r.type === "SRV" && r.name === instanceName
|
|
102
|
-
);
|
|
103
|
-
const txt = allRecords.find(
|
|
104
|
-
(r) => r.type === "TXT" && r.name === instanceName
|
|
105
|
-
);
|
|
106
|
-
if (!srv) continue;
|
|
107
|
-
const port = srv.data.port;
|
|
108
|
-
const host = srv.data.target || "127.0.0.1";
|
|
109
|
-
let teamName = "";
|
|
110
|
-
let memberId = "";
|
|
111
|
-
if (txt) {
|
|
112
|
-
const txtData = txt.data ?? [];
|
|
113
|
-
for (const entry of txtData) {
|
|
114
|
-
const str = Buffer.isBuffer(entry) ? entry.toString() : String(entry);
|
|
115
|
-
if (str.startsWith("team=")) teamName = str.slice(5);
|
|
116
|
-
if (str.startsWith("memberId=")) memberId = str.slice(9);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
if (!teamName || !memberId) continue;
|
|
120
|
-
if (memberId === this.memberId) continue;
|
|
121
|
-
const ptrTtl = ptr.ttl ?? 300;
|
|
122
|
-
const srvTtl = srv.ttl ?? 300;
|
|
123
|
-
if (ptrTtl === 0 || srvTtl === 0) {
|
|
124
|
-
this.peersByTeam.delete(teamName);
|
|
125
|
-
this.peersByMemberId.delete(memberId);
|
|
126
|
-
this.onPeerLostCb?.(memberId);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const peer = { host, port, teamName, memberId };
|
|
130
|
-
this.peersByTeam.set(teamName, peer);
|
|
131
|
-
this.peersByMemberId.set(memberId, peer);
|
|
132
|
-
this.onPeerFoundCb?.(peer);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Announce this node's service via mDNS.
|
|
137
|
-
* Sends an unsolicited response so existing peers notice immediately.
|
|
138
|
-
*/
|
|
139
|
-
announce(port, teamName, memberId) {
|
|
140
|
-
this.port = port;
|
|
141
|
-
this.teamName = teamName;
|
|
142
|
-
this.memberId = memberId;
|
|
143
|
-
this.announced = true;
|
|
144
|
-
this.mdns.respond({ answers: this.buildAnswers() });
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Send a PTR query to discover existing peers.
|
|
148
|
-
*/
|
|
149
|
-
discover() {
|
|
150
|
-
this.mdns.query({
|
|
151
|
-
questions: [{ name: SERVICE_TYPE, type: "PTR" }]
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
getPeerByTeam(teamName) {
|
|
155
|
-
return this.peersByTeam.get(teamName);
|
|
156
|
-
}
|
|
157
|
-
onPeerFound(cb) {
|
|
158
|
-
this.onPeerFoundCb = cb;
|
|
159
|
-
}
|
|
160
|
-
onPeerLost(cb) {
|
|
161
|
-
this.onPeerLostCb = cb;
|
|
162
|
-
}
|
|
163
|
-
destroy() {
|
|
164
|
-
this.mdns.destroy();
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
|
|
168
14
|
// src/infrastructure/p2p/p2p-message-protocol.ts
|
|
169
15
|
function serializeP2PMsg(msg) {
|
|
170
16
|
return JSON.stringify(msg);
|
|
@@ -180,8 +26,6 @@ using System.Runtime.InteropServices;
|
|
|
180
26
|
public class ConInject {
|
|
181
27
|
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
|
|
182
28
|
[DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
|
|
183
|
-
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
|
|
184
|
-
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
|
|
185
29
|
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
186
30
|
public static extern IntPtr CreateFile(
|
|
187
31
|
string lpFileName, uint dwDesiredAccess, uint dwShareMode,
|
|
@@ -210,7 +54,7 @@ public class ConInject {
|
|
|
210
54
|
return CreateFile("CONIN$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
|
|
211
55
|
}
|
|
212
56
|
|
|
213
|
-
// Inject
|
|
57
|
+
// Inject plain text characters into console input buffer
|
|
214
58
|
public static int InjectText(uint pid, string text) {
|
|
215
59
|
IntPtr hIn = OpenConin(pid);
|
|
216
60
|
if (hIn == new IntPtr(-1)) return -1;
|
|
@@ -228,13 +72,56 @@ public class ConInject {
|
|
|
228
72
|
return ok ? (int)written : -2;
|
|
229
73
|
}
|
|
230
74
|
|
|
231
|
-
//
|
|
232
|
-
public static
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
75
|
+
// Inject Enter (VK_RETURN = 0x0D)
|
|
76
|
+
public static int InjectEnter(uint pid) {
|
|
77
|
+
IntPtr hIn = OpenConin(pid);
|
|
78
|
+
if (hIn == new IntPtr(-1)) return -1;
|
|
79
|
+
|
|
80
|
+
var records = new INPUT_RECORD[] {
|
|
81
|
+
new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, wVirtualKeyCode=0x0D, UnicodeChar=0x0D },
|
|
82
|
+
new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, wVirtualKeyCode=0x0D, UnicodeChar=0x0D }
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
uint written;
|
|
86
|
+
bool ok = WriteConsoleInput(hIn, records, (uint)records.Length, out written);
|
|
87
|
+
CloseHandle(hIn);
|
|
88
|
+
return ok ? (int)written : -2;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Inject Ctrl+U (VK_U = 0x55, char = 0x15)
|
|
92
|
+
public static int InjectCtrlU(uint pid) {
|
|
93
|
+
IntPtr hIn = OpenConin(pid);
|
|
94
|
+
if (hIn == new IntPtr(-1)) return -1;
|
|
95
|
+
|
|
96
|
+
var records = new INPUT_RECORD[] {
|
|
97
|
+
new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, wVirtualKeyCode=0xA2, dwControlKeyState=LEFT_CTRL },
|
|
98
|
+
new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, wVirtualKeyCode=0x55, UnicodeChar=0x15, dwControlKeyState=LEFT_CTRL },
|
|
99
|
+
new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, wVirtualKeyCode=0x55, UnicodeChar=0x15, dwControlKeyState=LEFT_CTRL },
|
|
100
|
+
new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, wVirtualKeyCode=0xA2, dwControlKeyState=0 }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
uint written;
|
|
104
|
+
bool ok = WriteConsoleInput(hIn, records, (uint)records.Length, out written);
|
|
105
|
+
CloseHandle(hIn);
|
|
106
|
+
return ok ? (int)written : -2;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Inject Ctrl+Y (VK_Y = 0x59, char = 0x19)
|
|
110
|
+
public static int InjectCtrlY(uint pid) {
|
|
111
|
+
IntPtr hIn = OpenConin(pid);
|
|
112
|
+
if (hIn == new IntPtr(-1)) return -1;
|
|
113
|
+
|
|
114
|
+
var records = new INPUT_RECORD[] {
|
|
115
|
+
new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, wVirtualKeyCode=0xA2, dwControlKeyState=LEFT_CTRL },
|
|
116
|
+
new INPUT_RECORD { EventType=1, bKeyDown=1, wRepeatCount=1, wVirtualKeyCode=0x59, UnicodeChar=0x19, dwControlKeyState=LEFT_CTRL },
|
|
117
|
+
new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, wVirtualKeyCode=0x59, UnicodeChar=0x19, dwControlKeyState=LEFT_CTRL },
|
|
118
|
+
new INPUT_RECORD { EventType=1, bKeyDown=0, wRepeatCount=1, wVirtualKeyCode=0xA2, dwControlKeyState=0 }
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
uint written;
|
|
122
|
+
bool ok = WriteConsoleInput(hIn, records, (uint)records.Length, out written);
|
|
123
|
+
CloseHandle(hIn);
|
|
124
|
+
return ok ? (int)written : -2;
|
|
238
125
|
}
|
|
239
126
|
}
|
|
240
127
|
`;
|
|
@@ -272,33 +159,24 @@ async function windowsInject(text) {
|
|
|
272
159
|
const script = buildScript(claudePid, `
|
|
273
160
|
$textBytes = [System.Convert]::FromBase64String('${textB64}')
|
|
274
161
|
$text = [System.Text.Encoding]::Unicode.GetString($textBytes)
|
|
275
|
-
$wsh = New-Object -ComObject WScript.Shell
|
|
276
162
|
|
|
277
|
-
# 1.
|
|
278
|
-
|
|
279
|
-
Start-Sleep -Milliseconds
|
|
280
|
-
$wsh.SendKeys('^u')
|
|
281
|
-
Start-Sleep -Milliseconds 150
|
|
163
|
+
# 1. Ctrl+U to save user's current text to kill ring
|
|
164
|
+
[ConInject]::InjectCtrlU([uint32]$claudePid) | Out-Null
|
|
165
|
+
Start-Sleep -Milliseconds 100
|
|
282
166
|
|
|
283
167
|
# 2. Write question text into console input buffer
|
|
284
168
|
[ConInject]::InjectText([uint32]$claudePid, $text) | Out-Null
|
|
169
|
+
Start-Sleep -Milliseconds 50
|
|
285
170
|
|
|
286
|
-
# 3.
|
|
287
|
-
[ConInject]::
|
|
288
|
-
Start-Sleep -Milliseconds 150
|
|
289
|
-
|
|
290
|
-
# 4. Send Enter
|
|
291
|
-
$wsh.SendKeys('~')
|
|
171
|
+
# 3. Send Enter
|
|
172
|
+
[ConInject]::InjectEnter([uint32]$claudePid) | Out-Null
|
|
292
173
|
`);
|
|
293
174
|
await run(script);
|
|
294
175
|
}
|
|
295
176
|
async function windowsInjectCtrlY() {
|
|
296
177
|
const claudePid = process.ppid;
|
|
297
178
|
const script = buildScript(claudePid, `
|
|
298
|
-
$
|
|
299
|
-
$hwnd = [ConInject]::FocusConsole([uint32]$claudePid)
|
|
300
|
-
Start-Sleep -Milliseconds 150
|
|
301
|
-
$wsh.SendKeys('^y')
|
|
179
|
+
[ConInject]::InjectCtrlY([uint32]$claudePid) | Out-Null
|
|
302
180
|
`);
|
|
303
181
|
await run(script);
|
|
304
182
|
}
|
|
@@ -365,35 +243,18 @@ var config = {
|
|
|
365
243
|
*/
|
|
366
244
|
p2p: {
|
|
367
245
|
/**
|
|
368
|
-
*
|
|
369
|
-
*/
|
|
370
|
-
portRangeMin: 1e4,
|
|
371
|
-
/**
|
|
372
|
-
* Maximum port for the random WS server port range
|
|
246
|
+
* Fixed port for the WS server. Override with CLAUDE_COLLAB_PORT env var.
|
|
373
247
|
*/
|
|
374
|
-
|
|
248
|
+
port: Number(process.env["CLAUDE_COLLAB_PORT"] ?? 11777)
|
|
375
249
|
}};
|
|
376
250
|
|
|
377
251
|
// src/infrastructure/p2p/p2p-node.ts
|
|
378
|
-
function getRandomPort(min, max) {
|
|
379
|
-
return new Promise((resolve) => {
|
|
380
|
-
const port = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
381
|
-
const server = createServer();
|
|
382
|
-
server.listen(port, () => {
|
|
383
|
-
server.close(() => resolve(port));
|
|
384
|
-
});
|
|
385
|
-
server.on("error", () => {
|
|
386
|
-
resolve(getRandomPort(min, max));
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
252
|
var P2PNode = class {
|
|
391
253
|
wss = null;
|
|
392
|
-
mdnsDiscovery = null;
|
|
393
254
|
port = 0;
|
|
394
255
|
// Connections indexed by remote team name
|
|
395
256
|
peerConns = /* @__PURE__ */ new Map();
|
|
396
|
-
// Reverse lookup: ws → teamName (for cleanup
|
|
257
|
+
// Reverse lookup: ws → teamName (for cleanup)
|
|
397
258
|
wsToTeam = /* @__PURE__ */ new Map();
|
|
398
259
|
// Questions we received from remote peers (our inbox)
|
|
399
260
|
incomingQuestions = /* @__PURE__ */ new Map();
|
|
@@ -412,14 +273,13 @@ var P2PNode = class {
|
|
|
412
273
|
return this.localMember?.teamName;
|
|
413
274
|
}
|
|
414
275
|
/**
|
|
415
|
-
* Starts the WS server on
|
|
276
|
+
* Starts the WS server on the configured fixed port.
|
|
416
277
|
* Called automatically from join() if not yet started.
|
|
417
278
|
*/
|
|
418
279
|
async start() {
|
|
419
|
-
this.port =
|
|
280
|
+
this.port = config.p2p.port;
|
|
420
281
|
this.wss = new WebSocketServer({ port: this.port });
|
|
421
282
|
this.setupWssHandlers();
|
|
422
|
-
this.mdnsDiscovery = new MdnsDiscovery();
|
|
423
283
|
this._isStarted = true;
|
|
424
284
|
console.error(`P2P node started on port ${this.port}`);
|
|
425
285
|
}
|
|
@@ -429,17 +289,60 @@ var P2PNode = class {
|
|
|
429
289
|
}
|
|
430
290
|
const memberId = v4();
|
|
431
291
|
this.localMember = { memberId, teamName, displayName };
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
292
|
+
return { memberId, teamId: teamName, teamName, displayName, status: "ONLINE" };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Connects to a peer at the given IP and port.
|
|
296
|
+
* Performs a bidirectional HELLO handshake and returns the peer's team name.
|
|
297
|
+
*/
|
|
298
|
+
async connectPeer(ip, port) {
|
|
299
|
+
if (!this.localMember) {
|
|
300
|
+
throw new Error("Must call join() before connectPeer()");
|
|
301
|
+
}
|
|
302
|
+
const targetPort = port ?? config.p2p.port;
|
|
303
|
+
const ws = new WebSocket(`ws://${ip}:${targetPort}`);
|
|
304
|
+
ws.on("message", (data) => {
|
|
305
|
+
try {
|
|
306
|
+
const msg = parseP2PMsg(data.toString());
|
|
307
|
+
this.handleMessage(ws, msg);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error("Failed to parse P2P message:", err);
|
|
438
310
|
}
|
|
439
311
|
});
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
312
|
+
ws.on("close", () => {
|
|
313
|
+
const team = this.wsToTeam.get(ws);
|
|
314
|
+
if (team) {
|
|
315
|
+
if (this.peerConns.get(team) === ws) {
|
|
316
|
+
this.peerConns.delete(team);
|
|
317
|
+
}
|
|
318
|
+
this.wsToTeam.delete(ws);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
await new Promise((resolve, reject) => {
|
|
322
|
+
const timeout = setTimeout(
|
|
323
|
+
() => reject(new Error(`Connection timeout to ${ip}:${targetPort}`)),
|
|
324
|
+
5e3
|
|
325
|
+
);
|
|
326
|
+
ws.on("open", () => {
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
const hello = {
|
|
329
|
+
type: "P2P_HELLO",
|
|
330
|
+
fromTeam: this.localMember.teamName,
|
|
331
|
+
fromMemberId: this.localMember.memberId
|
|
332
|
+
};
|
|
333
|
+
ws.send(serializeP2PMsg(hello));
|
|
334
|
+
resolve();
|
|
335
|
+
});
|
|
336
|
+
ws.on("error", (err) => {
|
|
337
|
+
clearTimeout(timeout);
|
|
338
|
+
reject(err);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const helloMsg = await this.waitForResponse(
|
|
342
|
+
(m) => m.type === "P2P_HELLO",
|
|
343
|
+
1e4
|
|
344
|
+
);
|
|
345
|
+
return helloMsg.fromTeam;
|
|
443
346
|
}
|
|
444
347
|
async ask(toTeam, content, format) {
|
|
445
348
|
const ws = await this.getPeerConnection(toTeam);
|
|
@@ -545,7 +448,6 @@ var P2PNode = class {
|
|
|
545
448
|
};
|
|
546
449
|
}
|
|
547
450
|
async disconnect() {
|
|
548
|
-
this.mdnsDiscovery?.destroy();
|
|
549
451
|
for (const ws of this.peerConns.values()) {
|
|
550
452
|
ws.close();
|
|
551
453
|
}
|
|
@@ -567,6 +469,14 @@ var P2PNode = class {
|
|
|
567
469
|
ws.on("message", (data) => {
|
|
568
470
|
try {
|
|
569
471
|
const msg = parseP2PMsg(data.toString());
|
|
472
|
+
if (msg.type === "P2P_HELLO" && this.localMember) {
|
|
473
|
+
const hello = {
|
|
474
|
+
type: "P2P_HELLO",
|
|
475
|
+
fromTeam: this.localMember.teamName,
|
|
476
|
+
fromMemberId: this.localMember.memberId
|
|
477
|
+
};
|
|
478
|
+
ws.send(serializeP2PMsg(hello));
|
|
479
|
+
}
|
|
570
480
|
this.handleMessage(ws, msg);
|
|
571
481
|
} catch (err) {
|
|
572
482
|
console.error("Failed to parse incoming P2P message:", err);
|
|
@@ -584,7 +494,7 @@ var P2PNode = class {
|
|
|
584
494
|
});
|
|
585
495
|
}
|
|
586
496
|
// ---------------------------------------------------------------------------
|
|
587
|
-
// Private: unified message handler
|
|
497
|
+
// Private: unified message handler
|
|
588
498
|
// ---------------------------------------------------------------------------
|
|
589
499
|
handleMessage(ws, msg) {
|
|
590
500
|
for (const handler of this.pendingHandlers) {
|
|
@@ -679,78 +589,9 @@ var P2PNode = class {
|
|
|
679
589
|
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
680
590
|
return existing;
|
|
681
591
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
await this.waitForMdnsPeer(teamName, 1e4);
|
|
686
|
-
peer = this.mdnsDiscovery?.getPeerByTeam(teamName);
|
|
687
|
-
}
|
|
688
|
-
if (!peer) {
|
|
689
|
-
throw new Error(
|
|
690
|
-
`Peer for team '${teamName}' not found via mDNS. Make sure the other terminal has joined with that team name.`
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
return this.connectToPeer(teamName, peer.host, peer.port);
|
|
694
|
-
}
|
|
695
|
-
async connectToPeer(teamName, host, port) {
|
|
696
|
-
const existing = this.peerConns.get(teamName);
|
|
697
|
-
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
698
|
-
return existing;
|
|
699
|
-
}
|
|
700
|
-
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
701
|
-
await new Promise((resolve, reject) => {
|
|
702
|
-
const timeout = setTimeout(
|
|
703
|
-
() => reject(new Error(`Connection timeout to team '${teamName}'`)),
|
|
704
|
-
5e3
|
|
705
|
-
);
|
|
706
|
-
ws.on("open", () => {
|
|
707
|
-
clearTimeout(timeout);
|
|
708
|
-
const hello = {
|
|
709
|
-
type: "P2P_HELLO",
|
|
710
|
-
fromTeam: this.localMember?.teamName ?? "unknown",
|
|
711
|
-
fromMemberId: this.localMember?.memberId ?? "unknown"
|
|
712
|
-
};
|
|
713
|
-
ws.send(serializeP2PMsg(hello));
|
|
714
|
-
resolve();
|
|
715
|
-
});
|
|
716
|
-
ws.on("error", (err) => {
|
|
717
|
-
clearTimeout(timeout);
|
|
718
|
-
reject(err);
|
|
719
|
-
});
|
|
720
|
-
});
|
|
721
|
-
ws.on("message", (data) => {
|
|
722
|
-
try {
|
|
723
|
-
const msg = parseP2PMsg(data.toString());
|
|
724
|
-
this.handleMessage(ws, msg);
|
|
725
|
-
} catch (err) {
|
|
726
|
-
console.error("Failed to parse P2P message:", err);
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
ws.on("close", () => {
|
|
730
|
-
if (this.peerConns.get(teamName) === ws) {
|
|
731
|
-
this.peerConns.delete(teamName);
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
this.peerConns.set(teamName, ws);
|
|
735
|
-
return ws;
|
|
736
|
-
}
|
|
737
|
-
waitForMdnsPeer(teamName, timeoutMs) {
|
|
738
|
-
return new Promise((resolve, reject) => {
|
|
739
|
-
const deadline = Date.now() + timeoutMs;
|
|
740
|
-
const check = () => {
|
|
741
|
-
if (this.mdnsDiscovery?.getPeerByTeam(teamName)) {
|
|
742
|
-
resolve();
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
if (Date.now() >= deadline) {
|
|
746
|
-
reject(new Error(`mDNS timeout: team '${teamName}' not found`));
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
this.mdnsDiscovery?.discover();
|
|
750
|
-
setTimeout(check, 500);
|
|
751
|
-
};
|
|
752
|
-
check();
|
|
753
|
-
});
|
|
592
|
+
throw new Error(
|
|
593
|
+
`No connection to team '${teamName}'. Use the connect_peer tool to connect first.`
|
|
594
|
+
);
|
|
754
595
|
}
|
|
755
596
|
waitForResponse(filter, timeoutMs) {
|
|
756
597
|
return new Promise((resolve, reject) => {
|
|
@@ -805,6 +646,37 @@ Status: ${member.status}`
|
|
|
805
646
|
}
|
|
806
647
|
});
|
|
807
648
|
}
|
|
649
|
+
var connectPeerSchema = {
|
|
650
|
+
ip: z.string().describe('IP address of the peer to connect to (e.g., "172.16.40.137")'),
|
|
651
|
+
port: z.number().optional().describe(`Port the peer is listening on (default: ${config.p2p.port})`)
|
|
652
|
+
};
|
|
653
|
+
function registerConnectPeerTool(server, client) {
|
|
654
|
+
server.tool("connect_peer", connectPeerSchema, async (args) => {
|
|
655
|
+
const targetPort = args.port ?? config.p2p.port;
|
|
656
|
+
try {
|
|
657
|
+
const peerTeam = await client.connectPeer(args.ip, args.port);
|
|
658
|
+
return {
|
|
659
|
+
content: [
|
|
660
|
+
{
|
|
661
|
+
type: "text",
|
|
662
|
+
text: `Connected to peer "${peerTeam}" at ${args.ip}:${targetPort}.`
|
|
663
|
+
}
|
|
664
|
+
]
|
|
665
|
+
};
|
|
666
|
+
} catch (error) {
|
|
667
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
668
|
+
return {
|
|
669
|
+
content: [
|
|
670
|
+
{
|
|
671
|
+
type: "text",
|
|
672
|
+
text: `Failed to connect to ${args.ip}:${targetPort}: ${errorMessage}`
|
|
673
|
+
}
|
|
674
|
+
],
|
|
675
|
+
isError: true
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
808
680
|
var askSchema = {
|
|
809
681
|
team: z.string().describe('Target team name to ask (e.g., "backend", "frontend")'),
|
|
810
682
|
question: z.string().describe("The question to ask (supports markdown)")
|
|
@@ -1051,6 +923,7 @@ function createMcpServer(options) {
|
|
|
1051
923
|
}
|
|
1052
924
|
);
|
|
1053
925
|
registerJoinTool(server, client);
|
|
926
|
+
registerConnectPeerTool(server, client);
|
|
1054
927
|
registerAskTool(server, client);
|
|
1055
928
|
registerCheckAnswerTool(server, client);
|
|
1056
929
|
registerInboxTool(server, client);
|
|
@@ -1088,14 +961,10 @@ async function startMcpServer(options) {
|
|
|
1088
961
|
// src/cli.ts
|
|
1089
962
|
var program = new Command();
|
|
1090
963
|
program.name("claude-collab").description("Real-time P2P team collaboration between Claude Code terminals").version("0.1.0");
|
|
1091
|
-
program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").
|
|
964
|
+
program.command("client").description("Start MCP client (P2P mode, connects to Claude Code)").action(async () => {
|
|
1092
965
|
const p2pNode = new P2PNode();
|
|
1093
966
|
try {
|
|
1094
967
|
await p2pNode.start();
|
|
1095
|
-
if (options.team) {
|
|
1096
|
-
await p2pNode.join(options.team, `${options.team} Claude`);
|
|
1097
|
-
console.error(`Auto-joined team: ${options.team}`);
|
|
1098
|
-
}
|
|
1099
968
|
} catch (error) {
|
|
1100
969
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1101
970
|
console.error(`Failed to start P2P node: ${errorMessage}`);
|