From b49e9a9b61794746d571b51d6cea496cee239e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:55:05 +0100 Subject: [PATCH] feat(cli+broker): three-tier peer removal: disconnect, kick, ban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broker (apps/broker/src/index.ts) - Unified disconnect/kick handler uses close code 1000 for disconnect (CLI auto-reconnects) vs 4001 for kick (CLI exits, no reconnect). - Ban now closes with code 4002. - Hello handler: revoked members get a specific 'revoked' error with a 'Contact the mesh owner to rejoin' message, then ws.close(4002). Previously banned users saw the generic 'unauthorized' error. - list_bans handler returns { name, pubkey, revokedAt } for each revoked member. CLI (apps/cli) - ws-client: close codes 4001 and 4002 set .closed = true and stash .terminalClose so callers can surface a friendly message instead of the low-level 'ws terminal close' error. Revoked error in hello is also captured as a terminal close. - withMesh catches terminalClose and prints: 4001 → 'Kicked from this mesh. Run claudemesh to rejoin.' 4002 → the broker's 'Contact the mesh owner to rejoin.' message - kick.ts now exports runDisconnect + runKick with clear hints: 'disconnect' → 'They will auto-reconnect within seconds.' 'kick' → 'They can rejoin anytime by running claudemesh.' - cli.ts adds 'disconnect' dispatch; HELP updated. Semantics: disconnect: session reset, no DB state, auto-reconnects kick : session ends, no DB state, user must manually rejoin ban : session ends + revokedAt set, cannot rejoin until unban Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-19-hackathon-day-one-scenario.txt | 158 ++++++++++++++++++ .../ideas/2026-04-19-hackathon-proposal.txt | 147 ++++++++++++++++ apps/broker/src/index.ts | 60 +++++-- apps/cli/package.json | 2 +- apps/cli/src/commands/connect.ts | 15 ++ apps/cli/src/commands/kick.ts | 81 ++++++--- apps/cli/src/entrypoints/cli.ts | 8 +- apps/cli/src/services/broker/ws-client.ts | 22 ++- 8 files changed, 445 insertions(+), 48 deletions(-) create mode 100644 .artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt create mode 100644 .artifacts/ideas/2026-04-19-hackathon-proposal.txt diff --git a/.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt b/.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt new file mode 100644 index 0000000..79cd2bf --- /dev/null +++ b/.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt @@ -0,0 +1,158 @@ +HACKATHON — THE DAY-ONE "WOW" SCENARIO +====================================== +Date: 2026-04-19 +Follow-up to: 2026-04-19-hackathon-proposal.txt + + +THE SHORT ANSWER +---------------- + +Yes — it's exactly as simple as run one command, join a mesh, and +immediately inherit your team's tools, skills, MCPs, and context. +No config copying. No API key juggling. No "let me send you my +.mcp.json". Zero setup. + +That's the thing that has never existed before: Claude Code sessions +that share capability at the speed of a chat invite. + + +THE 60-SECOND STORY (rough, but close to real) +---------------------------------------------- + +Picture Ana at the hackathon. Her teammate David has been working on +their project for two days — wired up a Linear MCP, a Figma MCP, a +custom "brand-asset" skill, shared project context, a few API keys +in the team vault. She shows up at the table, opens her laptop, has +never touched the project. + +1. David runs one command: + $ claudemesh share ana@team.com + She gets a link: https://claudemesh.com/i/5SLJ7F95 + +2. Ana runs one command: + $ claudemesh https://claudemesh.com/i/5SLJ7F95 + (No separate install, the CLI self-installs if missing. + Takes under 10 seconds.) + +3. Claude Code opens automatically, connected to the mesh. No + further setup. + +4. Ana types into Claude Code: + "what are we building?" + + Claude — HER local Claude, on HER laptop — answers with the + team's current brief, pulled from the mesh's shared context + that David set earlier. It knows the repo, the deadline, the + stack, who's on the team, what's done, what's open. + +5. Ana says: + "pull the latest tickets from Linear" + + Her Claude uses the Linear MCP. Ana never installed it. She has + no Linear API key on her machine. The MCP was deployed to the + mesh by David on day one; the moment Ana joined, it became + callable from her Claude Code as if it were local. Ciphertext + routes through the broker, tool calls execute on the peer that + owns the integration. + +6. She asks: + "generate launch-day assets in our brand" + + Her Claude invokes the /brand-asset skill that David authored + two days ago. Skills are portable in the mesh — calling it + remotely is indistinguishable from having it installed locally. + +7. She hits a wall on a type error. Instead of pinging David in + Slack she types: + "ask the mesh" + + Question fans out to every teammate's Claude. Thirty seconds + later she has three answers with three different repo contexts, + synthesized into one reply, with attributions. This is the + fan-out demo from the main proposal. + +TOTAL ELAPSED TIME: under 90 seconds from "I don't have anything +set up" to "my Claude knows our project and can use my team's tools." + + +WHY THIS IS THE HEADLINE +------------------------ + +Every other developer tool in 2026 still demands: + - install this package + - set these env vars + - copy this config + - get an API key approved + - restart your editor + - re-index your repo + +claudemesh replaces all of that with a single click on an invite +link. The mesh IS the onboarding. + +The shorter way to say it: every Claude Code session you onboard, +you onboard your team's entire AI toolchain in one shot. + + +WHAT THE USER ACTUALLY SEES +--------------------------- + + Terminal (Ana): + $ claudemesh https://claudemesh.com/i/5SLJ7F95 + ✔ Joined "launch-team" as Ana + 4 peers online: David, Nedas, Lug-Nut, Juan + 12 tools available from the mesh + 3 shared skills + context: "launch-day assets — due Friday" + ✔ Launching Claude Code… + + Claude Code: + > connected to mesh: launch-team + > inherited: 12 tools, 3 skills, shared context, 14 memories + + Dashboard (claudemesh.com): + Ana's node appears on the live topology. Packets animate along + edges as her first message flies. David's screen gets a presence + ping: "Ana joined — ready". + +That's the wow. Not a pitch deck, not a feature matrix — a literal +before-and-after experience that takes under two minutes and looks +impossible to anyone who's ever onboarded a new developer onto a +project the old way. + + +WHAT WE'RE BUILDING THIS WEEK TO MAKE THIS REAL +----------------------------------------------- + +Most of the primitives exist. The hackathon week is the glue: + + • Tool inheritance — a peer's deployed MCPs become callable from + other peers as if installed locally. Today: partially shipped. + Hackathon goal: make it automatic, zero-config, visible in the + universe dashboard. + + • Skill sharing — same story, for skills (already has an alpha). + Hackathon goal: polish, auto-discovery, one-line invoke. + + • Context inheritance — joining a mesh automatically loads the + mesh's shared context into the new Claude's session so it + "knows what we're working on" from minute one. Today: state + exists, auto-pull on join does not. + + • "Ask the mesh" fan-out — the broadcast + synthesize primitive + from the main proposal. + + • The onboarding CLI flow — make the invite-link-to-Claude-ready + path bulletproof and under 10 seconds on a fresh machine. + + +THE DEMO ARTIFACT +----------------- + +A single 90-second screencast. Split screen: Ana's terminal on the +left, the claudemesh.com live universe dashboard on the right. +She joins. Her node appears on the mesh. She asks a question. Tools +fire. Skills execute. Answer comes back. No text overlays needed — +the UX itself is the argument. + +That's the video that goes at the top of claudemesh.com on demo +day. diff --git a/.artifacts/ideas/2026-04-19-hackathon-proposal.txt b/.artifacts/ideas/2026-04-19-hackathon-proposal.txt new file mode 100644 index 0000000..bcb9927 --- /dev/null +++ b/.artifacts/ideas/2026-04-19-hackathon-proposal.txt @@ -0,0 +1,147 @@ +HACKATHON PROPOSAL — CLAUDEMESH +=============================== +Date: 2026-04-19 +Author: Alejandro Gutiérrez + + +THE SHORT ANSWER +---------------- + +I'm going with claudemesh — not the Flexicar voice assistant, not a fresh +blend. claudemesh is already a real product with a real backbone (CLI, +MCP server, broker, E2E crypto, web dashboard), and what it still lacks +is the one thing a hackathon is perfect for: a single headline capability +that makes its existence obvious in ten seconds. + +So I'm using the week to push claudemesh from "useful infra for people +who already get it" → "demo that makes someone say, oh, that's what this +is for." + + +WHAT'S ALREADY THERE (SO YOU KNOW WHAT I'M BUILDING ON, NOT FROM ZERO) +---------------------------------------------------------------------- + +- CLI + MCP server (claudemesh-cli), 40+ alpha releases shipped +- Broker on wss://ic.claudemesh.com/ws with libsodium E2E encryption — + broker routes ciphertext, never reads messages +- Shared primitives: direct messages, group broadcasts, shared state, + memory, file sharing, skill sharing, MCP deployment to the mesh +- Telegram bridge with a Haiku-4.5 AI layer so you can talk to the mesh + from your phone (shipped this week) +- Web dashboard with per-mesh live panel (peers, envelope stream, + audit chain) +- Brand-new "Universe" dashboard landing (shipped today) — meshes + + incoming invitations in one view + + +WHAT I'M BUILDING DURING THE HACKATHON +--------------------------------------- + +Headline: AGENT-TO-AGENT DELEGATION WITH LIVE STREAMING + +Right now a Claude Code session can SEND a message to another session +in the mesh. That's primitive-level. What's missing — and what makes +the whole thing click — is DELEGATION: one Claude hands off a task to +another, waits for the real answer (not a "sure, I'll do that later" +acknowledgement), and composes it into its own response, with the +user watching the whole thing happen live. + +Why this is the right hackathon target: +- It requires NO new physical infrastructure. The broker, the crypto, + the transport are all there. +- It's the unlock that turns claudemesh from "chat for Claudes" into + "distributed cognition layer for Claude Code." +- It's demoable in 60 seconds and the value is self-evident. + + +DAY-BY-DAY PLAN (REALISTIC, NOT ASPIRATIONAL) +--------------------------------------------- + +DAY 1 — Protocol + primitive + • Design `mesh_delegate(to, task, timeout)` MCP tool — one call from + the local Claude, returns the remote Claude's answer synchronously + from the caller's perspective + • Broker-side: new message type `delegation_request` / `_response` + with correlation IDs so responses route back to the originator + • Remote Claude receives delegation → runs in a sandboxed subcontext + → emits structured response (text + artifacts) + +DAY 2 — Live streaming of remote work + • While remote Claude works, stream its tool calls + thinking back + through the mesh as `delegation_progress` events + • Caller's dashboard lights up with "Nedas is reading src/auth.ts…" + in real time + • The "wow" moment: watching another Claude think, from your terminal + +DAY 3 — Multi-peer fan-out + • `mesh_ask_all(question)` — broadcast a question to @group, gather + answers in parallel, synthesize + • This is the Slack-killer: one question, three Claudes with + different repo contexts, one merged answer + • Add to the universe dashboard: inline "ask your mesh" prompt + +DAY 4 — Voice control (stretch, uses my Pipecat/Cartesia background) + • Phone → Telegram voice note → AI layer already in place → + mesh_delegate or mesh_ask_all fires + • "Hey mesh, which of you is closest to the payments bug?" — the + mesh answers with the Claude that has the most recent auth.ts edits + • Ties the Flexicar voice work into claudemesh without fragmenting + the proposal + +DAY 5 — Live schematic on the dashboard + • Build the animated mesh-topology view from my prototype + (SVG nodes + packets in flight) using REAL delegation traffic + • When a delegation fires, you literally see a packet fly from one + node to another on the dashboard + • This is the screenshot/video artifact for the demo day + +DAY 6 — Demo recording + narrative + • 90-second video: single person, three terminals, one dashboard. + Asks a question in terminal 1, two other Claudes answer, dashboard + animates, final answer synthesized + • Landing page update with the video above the fold + • Changelog post + +DAY 7 — Buffer, polish, publish alpha + + +WHAT MAKES THIS TAILORED FOR A HACKATHON (NOT JUST ROADMAP WORK) +----------------------------------------------------------------- + +1. Visible. Three terminals + one dashboard = immediately legible. +2. Ambitious. Going from "pub/sub messaging" to "synchronous distributed + delegation" is a real protocol-level step up — it's the difference + between email and RPC. +3. Native to the event. Hackathon judges are the exact target user: + people with multiple Claude Code sessions open, wanting them to + coordinate. Dogfood-able during the week itself. +4. Leverages what I already built. I'm not rebuilding the transport, + the crypto, the auth, the dashboard shell — just adding the one + missing primitive that ties it all together. +5. Stretch goal (voice) reuses my Flexicar/Pipecat expertise without + making the proposal schizophrenic — it's one coherent pitch with a + multimodal cherry on top if time allows. + + +WHAT I'M EXPLICITLY NOT DOING +------------------------------ + +- Not rewriting the Flexicar assistant as a mesh app. It's a great + product, wrong scope for one week. +- Not building federation (mesh-to-mesh). Powerful but too abstract + to demo cleanly. +- Not building a self-hosted broker. Infra work, no hackathon payoff. +- Not building a mobile app. Telegram already covers the "mesh from + anywhere" story. + + +THE PITCH IN ONE SENTENCE +------------------------- + +By the end of the week, one Claude will delegate a real coding task to +another Claude running on a different machine, get a real answer back, +and the whole thing will happen in sixty seconds with the mesh +topology animating live on claudemesh.com. + +That's the demo. Everything else in the week is in service of making +those sixty seconds watertight. diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index e6052c6..88dfd19 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -1669,6 +1669,26 @@ async function handleHello( } const member = await findMemberByPubkey(hello.meshId, hello.pubkey); if (!member) { + // Distinguish "revoked" from "never a member" so banned users get + // a clear message ("contact admin") instead of generic unauthorized. + const [revokedRow] = await db + .select({ displayName: meshMember.displayName, revokedAt: meshMember.revokedAt }) + .from(meshMember) + .where(and(eq(meshMember.meshId, hello.meshId), eq(meshMember.peerPubkey, hello.pubkey))) + .limit(1); + if (revokedRow?.revokedAt) { + metrics.connectionsRejected.inc({ reason: "revoked" }); + const [m] = await db.select({ slug: mesh.slug, name: mesh.name }).from(mesh).where(eq(mesh.id, hello.meshId)).limit(1); + const meshLabel = m?.name || m?.slug || hello.meshId; + sendError( + ws, + "revoked", + `You've been removed from "${meshLabel}". Contact the mesh owner to rejoin.`, + ); + ws.close(4002, "banned"); + log.info("hello rejected: revoked", { mesh_id: hello.meshId, display_name: revokedRow.displayName }); + return null; + } metrics.connectionsRejected.inc({ reason: "unauthorized" }); sendError(ws, "unauthorized", "pubkey not found in mesh"); ws.close(1008, "unauthorized"); @@ -3900,57 +3920,63 @@ function handleConnection(ws: WebSocket): void { // --- Kick / Ban / Unban --- + case "disconnect": case "kick": { - const km = msg as { type: "kick"; target?: string; stale?: number; all?: boolean; _reqId?: string }; - // Authz: only owner or admin can kick. + // disconnect: soft — WS closes with 1000, CLI auto-reconnects. + // kick: hard — WS closes with 4001, CLI exits (no reconnect). + // Same target semantics ( | --stale | --all). Only + // the close code differs. + const isKick = msg.type === "kick"; + const km = msg as { type: "kick" | "disconnect"; target?: string; stale?: number; all?: boolean; _reqId?: string }; + const closeCode = isKick ? 4001 : 1000; + const closeReason = isKick ? "kicked" : "disconnected"; + const ackType = isKick ? "kick_ack" : "disconnect_ack"; + + // Authz: only owner or admin. const [kickMesh] = await db.select({ ownerUserId: mesh.ownerUserId }).from(mesh).where(eq(mesh.id, conn.meshId)).limit(1); const [kickMember] = await db.select({ role: meshMember.role, userId: meshMember.userId }).from(meshMember).where(eq(meshMember.id, conn.memberId)).limit(1); if (!kickMesh || (kickMesh.ownerUserId !== kickMember?.userId && kickMember?.role !== "admin")) { - sendError(ws, "forbidden", "only owner or admin can kick", undefined, km._reqId); + sendError(ws, "forbidden", `only owner or admin can ${closeReason}`, undefined, km._reqId); break; } - const kicked: string[] = []; + const affected: string[] = []; const now = Date.now(); if (km.all) { - // Kick everyone except caller for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId || pid === presenceId) continue; - try { peer.ws.close(1000, "kicked"); } catch {} + try { peer.ws.close(closeCode, closeReason); } catch {} connections.delete(pid); void disconnectPresence(pid); - kicked.push(peer.displayName || pid); + affected.push(peer.displayName || pid); } } else if (km.stale && typeof km.stale === "number") { - // Kick peers idle longer than stale ms const cutoff = now - km.stale; for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId || pid === presenceId) continue; - // Check last_ping_at from DB for accurate staleness const [pres] = await db.select({ lastPingAt: presence.lastPingAt }).from(presence).where(eq(presence.id, pid)).limit(1); if (pres && pres.lastPingAt && pres.lastPingAt.getTime() < cutoff) { - try { peer.ws.close(1000, "kicked_stale"); } catch {} + try { peer.ws.close(closeCode, `${closeReason}_stale`); } catch {} connections.delete(pid); void disconnectPresence(pid); - kicked.push(peer.displayName || pid); + affected.push(peer.displayName || pid); } } } else if (km.target) { - // Kick specific peer by name or pubkey for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId) continue; if (peer.displayName === km.target || peer.memberPubkey === km.target || peer.memberPubkey.startsWith(km.target)) { - try { peer.ws.close(1000, "kicked"); } catch {} + try { peer.ws.close(closeCode, closeReason); } catch {} connections.delete(pid); void disconnectPresence(pid); - kicked.push(peer.displayName || pid); + affected.push(peer.displayName || pid); } } } - conn.ws.send(JSON.stringify({ type: "kick_ack", kicked, _reqId: km._reqId })); - log.info("ws kick", { presence_id: presenceId, kicked_count: kicked.length, target: km.target ?? km.stale ?? "all" }); + conn.ws.send(JSON.stringify({ type: ackType, kicked: affected, affected, _reqId: km._reqId })); + log.info(`ws ${closeReason}`, { presence_id: presenceId, count: affected.length, target: km.target ?? km.stale ?? "all" }); break; } @@ -3985,7 +4011,7 @@ function handleConnection(ws: WebSocket): void { // Kick all their connections for (const [pid, peer] of connections) { if (peer.meshId === conn.meshId && peer.memberPubkey === targetMember.peerPubkey) { - try { peer.ws.close(1000, "banned"); } catch {} + try { peer.ws.close(4002, "banned"); } catch {} connections.delete(pid); void disconnectPresence(pid); } diff --git a/apps/cli/package.json b/apps/cli/package.json index 3b86889..06040ab 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.0.0", + "version": "1.0.0-alpha.43", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/connect.ts b/apps/cli/src/commands/connect.ts index 5bdb1f7..2034ed6 100644 --- a/apps/cli/src/commands/connect.ts +++ b/apps/cli/src/commands/connect.ts @@ -75,6 +75,21 @@ export async function withMesh( await client.connect(); const result = await fn(client, mesh); return result; + } catch (e) { + // Terminal close from the broker (banned / kicked). Give the user + // a clear message instead of the low-level ws error. + if (client.terminalClose) { + const { code, reason } = client.terminalClose; + if (code === 4002) { + console.error(`\n ✘ ${reason}\n`); + } else if (code === 4001) { + console.error(`\n ✘ Kicked from this mesh. Run \`claudemesh\` to rejoin.\n`); + } else { + console.error(`\n ✘ Broker closed connection: ${reason}\n`); + } + process.exit(1); + } + throw e; } finally { client.close(); } diff --git a/apps/cli/src/commands/kick.ts b/apps/cli/src/commands/kick.ts index 715f9cf..6c462a3 100644 --- a/apps/cli/src/commands/kick.ts +++ b/apps/cli/src/commands/kick.ts @@ -1,9 +1,13 @@ /** - * `claudemesh kick` — disconnect peers from the mesh. + * `claudemesh disconnect` — soft disconnect (session reset, auto-reconnects). + * `claudemesh kick` — hard kick (session ends, no auto-reconnect). * - * claudemesh kick kick one peer (can reconnect) - * claudemesh kick --stale 30m kick idle peers (> 30 min no activity) - * claudemesh kick --all kick everyone except yourself + * claudemesh disconnect # nudge, reconnects in seconds + * claudemesh kick # stop session, user runs claudemesh to rejoin + * claudemesh kick --stale 30m # kick peers idle > 30m + * claudemesh kick --all # kick everyone except yourself + * + * Ban (permanent, revokes membership) is in ban.ts. */ import { withMesh } from "./connect.js"; @@ -22,6 +26,44 @@ function parseStaleMs(input: string): number | null { return null; } +function buildPayload( + kind: "disconnect" | "kick", + target: string | undefined, + opts: { stale?: string; all?: boolean }, +): Record | { error: string } { + if (opts.all) return { type: kind, all: true }; + if (opts.stale) { + const ms = parseStaleMs(opts.stale); + if (!ms) return { error: `Invalid stale duration: "${opts.stale}". Use e.g. 30m, 1h, 300s.` }; + return { type: kind, stale: ms }; + } + if (target) return { type: kind, target }; + return { error: `Usage: claudemesh ${kind} | --stale 30m | --all` }; +} + +export async function runDisconnect( + target: string | undefined, + opts: { mesh?: string; stale?: string; all?: boolean } = {}, +): Promise { + const config = readConfig(); + const meshSlug = opts.mesh ?? config.meshes[0]?.slug; + if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; } + + const built = buildPayload("disconnect", target, opts); + if ("error" in built) { render.err(String(built.error)); return EXIT.INVALID_ARGS; } + + return await withMesh({ meshSlug }, async (client) => { + const result = await client.sendAndWait(built as Record) as { affected?: string[]; kicked?: string[] }; + const peers = result?.affected ?? result?.kicked ?? []; + if (peers.length === 0) render.info("No peers matched."); + else { + render.ok(`Disconnected ${peers.length} peer(s): ${peers.join(", ")}`); + render.hint("They will auto-reconnect within seconds. For a session-ending kick, use `claudemesh kick`."); + } + return EXIT.SUCCESS; + }); +} + export async function runKick( target: string | undefined, opts: { mesh?: string; stale?: string; all?: boolean } = {}, @@ -30,29 +72,16 @@ export async function runKick( const meshSlug = opts.mesh ?? config.meshes[0]?.slug; if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; } + const built = buildPayload("kick", target, opts); + if ("error" in built) { render.err(String(built.error)); return EXIT.INVALID_ARGS; } + return await withMesh({ meshSlug }, async (client) => { - let payload: Record; - - if (opts.all) { - payload = { type: "kick", all: true }; - } else if (opts.stale) { - const ms = parseStaleMs(opts.stale); - if (!ms) { render.err(`Invalid stale duration: "${opts.stale}". Use e.g. 30m, 1h, 300s.`); return EXIT.INVALID_ARGS; } - payload = { type: "kick", stale: ms }; - } else if (target) { - payload = { type: "kick", target }; - } else { - render.err("Usage: claudemesh kick | --stale 30m | --all"); - return EXIT.INVALID_ARGS; - } - - const result = await client.sendAndWait(payload) as { kicked?: string[] }; - const kicked = result?.kicked ?? []; - - if (kicked.length === 0) { - render.info("No peers matched."); - } else { - render.ok(`Kicked ${kicked.length} peer(s): ${kicked.join(", ")}`); + const result = await client.sendAndWait(built as Record) as { affected?: string[]; kicked?: string[] }; + const peers = result?.affected ?? result?.kicked ?? []; + if (peers.length === 0) render.info("No peers matched."); + else { + render.ok(`Kicked ${peers.length} peer(s): ${peers.join(", ")}`); + render.hint("Their Claude Code session ended. They can rejoin anytime by running `claudemesh`."); } return EXIT.SUCCESS; }); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 4e2b4f5..4bb0c29 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -30,9 +30,10 @@ Mesh claudemesh delete [slug] delete a mesh (alias: rm) claudemesh rename rename a mesh claudemesh share [email] share mesh (invite link / send email) - claudemesh kick disconnect a peer (can reconnect) - claudemesh kick --stale 30m disconnect idle peers (> duration) - claudemesh kick --all disconnect everyone except you + claudemesh disconnect soft disconnect (peer auto-reconnects) + claudemesh kick end session (peer must manually rejoin) + claudemesh kick --stale 30m kick peers idle > duration + claudemesh kick --all kick everyone except yourself claudemesh ban kick + permanently revoke (can't rejoin) claudemesh unban lift a ban claudemesh bans list banned members @@ -139,6 +140,7 @@ async function main(): Promise { case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; } case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; } case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; } case "unban": { const { runUnban } = await import("~/commands/ban.js"); process.exit(await runUnban(positionals[0], { mesh: flags.mesh as string })); break; } diff --git a/apps/cli/src/services/broker/ws-client.ts b/apps/cli/src/services/broker/ws-client.ts index 88fadf8..76a5d0f 100644 --- a/apps/cli/src/services/broker/ws-client.ts +++ b/apps/cli/src/services/broker/ws-client.ts @@ -166,6 +166,8 @@ export class BrokerClient { private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = []; get serviceCatalog() { return this._serviceCatalog; } private closed = false; + /** Non-null when the broker closed us with a terminal code (4001/4002). */ + public terminalClose: { code: number; reason: string } | null = null; private reconnectAttempt = 0; private helloTimer: NodeJS.Timeout | null = null; private reconnectTimer: NodeJS.Timeout | null = null; @@ -321,10 +323,23 @@ export class BrokerClient { this.handleServerMessage(msg); }; - const onClose = (): void => { + const onClose = (code?: number, reasonBuf?: Buffer): void => { if (this.helloTimer) clearTimeout(this.helloTimer); this.helloTimer = null; if (this.ws === ws) this.ws = null; + const reason = reasonBuf?.toString("utf-8") ?? ""; + // Terminal close codes — broker told us to stay gone. + // 4001 = kicked (session ended, user must manually rejoin) + // 4002 = banned (member revoked, cannot rejoin until unbanned) + if (code === 4001 || code === 4002) { + this.closed = true; + this.setConnStatus("closed"); + this.terminalClose = { code, reason }; + if (this._status !== "open") { + reject(new Error(`ws terminal close ${code}: ${reason || "session ended"}`)); + } + return; + } if (this._status !== "open" && this._status !== "reconnecting") { reject(new Error("ws closed before hello_ack")); } @@ -2158,6 +2173,11 @@ export class BrokerClient { } if (msg.type === "error") { this.debug(`broker error: ${msg.code} ${msg.message}`); + // Terminal errors from hello — broker will close us next. Capture + // so the caller (launch/peers/etc.) can surface a friendly message. + if (msg.code === "revoked") { + this.terminalClose = { code: 4002, reason: String(msg.message ?? "revoked") }; + } const id = msg.id ? String(msg.id) : null; let handledByPendingSend = false; if (id) {