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);
|
const member = await findMemberByPubkey(hello.meshId, hello.pubkey);
|
||||||
if (!member) {
|
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" });
|
metrics.connectionsRejected.inc({ reason: "unauthorized" });
|
||||||
sendError(ws, "unauthorized", "pubkey not found in mesh");
|
sendError(ws, "unauthorized", "pubkey not found in mesh");
|
||||||
ws.close(1008, "unauthorized");
|
ws.close(1008, "unauthorized");
|
||||||
@@ -3900,57 +3920,63 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
|
|
||||||
// --- Kick / Ban / Unban ---
|
// --- Kick / Ban / Unban ---
|
||||||
|
|
||||||
|
case "disconnect":
|
||||||
case "kick": {
|
case "kick": {
|
||||||
const km = msg as { type: "kick"; target?: string; stale?: number; all?: boolean; _reqId?: string };
|
// disconnect: soft — WS closes with 1000, CLI auto-reconnects.
|
||||||
// Authz: only owner or admin can kick.
|
// 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 [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);
|
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")) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kicked: string[] = [];
|
const affected: string[] = [];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (km.all) {
|
if (km.all) {
|
||||||
// Kick everyone except caller
|
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
if (peer.meshId !== conn.meshId || pid === presenceId) continue;
|
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);
|
connections.delete(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
kicked.push(peer.displayName || pid);
|
affected.push(peer.displayName || pid);
|
||||||
}
|
}
|
||||||
} else if (km.stale && typeof km.stale === "number") {
|
} else if (km.stale && typeof km.stale === "number") {
|
||||||
// Kick peers idle longer than stale ms
|
|
||||||
const cutoff = now - km.stale;
|
const cutoff = now - km.stale;
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
if (peer.meshId !== conn.meshId || pid === presenceId) continue;
|
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);
|
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) {
|
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);
|
connections.delete(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
kicked.push(peer.displayName || pid);
|
affected.push(peer.displayName || pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (km.target) {
|
} else if (km.target) {
|
||||||
// Kick specific peer by name or pubkey
|
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
if (peer.meshId !== conn.meshId) continue;
|
if (peer.meshId !== conn.meshId) continue;
|
||||||
if (peer.displayName === km.target || peer.memberPubkey === km.target || peer.memberPubkey.startsWith(km.target)) {
|
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);
|
connections.delete(pid);
|
||||||
void disconnectPresence(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 }));
|
conn.ws.send(JSON.stringify({ type: ackType, kicked: affected, affected, _reqId: km._reqId }));
|
||||||
log.info("ws kick", { presence_id: presenceId, kicked_count: kicked.length, target: km.target ?? km.stale ?? "all" });
|
log.info(`ws ${closeReason}`, { presence_id: presenceId, count: affected.length, target: km.target ?? km.stale ?? "all" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3985,7 +4011,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
// Kick all their connections
|
// Kick all their connections
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
if (peer.meshId === conn.meshId && peer.memberPubkey === targetMember.peerPubkey) {
|
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);
|
connections.delete(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0-alpha.43",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ export async function withMesh<T>(
|
|||||||
await client.connect();
|
await client.connect();
|
||||||
const result = await fn(client, mesh);
|
const result = await fn(client, mesh);
|
||||||
return result;
|
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 {
|
} finally {
|
||||||
client.close();
|
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 disconnect <peer> # nudge, reconnects in seconds
|
||||||
* claudemesh kick --stale 30m kick idle peers (> 30 min no activity)
|
* claudemesh kick <peer> # stop session, user runs claudemesh to rejoin
|
||||||
* claudemesh kick --all kick everyone except yourself
|
* 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";
|
import { withMesh } from "./connect.js";
|
||||||
@@ -22,6 +26,44 @@ function parseStaleMs(input: string): number | null {
|
|||||||
return 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(
|
export async function runKick(
|
||||||
target: string | undefined,
|
target: string | undefined,
|
||||||
opts: { mesh?: string; stale?: string; all?: boolean } = {},
|
opts: { mesh?: string; stale?: string; all?: boolean } = {},
|
||||||
@@ -30,29 +72,16 @@ export async function runKick(
|
|||||||
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||||
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
|
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) => {
|
return await withMesh({ meshSlug }, async (client) => {
|
||||||
let payload: Record<string, unknown>;
|
const result = await client.sendAndWait(built as Record<string, unknown>) as { affected?: string[]; kicked?: string[] };
|
||||||
|
const peers = result?.affected ?? result?.kicked ?? [];
|
||||||
if (opts.all) {
|
if (peers.length === 0) render.info("No peers matched.");
|
||||||
payload = { type: "kick", all: true };
|
else {
|
||||||
} else if (opts.stale) {
|
render.ok(`Kicked ${peers.length} peer(s): ${peers.join(", ")}`);
|
||||||
const ms = parseStaleMs(opts.stale);
|
render.hint("Their Claude Code session ended. They can rejoin anytime by running `claudemesh`.");
|
||||||
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(", ")}`);
|
|
||||||
}
|
}
|
||||||
return EXIT.SUCCESS;
|
return EXIT.SUCCESS;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ Mesh
|
|||||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||||
claudemesh rename <slug> <name> rename a mesh
|
claudemesh rename <slug> <name> rename a mesh
|
||||||
claudemesh share [email] share mesh (invite link / send email)
|
claudemesh share [email] share mesh (invite link / send email)
|
||||||
claudemesh kick <peer> disconnect a peer (can reconnect)
|
claudemesh disconnect <peer> soft disconnect (peer auto-reconnects)
|
||||||
claudemesh kick --stale 30m disconnect idle peers (> duration)
|
claudemesh kick <peer> end session (peer must manually rejoin)
|
||||||
claudemesh kick --all disconnect everyone except you
|
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 ban <peer> kick + permanently revoke (can't rejoin)
|
||||||
claudemesh unban <peer> lift a ban
|
claudemesh unban <peer> lift a ban
|
||||||
claudemesh bans list banned members
|
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 "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 "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 "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 "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 "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; }
|
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 }> = [];
|
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; }
|
get serviceCatalog() { return this._serviceCatalog; }
|
||||||
private closed = false;
|
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 reconnectAttempt = 0;
|
||||||
private helloTimer: NodeJS.Timeout | null = null;
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -321,10 +323,23 @@ export class BrokerClient {
|
|||||||
this.handleServerMessage(msg);
|
this.handleServerMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = (): void => {
|
const onClose = (code?: number, reasonBuf?: Buffer): void => {
|
||||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
this.helloTimer = null;
|
this.helloTimer = null;
|
||||||
if (this.ws === ws) this.ws = 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") {
|
if (this._status !== "open" && this._status !== "reconnecting") {
|
||||||
reject(new Error("ws closed before hello_ack"));
|
reject(new Error("ws closed before hello_ack"));
|
||||||
}
|
}
|
||||||
@@ -2158,6 +2173,11 @@ export class BrokerClient {
|
|||||||
}
|
}
|
||||||
if (msg.type === "error") {
|
if (msg.type === "error") {
|
||||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
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;
|
const id = msg.id ? String(msg.id) : null;
|
||||||
let handledByPendingSend = false;
|
let handledByPendingSend = false;
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user