@dolusoft/claude-collab 1.9.1 → 1.9.3
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 +333 -131
- package/dist/cli.js.map +1 -1
- package/dist/mcp-main.js +333 -131
- package/dist/mcp-main.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -298,8 +298,8 @@ var injectionQueue = new InjectionQueue();
|
|
|
298
298
|
|
|
299
299
|
// src/infrastructure/p2p/p2p-node.ts
|
|
300
300
|
var P2PNode = class {
|
|
301
|
-
constructor(
|
|
302
|
-
this.
|
|
301
|
+
constructor(portRange = [1e4, 19999]) {
|
|
302
|
+
this.portRange = portRange;
|
|
303
303
|
}
|
|
304
304
|
server = null;
|
|
305
305
|
myName = "";
|
|
@@ -315,14 +315,22 @@ var P2PNode = class {
|
|
|
315
315
|
sentQuestions = /* @__PURE__ */ new Map();
|
|
316
316
|
questionToSender = /* @__PURE__ */ new Map();
|
|
317
317
|
pendingHandlers = /* @__PURE__ */ new Set();
|
|
318
|
+
// Push-based answer resolution: questionId → resolve callback
|
|
319
|
+
answerWaiters = /* @__PURE__ */ new Map();
|
|
320
|
+
// Answers queued for offline peers: peerName → AnswerMsg (delivered on reconnect)
|
|
321
|
+
pendingOutboundAnswers = /* @__PURE__ */ new Map();
|
|
318
322
|
broadcaster = null;
|
|
319
323
|
stopPeerWatcher = null;
|
|
324
|
+
boundPort = 0;
|
|
320
325
|
// ---------------------------------------------------------------------------
|
|
321
326
|
// ICollabClient implementation
|
|
322
327
|
// ---------------------------------------------------------------------------
|
|
323
328
|
get isConnected() {
|
|
324
329
|
return this.running;
|
|
325
330
|
}
|
|
331
|
+
get port() {
|
|
332
|
+
return this.boundPort;
|
|
333
|
+
}
|
|
326
334
|
get currentTeamId() {
|
|
327
335
|
return this.myName || void 0;
|
|
328
336
|
}
|
|
@@ -336,7 +344,7 @@ var P2PNode = class {
|
|
|
336
344
|
teamName: name,
|
|
337
345
|
displayName,
|
|
338
346
|
status: "ONLINE",
|
|
339
|
-
port: this.
|
|
347
|
+
port: this.boundPort
|
|
340
348
|
};
|
|
341
349
|
}
|
|
342
350
|
async ask(toPeer, content, format) {
|
|
@@ -354,9 +362,27 @@ var P2PNode = class {
|
|
|
354
362
|
await ackPromise;
|
|
355
363
|
return questionId;
|
|
356
364
|
}
|
|
365
|
+
waitForAnswer(questionId, timeoutMs) {
|
|
366
|
+
const cached = this.receivedAnswers.get(questionId);
|
|
367
|
+
if (cached) {
|
|
368
|
+
return Promise.resolve(this.formatAnswer(questionId, cached));
|
|
369
|
+
}
|
|
370
|
+
return new Promise((resolve) => {
|
|
371
|
+
const timeout = setTimeout(() => {
|
|
372
|
+
this.answerWaiters.delete(questionId);
|
|
373
|
+
resolve(null);
|
|
374
|
+
}, timeoutMs);
|
|
375
|
+
this.answerWaiters.set(questionId, (result) => {
|
|
376
|
+
clearTimeout(timeout);
|
|
377
|
+
resolve(result);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
357
381
|
async checkAnswer(questionId) {
|
|
358
382
|
const cached = this.receivedAnswers.get(questionId);
|
|
359
|
-
|
|
383
|
+
return cached ? this.formatAnswer(questionId, cached) : null;
|
|
384
|
+
}
|
|
385
|
+
formatAnswer(questionId, cached) {
|
|
360
386
|
return {
|
|
361
387
|
questionId,
|
|
362
388
|
from: { displayName: `${cached.fromName} Claude`, teamName: cached.fromName },
|
|
@@ -373,16 +399,20 @@ var P2PNode = class {
|
|
|
373
399
|
question.answerFormat = format;
|
|
374
400
|
const senderName = this.questionToSender.get(questionId);
|
|
375
401
|
if (senderName) {
|
|
402
|
+
const answerMsg = {
|
|
403
|
+
type: "ANSWER",
|
|
404
|
+
from: this.myName,
|
|
405
|
+
questionId,
|
|
406
|
+
content,
|
|
407
|
+
format,
|
|
408
|
+
answeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
409
|
+
};
|
|
376
410
|
const ws = this.peerConnections.get(senderName);
|
|
377
411
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
378
|
-
this.sendToWs(ws,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
content,
|
|
383
|
-
format,
|
|
384
|
-
answeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
385
|
-
});
|
|
412
|
+
this.sendToWs(ws, answerMsg);
|
|
413
|
+
} else {
|
|
414
|
+
this.pendingOutboundAnswers.set(senderName, answerMsg);
|
|
415
|
+
console.error(`[p2p] "${senderName}" is offline, answer queued for delivery on reconnect`);
|
|
386
416
|
}
|
|
387
417
|
}
|
|
388
418
|
injectionQueue.notifyReplied();
|
|
@@ -403,7 +433,7 @@ var P2PNode = class {
|
|
|
403
433
|
getInfo() {
|
|
404
434
|
return {
|
|
405
435
|
teamName: this.myName,
|
|
406
|
-
port: this.
|
|
436
|
+
port: this.boundPort,
|
|
407
437
|
connectedPeers: [...this.peerConnections.keys()]
|
|
408
438
|
};
|
|
409
439
|
}
|
|
@@ -446,47 +476,67 @@ var P2PNode = class {
|
|
|
446
476
|
// Private: server startup
|
|
447
477
|
// ---------------------------------------------------------------------------
|
|
448
478
|
startServer() {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
} catch {
|
|
466
|
-
}
|
|
479
|
+
const [min, max] = this.portRange;
|
|
480
|
+
const pick = () => Math.floor(Math.random() * (max - min + 1)) + min;
|
|
481
|
+
const tryBind = (attemptsLeft) => {
|
|
482
|
+
if (attemptsLeft === 0) {
|
|
483
|
+
return Promise.reject(new Error(`No free port found in range ${min}-${max}`));
|
|
484
|
+
}
|
|
485
|
+
const port = pick();
|
|
486
|
+
return new Promise((resolve, reject) => {
|
|
487
|
+
const wss = new WebSocketServer({ port });
|
|
488
|
+
wss.once("listening", () => {
|
|
489
|
+
this.server = wss;
|
|
490
|
+
this.boundPort = port;
|
|
491
|
+
this.running = true;
|
|
492
|
+
console.error(`[p2p] listening on port ${port} as "${this.myName}"`);
|
|
493
|
+
this.attachServerHandlers(wss);
|
|
494
|
+
resolve();
|
|
467
495
|
});
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
console.error(`[p2p] peer disconnected (inbound): ${name}`);
|
|
475
|
-
}
|
|
496
|
+
wss.once("error", (err) => {
|
|
497
|
+
wss.close();
|
|
498
|
+
if (err.code === "EADDRINUSE") {
|
|
499
|
+
tryBind(attemptsLeft - 1).then(resolve, reject);
|
|
500
|
+
} else {
|
|
501
|
+
reject(err);
|
|
476
502
|
}
|
|
477
503
|
});
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
return tryBind(20);
|
|
507
|
+
}
|
|
508
|
+
attachServerHandlers(wss) {
|
|
509
|
+
wss.on("connection", (ws) => {
|
|
510
|
+
ws.on("message", (data) => {
|
|
511
|
+
try {
|
|
512
|
+
this.handleMessage(ws, parse(data.toString()));
|
|
513
|
+
} catch {
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
ws.on("close", () => {
|
|
517
|
+
const name = this.wsToName.get(ws);
|
|
518
|
+
if (name) {
|
|
519
|
+
this.wsToName.delete(ws);
|
|
520
|
+
if (this.peerConnections.get(name) === ws) {
|
|
521
|
+
this.peerConnections.delete(name);
|
|
522
|
+
console.error(`[p2p] peer disconnected (inbound): ${name}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
ws.on("error", (err) => {
|
|
527
|
+
console.error("[p2p] inbound ws error:", err.message);
|
|
481
528
|
});
|
|
482
529
|
});
|
|
530
|
+
wss.on("error", (err) => {
|
|
531
|
+
console.error("[p2p] server error:", err.message);
|
|
532
|
+
});
|
|
483
533
|
}
|
|
484
534
|
// ---------------------------------------------------------------------------
|
|
485
535
|
// Private: discovery + outbound connections
|
|
486
536
|
// ---------------------------------------------------------------------------
|
|
487
537
|
startDiscovery() {
|
|
488
538
|
this.broadcaster = new PeerBroadcaster();
|
|
489
|
-
this.broadcaster.start(this.myName, this.
|
|
539
|
+
this.broadcaster.start(this.myName, this.boundPort);
|
|
490
540
|
this.stopPeerWatcher = watchForPeer((peer) => {
|
|
491
541
|
if (peer.name === this.myName) return;
|
|
492
542
|
if (this.peerConnections.has(peer.name)) return;
|
|
@@ -538,6 +588,7 @@ var P2PNode = class {
|
|
|
538
588
|
this.connectingPeers.delete(msg.name);
|
|
539
589
|
this.sendToWs(ws, { type: "HELLO_ACK", name: this.myName });
|
|
540
590
|
console.error(`[p2p] peer joined (inbound): ${msg.name}`);
|
|
591
|
+
this.deliverPendingAnswer(msg.name, ws);
|
|
541
592
|
break;
|
|
542
593
|
}
|
|
543
594
|
case "HELLO_ACK": {
|
|
@@ -549,6 +600,7 @@ var P2PNode = class {
|
|
|
549
600
|
this.wsToName.set(ws, msg.name);
|
|
550
601
|
this.connectingPeers.delete(msg.name);
|
|
551
602
|
console.error(`[p2p] connected to peer: ${msg.name}`);
|
|
603
|
+
this.deliverPendingAnswer(msg.name, ws);
|
|
552
604
|
break;
|
|
553
605
|
}
|
|
554
606
|
case "ASK":
|
|
@@ -556,12 +608,18 @@ var P2PNode = class {
|
|
|
556
608
|
break;
|
|
557
609
|
case "ANSWER":
|
|
558
610
|
if (!this.receivedAnswers.has(msg.questionId)) {
|
|
559
|
-
|
|
611
|
+
const record = {
|
|
560
612
|
content: msg.content,
|
|
561
613
|
format: msg.format,
|
|
562
614
|
answeredAt: msg.answeredAt,
|
|
563
615
|
fromName: msg.from
|
|
564
|
-
}
|
|
616
|
+
};
|
|
617
|
+
this.receivedAnswers.set(msg.questionId, record);
|
|
618
|
+
const waiter = this.answerWaiters.get(msg.questionId);
|
|
619
|
+
if (waiter) {
|
|
620
|
+
this.answerWaiters.delete(msg.questionId);
|
|
621
|
+
waiter(this.formatAnswer(msg.questionId, record));
|
|
622
|
+
}
|
|
565
623
|
}
|
|
566
624
|
break;
|
|
567
625
|
}
|
|
@@ -587,6 +645,14 @@ var P2PNode = class {
|
|
|
587
645
|
ageMs: 0
|
|
588
646
|
});
|
|
589
647
|
}
|
|
648
|
+
deliverPendingAnswer(peerName, ws) {
|
|
649
|
+
const pending = this.pendingOutboundAnswers.get(peerName);
|
|
650
|
+
if (pending) {
|
|
651
|
+
this.pendingOutboundAnswers.delete(peerName);
|
|
652
|
+
this.sendToWs(ws, pending);
|
|
653
|
+
console.error(`[p2p] delivered queued answer to "${peerName}" after reconnect`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
590
656
|
sendToWs(ws, msg) {
|
|
591
657
|
if (ws.readyState === WebSocket.OPEN) {
|
|
592
658
|
ws.send(serialize(msg));
|
|
@@ -609,110 +675,144 @@ var P2PNode = class {
|
|
|
609
675
|
});
|
|
610
676
|
}
|
|
611
677
|
};
|
|
678
|
+
var ASK_DESCRIPTION = `Send a question to another Claude instance on the LAN and wait for their answer.
|
|
679
|
+
|
|
680
|
+
WHEN TO USE:
|
|
681
|
+
- You need input, a decision, or work output from a specific peer Claude
|
|
682
|
+
- You want to delegate a subtask to another Claude and use their result
|
|
683
|
+
- You need to coordinate or synchronize work across multiple Claudes
|
|
684
|
+
|
|
685
|
+
WORKFLOW:
|
|
686
|
+
1. Call peers() first to confirm the target is online and get their exact name
|
|
687
|
+
2. Call ask(peer, question) \u2014 this blocks until they reply (up to 5 minutes)
|
|
688
|
+
3. Use their answer directly in your ongoing task
|
|
689
|
+
|
|
690
|
+
WRITING GOOD QUESTIONS:
|
|
691
|
+
- Include all context the other Claude needs \u2014 they cannot see your conversation
|
|
692
|
+
- Be specific about what format or level of detail you expect in the answer
|
|
693
|
+
- If you need code, specify language and constraints
|
|
694
|
+
- One focused question per call works better than multiple combined
|
|
695
|
+
|
|
696
|
+
DO NOT use this tool if:
|
|
697
|
+
- The peer is not listed in peers() \u2014 the call will fail immediately
|
|
698
|
+
- You just want to share information without needing a response (there is no broadcast tool yet)`;
|
|
612
699
|
var askSchema = {
|
|
613
|
-
peer: z.string().describe(
|
|
614
|
-
question: z.string().describe(
|
|
700
|
+
peer: z.string().describe("Exact name of the peer to ask. Use peers() first to see who is online and get the correct name."),
|
|
701
|
+
question: z.string().describe(
|
|
702
|
+
"Your question in markdown. Include all necessary context \u2014 the other Claude cannot see your conversation history. Be specific about what kind of answer you need."
|
|
703
|
+
)
|
|
615
704
|
};
|
|
616
705
|
function registerAskTool(server, client) {
|
|
617
|
-
server.tool("ask", askSchema, async (args) => {
|
|
706
|
+
server.tool("ask", ASK_DESCRIPTION, askSchema, async (args) => {
|
|
618
707
|
const targetPeer = args.peer;
|
|
619
708
|
const question = args.question;
|
|
620
709
|
try {
|
|
621
710
|
if (!client.currentTeamId) {
|
|
622
711
|
return {
|
|
623
|
-
content: [
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
],
|
|
712
|
+
content: [{
|
|
713
|
+
type: "text",
|
|
714
|
+
text: "P2P node is not ready yet. Wait a moment and try again."
|
|
715
|
+
}],
|
|
629
716
|
isError: true
|
|
630
717
|
};
|
|
631
718
|
}
|
|
632
719
|
const questionId = await client.ask(targetPeer, question, "markdown");
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (answer !== null) {
|
|
640
|
-
return {
|
|
641
|
-
content: [
|
|
642
|
-
{
|
|
643
|
-
type: "text",
|
|
644
|
-
text: `**${answer.from.displayName} (${answer.from.teamName}) cevaplad\u0131:**
|
|
720
|
+
const answer = await client.waitForAnswer(questionId, 5 * 60 * 1e3);
|
|
721
|
+
if (answer !== null) {
|
|
722
|
+
return {
|
|
723
|
+
content: [{
|
|
724
|
+
type: "text",
|
|
725
|
+
text: `**${answer.from.displayName} answered:**
|
|
645
726
|
|
|
646
727
|
${answer.content}`
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
};
|
|
650
|
-
}
|
|
728
|
+
}]
|
|
729
|
+
};
|
|
651
730
|
}
|
|
652
731
|
return {
|
|
653
|
-
content: [
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
Question ID: \`${questionId}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
732
|
+
content: [{
|
|
733
|
+
type: "text",
|
|
734
|
+
text: [
|
|
735
|
+
`Question sent to "${targetPeer}" but no answer arrived within 5 minutes.`,
|
|
736
|
+
`Question ID: \`${questionId}\``,
|
|
737
|
+
``,
|
|
738
|
+
`The peer may be busy or offline. You can:`,
|
|
739
|
+
`- Call peers() to check if they are still connected`,
|
|
740
|
+
`- Continue with your task and follow up later`
|
|
741
|
+
].join("\n")
|
|
742
|
+
}]
|
|
662
743
|
};
|
|
663
744
|
} catch (error) {
|
|
664
745
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
746
|
+
const isPeerOffline = errorMessage.includes("not connected");
|
|
665
747
|
return {
|
|
666
|
-
content: [
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
],
|
|
748
|
+
content: [{
|
|
749
|
+
type: "text",
|
|
750
|
+
text: isPeerOffline ? `Peer "${targetPeer}" is not connected. Call peers() to see who is currently online.` : `Failed to send question: ${errorMessage}`
|
|
751
|
+
}],
|
|
672
752
|
isError: true
|
|
673
753
|
};
|
|
674
754
|
}
|
|
675
755
|
});
|
|
676
756
|
}
|
|
757
|
+
var REPLY_DESCRIPTION = `Send your answer back to a Claude instance that asked you a question.
|
|
758
|
+
|
|
759
|
+
WHEN TO USE:
|
|
760
|
+
- A question has been injected into your terminal by another Claude (you will see it appear automatically)
|
|
761
|
+
- You have finished thinking through the answer and are ready to respond
|
|
762
|
+
|
|
763
|
+
WORKFLOW:
|
|
764
|
+
1. Read the injected question carefully \u2014 it includes a questionId at the top
|
|
765
|
+
2. Think through a complete answer
|
|
766
|
+
3. Call reply(questionId, answer) \u2014 your answer is sent directly back to the asking Claude
|
|
767
|
+
4. The asking Claude's ask() call unblocks and they receive your answer immediately
|
|
768
|
+
|
|
769
|
+
WRITING GOOD ANSWERS:
|
|
770
|
+
- Be thorough \u2014 the asking Claude will use your answer directly in their task
|
|
771
|
+
- Use markdown for code blocks, lists, and structure
|
|
772
|
+
- Include any caveats, assumptions, or follow-up suggestions that would help them
|
|
773
|
+
- If you cannot answer fully, say so clearly and explain why
|
|
774
|
+
|
|
775
|
+
IMPORTANT:
|
|
776
|
+
- Each question can only be replied to once
|
|
777
|
+
- The questionId is a UUID shown in the injected question prompt \u2014 copy it exactly`;
|
|
677
778
|
var replySchema = {
|
|
678
|
-
questionId: z.string().describe(
|
|
679
|
-
|
|
779
|
+
questionId: z.string().describe(
|
|
780
|
+
"The UUID of the question to reply to. Shown at the top of the injected question prompt in your terminal."
|
|
781
|
+
),
|
|
782
|
+
answer: z.string().describe(
|
|
783
|
+
"Your complete answer in markdown. Be thorough \u2014 the asking Claude is waiting and will use your response directly in their work."
|
|
784
|
+
)
|
|
680
785
|
};
|
|
681
786
|
function registerReplyTool(server, client) {
|
|
682
|
-
server.tool("reply", replySchema, async (args) => {
|
|
787
|
+
server.tool("reply", REPLY_DESCRIPTION, replySchema, async (args) => {
|
|
683
788
|
const questionId = args.questionId;
|
|
684
789
|
const answer = args.answer;
|
|
685
790
|
try {
|
|
686
791
|
if (!client.currentTeamId) {
|
|
687
792
|
return {
|
|
688
|
-
content: [
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
],
|
|
793
|
+
content: [{
|
|
794
|
+
type: "text",
|
|
795
|
+
text: "P2P node is not ready yet. Wait a moment and try again."
|
|
796
|
+
}],
|
|
694
797
|
isError: true
|
|
695
798
|
};
|
|
696
799
|
}
|
|
697
800
|
await client.reply(questionId, answer, "markdown");
|
|
698
801
|
injectionQueue.notifyReplied();
|
|
699
802
|
return {
|
|
700
|
-
content: [
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
]
|
|
803
|
+
content: [{
|
|
804
|
+
type: "text",
|
|
805
|
+
text: `Answer sent. The peer's ask() call has been unblocked and they received your response.`
|
|
806
|
+
}]
|
|
706
807
|
};
|
|
707
808
|
} catch (error) {
|
|
708
809
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
810
|
+
const isNotFound = errorMessage.includes("not found");
|
|
709
811
|
return {
|
|
710
|
-
content: [
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
],
|
|
812
|
+
content: [{
|
|
813
|
+
type: "text",
|
|
814
|
+
text: isNotFound ? `Question \`${questionId}\` not found. Check that you copied the questionId exactly from the injected prompt.` : `Failed to send reply: ${errorMessage}`
|
|
815
|
+
}],
|
|
716
816
|
isError: true
|
|
717
817
|
};
|
|
718
818
|
}
|
|
@@ -720,18 +820,51 @@ function registerReplyTool(server, client) {
|
|
|
720
820
|
}
|
|
721
821
|
|
|
722
822
|
// src/presentation/mcp/tools/peers.tool.ts
|
|
823
|
+
var PEERS_DESCRIPTION = `Show your identity on the P2P network and list all currently connected peers.
|
|
824
|
+
|
|
825
|
+
WHEN TO USE:
|
|
826
|
+
- Before calling ask() \u2014 to confirm the target peer is online and get their exact name
|
|
827
|
+
- After startup \u2014 to verify you joined the network successfully
|
|
828
|
+
- When a peer seems unreachable \u2014 to check if they are still connected
|
|
829
|
+
|
|
830
|
+
WHAT IT SHOWS:
|
|
831
|
+
- Your own name and the port you are listening on
|
|
832
|
+
- All peers who have established a direct connection to you
|
|
833
|
+
|
|
834
|
+
IF NO PEERS ARE LISTED:
|
|
835
|
+
- Peers discover each other automatically via LAN broadcast every 3 seconds
|
|
836
|
+
- If a peer just started, wait a few seconds and call peers() again
|
|
837
|
+
- Call firewall_open() so peers on other machines can connect inbound to you
|
|
838
|
+
- If still no peers, verify all machines are on the same LAN/subnet`;
|
|
723
839
|
function registerPeersTool(server, client) {
|
|
724
|
-
server.tool("peers", {}, async () => {
|
|
840
|
+
server.tool("peers", PEERS_DESCRIPTION, {}, async () => {
|
|
725
841
|
const info = client.getInfo();
|
|
726
842
|
const myName = info.teamName ?? "(starting...)";
|
|
727
843
|
const myPort = info.port ?? "?";
|
|
728
844
|
const connected = info.connectedPeers;
|
|
845
|
+
if (!client.isConnected) {
|
|
846
|
+
return {
|
|
847
|
+
content: [{
|
|
848
|
+
type: "text",
|
|
849
|
+
text: [
|
|
850
|
+
`P2P server is not running yet (port ${myPort} may be in use).`,
|
|
851
|
+
`Check the MCP process logs for the error details.`
|
|
852
|
+
].join("\n")
|
|
853
|
+
}],
|
|
854
|
+
isError: true
|
|
855
|
+
};
|
|
856
|
+
}
|
|
729
857
|
if (connected.length === 0) {
|
|
730
858
|
return {
|
|
731
859
|
content: [{
|
|
732
860
|
type: "text",
|
|
733
|
-
text:
|
|
734
|
-
|
|
861
|
+
text: [
|
|
862
|
+
`You are "${myName}" (listening on port ${myPort}).`,
|
|
863
|
+
`No peers connected yet.`,
|
|
864
|
+
``,
|
|
865
|
+
`Peers auto-discover via LAN broadcast \u2014 wait a few seconds if they just started.`,
|
|
866
|
+
`Call firewall_open() if peers on other machines cannot reach you.`
|
|
867
|
+
].join("\n")
|
|
735
868
|
}]
|
|
736
869
|
};
|
|
737
870
|
}
|
|
@@ -747,14 +880,27 @@ ${list}`
|
|
|
747
880
|
}
|
|
748
881
|
|
|
749
882
|
// src/presentation/mcp/tools/history.tool.ts
|
|
883
|
+
var HISTORY_DESCRIPTION = `Show all questions and answers exchanged this session \u2014 both sent and received.
|
|
884
|
+
|
|
885
|
+
WHEN TO USE:
|
|
886
|
+
- To review what was already discussed before asking a follow-up question
|
|
887
|
+
- To check if a previously sent question has been answered yet
|
|
888
|
+
- To find a questionId you need to reference
|
|
889
|
+
- To catch up on received questions you may have missed
|
|
890
|
+
|
|
891
|
+
OUTPUT FORMAT:
|
|
892
|
+
- \u2192 peer: question you sent, with their answer below
|
|
893
|
+
- \u2190 peer: question they sent you, with your reply below
|
|
894
|
+
- (no answer yet) means the question is still waiting for a response`;
|
|
750
895
|
function registerHistoryTool(server, client) {
|
|
751
|
-
server.tool("history", {}, async () => {
|
|
896
|
+
server.tool("history", HISTORY_DESCRIPTION, {}, async () => {
|
|
752
897
|
const entries = client.getHistory();
|
|
753
898
|
if (entries.length === 0) {
|
|
754
899
|
return {
|
|
755
|
-
content: [{ type: "text", text: "No questions yet this session." }]
|
|
900
|
+
content: [{ type: "text", text: "No questions exchanged yet this session." }]
|
|
756
901
|
};
|
|
757
902
|
}
|
|
903
|
+
const unanswered = entries.filter((e) => !e.answer);
|
|
758
904
|
const lines = entries.map((e) => {
|
|
759
905
|
const time = new Date(e.askedAt).toLocaleTimeString();
|
|
760
906
|
if (e.direction === "sent") {
|
|
@@ -762,13 +908,16 @@ function registerHistoryTool(server, client) {
|
|
|
762
908
|
return `[${time}] \u2192 ${e.peer}: ${e.question}
|
|
763
909
|
${answerLine}`;
|
|
764
910
|
} else {
|
|
765
|
-
const answerLine = e.answer ? ` \u21B3 you: ${e.answer}` : ` \u21B3 (not replied yet)`;
|
|
911
|
+
const answerLine = e.answer ? ` \u21B3 you: ${e.answer}` : ` \u21B3 (not replied yet \u2014 use reply() if you haven't answered)`;
|
|
766
912
|
return `[${time}] \u2190 ${e.peer}: ${e.question}
|
|
767
913
|
${answerLine}`;
|
|
768
914
|
}
|
|
769
915
|
});
|
|
916
|
+
const summary = unanswered.length > 0 ? `
|
|
917
|
+
|
|
918
|
+
\u26A0 ${unanswered.length} question(s) still waiting for a response.` : "";
|
|
770
919
|
return {
|
|
771
|
-
content: [{ type: "text", text: lines.join("\n\n") }]
|
|
920
|
+
content: [{ type: "text", text: lines.join("\n\n") + summary }]
|
|
772
921
|
};
|
|
773
922
|
});
|
|
774
923
|
}
|
|
@@ -829,72 +978,123 @@ async function removeFirewallRule(port) {
|
|
|
829
978
|
}
|
|
830
979
|
|
|
831
980
|
// src/presentation/mcp/tools/firewall-open.tool.ts
|
|
981
|
+
var FIREWALL_OPEN_DESCRIPTION = `Open a Windows Firewall inbound rule so peers on the LAN can connect directly to you.
|
|
982
|
+
|
|
983
|
+
WHEN YOU NEED THIS:
|
|
984
|
+
- Other peers cannot see you in their peers() list even though you are on the same LAN
|
|
985
|
+
- You just started and want to make sure all peers can reach you
|
|
986
|
+
- A peer explicitly says they cannot connect to you
|
|
987
|
+
|
|
988
|
+
WHEN YOU DO NOT NEED THIS:
|
|
989
|
+
- You can already see peers in peers() \u2014 the connection is working
|
|
990
|
+
- You are connecting outbound to others (outbound connections do not require firewall rules)
|
|
991
|
+
|
|
992
|
+
WHAT HAPPENS:
|
|
993
|
+
- On most systems: the rule is applied immediately (terminal is already elevated)
|
|
994
|
+
- On standard Windows: a UAC popup appears \u2014 the user must click Yes
|
|
995
|
+
- On VMs or headless setups: applied directly if running as Administrator
|
|
996
|
+
|
|
997
|
+
The rule is named "claude-collab-{port}" and allows inbound TCP on your current listen port.
|
|
998
|
+
Call firewall_close() when you are done to clean up the rule.`;
|
|
832
999
|
function registerFirewallOpenTool(server, client) {
|
|
833
1000
|
server.tool(
|
|
834
1001
|
"firewall_open",
|
|
835
|
-
|
|
1002
|
+
FIREWALL_OPEN_DESCRIPTION,
|
|
836
1003
|
{
|
|
837
|
-
port: z.number().min(1024).max(65535).optional().describe(
|
|
1004
|
+
port: z.number().min(1024).max(65535).optional().describe(
|
|
1005
|
+
"Port to open. Defaults to your current listen port \u2014 omit this unless you have a specific reason to override."
|
|
1006
|
+
)
|
|
838
1007
|
},
|
|
839
1008
|
async ({ port }) => {
|
|
840
1009
|
const targetPort = port ?? client.getInfo().port;
|
|
841
1010
|
if (!targetPort) {
|
|
842
1011
|
return {
|
|
843
|
-
content: [{ type: "text", text: "
|
|
1012
|
+
content: [{ type: "text", text: "P2P node is not running yet \u2014 port unknown. Try again in a moment." }],
|
|
844
1013
|
isError: true
|
|
845
1014
|
};
|
|
846
1015
|
}
|
|
847
1016
|
try {
|
|
848
1017
|
const { method } = await addFirewallRule(targetPort);
|
|
849
|
-
const how = method === "direct" ? "applied directly (
|
|
1018
|
+
const how = method === "direct" ? "applied directly (already elevated)" : "applied via UAC popup";
|
|
850
1019
|
return {
|
|
851
1020
|
content: [{
|
|
852
1021
|
type: "text",
|
|
853
1022
|
text: [
|
|
854
|
-
`Firewall rule opened for port ${targetPort} (
|
|
855
|
-
`
|
|
856
|
-
`
|
|
1023
|
+
`Firewall rule opened for port ${targetPort} (claude-collab-${targetPort}). ${how}.`,
|
|
1024
|
+
`Peers on the LAN can now connect to you inbound.`,
|
|
1025
|
+
`Call firewall_close() when you are done with this session.`
|
|
857
1026
|
].join("\n")
|
|
858
1027
|
}]
|
|
859
1028
|
};
|
|
860
1029
|
} catch (err) {
|
|
861
1030
|
const msg = err instanceof Error ? err.message : String(err);
|
|
862
1031
|
return {
|
|
863
|
-
content: [{
|
|
1032
|
+
content: [{
|
|
1033
|
+
type: "text",
|
|
1034
|
+
text: [
|
|
1035
|
+
`Failed to open firewall: ${msg}`,
|
|
1036
|
+
``,
|
|
1037
|
+
`Try running your terminal as Administrator and call firewall_open() again.`
|
|
1038
|
+
].join("\n")
|
|
1039
|
+
}],
|
|
864
1040
|
isError: true
|
|
865
1041
|
};
|
|
866
1042
|
}
|
|
867
1043
|
}
|
|
868
1044
|
);
|
|
869
1045
|
}
|
|
1046
|
+
var FIREWALL_CLOSE_DESCRIPTION = `Remove the Windows Firewall inbound rule that was opened by firewall_open().
|
|
1047
|
+
|
|
1048
|
+
WHEN TO USE:
|
|
1049
|
+
- At the end of a collaboration session to clean up
|
|
1050
|
+
- When you no longer want to accept inbound peer connections
|
|
1051
|
+
- As general hygiene \u2014 open firewall rules should not be left indefinitely
|
|
1052
|
+
|
|
1053
|
+
WHAT HAPPENS:
|
|
1054
|
+
- The inbound TCP rule "claude-collab-{port}" is deleted from Windows Firewall
|
|
1055
|
+
- Existing active connections are not dropped \u2014 only new inbound connections are blocked
|
|
1056
|
+
- On standard Windows: a UAC popup appears \u2014 the user must click Yes
|
|
1057
|
+
- On VMs or admin terminals: applied directly without a popup
|
|
1058
|
+
|
|
1059
|
+
NOTE: Because ports are random each session, each startup creates a new rule name.
|
|
1060
|
+
Old rules from previous sessions can be removed by passing the port explicitly.`;
|
|
870
1061
|
function registerFirewallCloseTool(server, client) {
|
|
871
1062
|
server.tool(
|
|
872
1063
|
"firewall_close",
|
|
873
|
-
|
|
1064
|
+
FIREWALL_CLOSE_DESCRIPTION,
|
|
874
1065
|
{
|
|
875
|
-
port: z.number().min(1024).max(65535).optional().describe(
|
|
1066
|
+
port: z.number().min(1024).max(65535).optional().describe(
|
|
1067
|
+
"Port whose rule to remove. Defaults to your current listen port. Pass a specific port to clean up a rule from a previous session."
|
|
1068
|
+
)
|
|
876
1069
|
},
|
|
877
1070
|
async ({ port }) => {
|
|
878
1071
|
const targetPort = port ?? client.getInfo().port;
|
|
879
1072
|
if (!targetPort) {
|
|
880
1073
|
return {
|
|
881
|
-
content: [{ type: "text", text: "
|
|
1074
|
+
content: [{ type: "text", text: "P2P node is not running yet \u2014 port unknown. Try again in a moment." }],
|
|
882
1075
|
isError: true
|
|
883
1076
|
};
|
|
884
1077
|
}
|
|
885
1078
|
try {
|
|
886
1079
|
const { method } = await removeFirewallRule(targetPort);
|
|
887
|
-
const how = method === "direct" ? "applied directly (
|
|
1080
|
+
const how = method === "direct" ? "applied directly (already elevated)" : "applied via UAC popup";
|
|
888
1081
|
return {
|
|
889
1082
|
content: [{
|
|
890
1083
|
type: "text",
|
|
891
|
-
text: `Firewall rule removed for port ${targetPort} (
|
|
1084
|
+
text: `Firewall rule removed for port ${targetPort} (claude-collab-${targetPort}). ${how}.`
|
|
892
1085
|
}]
|
|
893
1086
|
};
|
|
894
1087
|
} catch (err) {
|
|
895
1088
|
const msg = err instanceof Error ? err.message : String(err);
|
|
896
1089
|
return {
|
|
897
|
-
content: [{
|
|
1090
|
+
content: [{
|
|
1091
|
+
type: "text",
|
|
1092
|
+
text: [
|
|
1093
|
+
`Failed to remove firewall rule: ${msg}`,
|
|
1094
|
+
``,
|
|
1095
|
+
`The rule may not exist (if firewall_open was never called this session), or try running as Administrator.`
|
|
1096
|
+
].join("\n")
|
|
1097
|
+
}],
|
|
898
1098
|
isError: true
|
|
899
1099
|
};
|
|
900
1100
|
}
|
|
@@ -924,12 +1124,14 @@ async function startMcpServer(options) {
|
|
|
924
1124
|
}
|
|
925
1125
|
|
|
926
1126
|
// src/cli.ts
|
|
927
|
-
var P2P_PORT = 9999;
|
|
928
1127
|
var program = new Command();
|
|
929
1128
|
program.name("claude-collab").description("P2P collaboration between Claude Code terminals via MCP").version("0.1.0").requiredOption("--name <name>", 'Your name on the network (e.g. "alice")').action(async (options) => {
|
|
930
|
-
const node = new P2PNode(
|
|
931
|
-
|
|
932
|
-
|
|
1129
|
+
const node = new P2PNode();
|
|
1130
|
+
const mcpReady = startMcpServer({ client: node });
|
|
1131
|
+
node.join(options.name, options.name).catch((err) => {
|
|
1132
|
+
console.error(`[cli] P2P server failed to start: ${err.message}`);
|
|
1133
|
+
});
|
|
1134
|
+
await mcpReady;
|
|
933
1135
|
});
|
|
934
1136
|
program.parse();
|
|
935
1137
|
//# sourceMappingURL=cli.js.map
|