feat(cli+broker): three-tier peer removal: disconnect, kick, ban
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) <noreply@anthropic.com>
This commit is contained in:
158
.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt
Normal file
158
.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt
Normal file
@@ -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.
|
||||
147
.artifacts/ideas/2026-04-19-hackathon-proposal.txt
Normal file
147
.artifacts/ideas/2026-04-19-hackathon-proposal.txt
Normal file
@@ -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.
|
||||
@@ -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 (<name> | --stale <ms> | --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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -75,6 +75,21 @@ export async function withMesh<T>(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 <name> 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 <peer> # nudge, reconnects in seconds
|
||||
* claudemesh kick <peer> # 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<string, unknown> | { 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} <peer> | --stale 30m | --all` };
|
||||
}
|
||||
|
||||
export async function runDisconnect(
|
||||
target: string | undefined,
|
||||
opts: { mesh?: string; stale?: string; all?: boolean } = {},
|
||||
): Promise<number> {
|
||||
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<string, unknown>) 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<string, unknown>;
|
||||
|
||||
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 <peer> | --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<string, unknown>) 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;
|
||||
});
|
||||
|
||||
@@ -30,9 +30,10 @@ Mesh
|
||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||
claudemesh rename <slug> <name> rename a mesh
|
||||
claudemesh share [email] share mesh (invite link / send email)
|
||||
claudemesh kick <peer> disconnect a peer (can reconnect)
|
||||
claudemesh kick --stale 30m disconnect idle peers (> duration)
|
||||
claudemesh kick --all disconnect everyone except you
|
||||
claudemesh disconnect <peer> soft disconnect (peer auto-reconnects)
|
||||
claudemesh kick <peer> end session (peer must manually rejoin)
|
||||
claudemesh kick --stale 30m kick peers idle > duration
|
||||
claudemesh kick --all kick everyone except yourself
|
||||
claudemesh ban <peer> kick + permanently revoke (can't rejoin)
|
||||
claudemesh unban <peer> lift a ban
|
||||
claudemesh bans list banned members
|
||||
@@ -139,6 +140,7 @@ async function main(): Promise<void> {
|
||||
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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user