feat(cli): websocket client + MCP tool integration

broker-client: full WS client with hello handshake + ack, auto-reconnect
with exponential backoff (1s → 30s capped), in-memory outbound queue
(max 100) during reconnect, 500-entry push buffer for check_messages.

MCP tool integration:
- send_message: "slug:target" prefix or single-mesh fast path
- check_messages: drains push buffers across all clients
- set_status: fans manual override across all connected meshes
- set_summary: stubbed (broker protocol extension needed)
- list_peers: stubbed — lists connected mesh slugs + statuses

manager module holds Map<meshId, BrokerClient>, starts on MCP server
boot for every joined mesh in ~/.claudemesh/config.json.

new CLI command: seed-test-mesh injects a mesh row for dev testing.

also fixes a broker-side hello race: handleHello sent hello_ack before
the caller closure assigned presenceId, so clients sending right after
the ack hit the no_hello check. Fix: return presenceId, caller sets
closure var, THEN sends hello_ack. Queue drain is fire-and-forget now.

round-trip verified: two clients, A→B, push received with correct
senderPubkey + ciphertext. 44/44 broker tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:30:11 +01:00
parent 8931296e82
commit 20d968f989
8 changed files with 709 additions and 79 deletions

View File

@@ -267,7 +267,7 @@ function sendError(
async function handleHello(
ws: WebSocket,
hello: Extract<WSClientMessage, { type: "hello" }>,
): Promise<string | null> {
): Promise<{ presenceId: string; memberDisplayName: string } | null> {
// Capacity check BEFORE touching DB.
const existing = connectionsPerMesh.get(hello.meshId) ?? 0;
if (existing >= env.MAX_CONNECTIONS_PER_MESH) {
@@ -308,8 +308,12 @@ async function handleHello(
presence_id: presenceId,
session_id: hello.sessionId,
});
await maybePushQueuedMessages(presenceId);
return presenceId;
// Drain any queued messages in the background. The hello_ack is
// sent by the CALLER after it assigns presenceId — sending it here
// races the caller's closure assignment, causing subsequent client
// messages to fail the "no_hello" check.
void maybePushQueuedMessages(presenceId);
return { presenceId, memberDisplayName: member.displayName };
}
async function handleSend(
@@ -348,7 +352,22 @@ function handleConnection(ws: WebSocket): void {
try {
const msg = JSON.parse(raw.toString()) as WSClientMessage;
if (msg.type === "hello") {
presenceId = await handleHello(ws, msg);
const result = await handleHello(ws, msg);
if (!result) return;
presenceId = result.presenceId;
// Ack AFTER closure assignment — subsequent client messages
// arriving immediately after will now see a non-null presenceId.
try {
ws.send(
JSON.stringify({
type: "hello_ack",
presenceId: result.presenceId,
memberDisplayName: result.memberDisplayName,
}),
);
} catch {
/* ws closed during hello */
}
return;
}
if (!presenceId) {

View File

@@ -95,6 +95,13 @@ export interface WSAckMessage {
queued: boolean;
}
/** Broker → client: hello handshake acknowledgement. */
export interface WSHelloAckMessage {
type: "hello_ack";
presenceId: string;
memberDisplayName: string;
}
/** Broker → client: structured error. */
export interface WSErrorMessage {
type: "error";
@@ -108,4 +115,8 @@ export type WSClientMessage =
| WSSendMessage
| WSSetStatusMessage;
export type WSServerMessage = WSPushMessage | WSAckMessage | WSErrorMessage;
export type WSServerMessage =
| WSHelloAckMessage
| WSPushMessage
| WSAckMessage
| WSErrorMessage;