feat(cli+broker): three-tier peer removal: disconnect, kick, ban
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-04-20 09:55:05 +01:00
parent 163e1be70a
commit b49e9a9b61
8 changed files with 445 additions and 48 deletions

View File

@@ -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;
});