feat(cli,broker): 1.34.14 + 1.34.15 — env-var fallback, peer list scope, kick refuses control-plane
Three follow-ups from the 1.34.x multi-session correctness train, all backwards-compatible. 1.34.14 — stale CLAUDEMESH_CONFIG_DIR falls back. The launch flow exposes CLAUDEMESH_CONFIG_DIR=<tmpdir> to its spawned claude; if a later claudemesh invocation inherited that env (Bash tool inside Claude Code, tmux update-environment, exported var), the inherited path pointed at a tmpdir that no longer existed and readConfig() silently returned empty. paths.ts now memoizes resolution: env unset → default; env points at a real dir → trust it; env set but dir gone → TTY-only stderr warning with shell-specific unset hint, fall back to ~/.claudemesh. 1.34.15 — peer list --mesh actually scopes. peers.ts and launch.ts were calling tryListPeersViaDaemon() with no argument; the daemon's ?mesh= filter (server-side, since 1.26.0) was already correct, the CLI just wasn't passing the slug. Forwarding fixed in both sites; send.ts cross-mesh hex-prefix resolution intentionally untouched. 1.34.15 — kick refuses no-op kicks on control-plane. Pre-1.34.15 kicking a daemon's member-WS just closed the socket and triggered auto-reconnect — a no-op with a misleading "session ended" message. Broker now skips peers where peerRole === "control-plane" and surfaces them in a new additive ack field skipped_control_plane; the CLI reads it and prints a clearer hint pointing at ban / daemon down. Soft disconnect verb keeps old behavior. PeerConn gains a peerRole slot populated at both connections.set sites. Tests: 4 new for paths-stale-env, 5 for kick-control-plane-skip. CLI 87/87 green; broker 55/55 unit green (integration tests pre-existing infra failure on this machine). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,12 +76,32 @@ export async function runKick(
|
||||
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 result = await client.sendAndWait(built as Record<string, unknown>) as {
|
||||
affected?: string[];
|
||||
kicked?: string[];
|
||||
// 1.34.15: broker refuses to kick control-plane WSes (they'd
|
||||
// just auto-reconnect). Older brokers don't emit this field.
|
||||
skipped_control_plane?: string[];
|
||||
};
|
||||
const peers = result?.affected ?? result?.kicked ?? [];
|
||||
if (peers.length === 0) render.info("No peers matched.");
|
||||
else {
|
||||
const skipped = result?.skipped_control_plane ?? [];
|
||||
|
||||
if (peers.length === 0 && skipped.length === 0) {
|
||||
render.info("No peers matched.");
|
||||
} else if (peers.length === 0 && skipped.length > 0) {
|
||||
render.warn(
|
||||
`${skipped.length} match(es) refused: ${skipped.join(", ")} — control-plane connections (daemon / dashboard) auto-reconnect, so kick is a no-op.`,
|
||||
"To take a daemon offline locally, run `claudemesh daemon down` on that machine. To remove a member from the mesh, use `claudemesh ban <peer>`.",
|
||||
);
|
||||
} else {
|
||||
render.ok(`Kicked ${peers.length} peer(s): ${peers.join(", ")}`);
|
||||
render.hint("Their Claude Code session ended. They can rejoin anytime by running `claudemesh`.");
|
||||
if (skipped.length > 0) {
|
||||
render.warn(
|
||||
`(also refused ${skipped.length} control-plane connection(s): ${skipped.join(", ")})`,
|
||||
"Daemon / dashboard connections auto-reconnect; kick is a no-op against them. Use `claudemesh ban <peer>` to remove a member entirely.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
|
||||
@@ -400,11 +400,13 @@ async function printBrokerWelcome(meshSlug: string): Promise<void> {
|
||||
}
|
||||
} catch { /* daemon unreachable — not fatal */ }
|
||||
|
||||
// Peer count (best-effort).
|
||||
// Peer count (best-effort). 1.34.15: scope to the launched mesh so
|
||||
// multi-mesh daemons don't inflate the welcome banner with peers
|
||||
// from other meshes the user didn't just attach to.
|
||||
let peerCount = -1;
|
||||
try {
|
||||
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||
const peers = (await tryListPeersViaDaemon()) ?? [];
|
||||
const peers = (await tryListPeersViaDaemon(meshSlug)) ?? [];
|
||||
peerCount = peers.filter((p) =>
|
||||
(p as { channel?: string }).channel !== "claudemesh-daemon",
|
||||
).length;
|
||||
|
||||
@@ -135,9 +135,17 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
||||
// lifecycle helper inside tryListPeersViaDaemon auto-spawns the
|
||||
// daemon if it's down and probes it for liveness — no separate bridge
|
||||
// tier is needed any more (1.28.0).
|
||||
//
|
||||
// 1.34.15: forward `slug` to the daemon as `?mesh=<slug>` so the
|
||||
// server-side aggregator narrows to the requested mesh. Pre-1.34.15
|
||||
// we called this with no argument, so a multi-mesh daemon returned
|
||||
// peers from every attached mesh and the renderer printed "peers on
|
||||
// flexicar" with cross-mesh rows mixed in. The daemon's
|
||||
// `meshFromCtx` already does the right scoping when the slug is
|
||||
// passed; the CLI just wasn't passing it.
|
||||
try {
|
||||
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||
const dr = await tryListPeersViaDaemon();
|
||||
const dr = await tryListPeersViaDaemon(slug);
|
||||
if (dr !== null) {
|
||||
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, selfSessionPubkey));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,82 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const home = homedir();
|
||||
const DEFAULT_CONFIG_DIR = join(home, ".claudemesh");
|
||||
|
||||
/**
|
||||
* Resolve `CONFIG_DIR` once, with stale-env detection.
|
||||
*
|
||||
* `claudemesh launch` exposes `CLAUDEMESH_CONFIG_DIR=<tmpdir>` to its
|
||||
* spawned `claude` so the per-session mesh selection is isolated from
|
||||
* `~/.claudemesh/config.json`. The tmpdir is rmSync'd on launch exit.
|
||||
*
|
||||
* Footgun: if a `claudemesh` invocation INHERITS that env from an
|
||||
* already-launched (or previously-launched) session — e.g. a Bash tool
|
||||
* call inside Claude Code, or a tmux pane that captured the env via
|
||||
* `update-environment` — the inherited path may point at a tmpdir that
|
||||
* no longer exists. Pre-1.34.14 we silently used the dead path,
|
||||
* `readConfig()` came back empty, and the user saw "No meshes joined"
|
||||
* from an otherwise-working install.
|
||||
*
|
||||
* Resolution rules:
|
||||
* 1. No env var → `~/.claudemesh` (default).
|
||||
* 2. Env points at a dir containing `config.json` → trust it
|
||||
* (the legitimate per-session-launch case).
|
||||
* 3. Env set but stale (dir missing or no `config.json`) → warn
|
||||
* once on stderr (TTY-only) and fall back to `~/.claudemesh`.
|
||||
*
|
||||
* Memoized: resolves once on first access. Mid-process env mutations
|
||||
* are intentionally ignored — paths must stay stable across one CLI
|
||||
* invocation.
|
||||
*/
|
||||
let _resolvedConfigDir: string | null = null;
|
||||
let _warnedStaleEnv = false;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
if (_resolvedConfigDir !== null) return _resolvedConfigDir;
|
||||
const envDir = process.env.CLAUDEMESH_CONFIG_DIR;
|
||||
if (!envDir) {
|
||||
_resolvedConfigDir = DEFAULT_CONFIG_DIR;
|
||||
return DEFAULT_CONFIG_DIR;
|
||||
}
|
||||
// Trust the env when it resolves to a real directory. We check
|
||||
// the DIR (not `config.json`) because the legitimate "fresh launch
|
||||
// before any write" case has the dir but no config.json yet.
|
||||
// The stale signature we want to catch is `rmSync(tmpDir,
|
||||
// {recursive: true})` from the outer launch's cleanup — that
|
||||
// removes the directory entirely, so a missing dir is the
|
||||
// unambiguous "stale" signal.
|
||||
if (existsSync(envDir)) {
|
||||
_resolvedConfigDir = envDir;
|
||||
return envDir;
|
||||
}
|
||||
// Stale: env set but the dir is gone. Most likely the outer
|
||||
// launch's cleanup ran and we inherited its (now-dead) tmpdir
|
||||
// path. Fall back to default and warn the user once on stderr —
|
||||
// only when attached to a TTY, so non-interactive callers (CI,
|
||||
// MCP boot, scripts piping stdout) stay quiet.
|
||||
if (!_warnedStaleEnv && process.stderr.isTTY) {
|
||||
_warnedStaleEnv = true;
|
||||
const unsetHint =
|
||||
process.env.SHELL?.endsWith("fish")
|
||||
? "set -e CLAUDEMESH_CONFIG_DIR CLAUDEMESH_IPC_TOKEN_FILE"
|
||||
: "unset CLAUDEMESH_CONFIG_DIR CLAUDEMESH_IPC_TOKEN_FILE";
|
||||
process.stderr.write(
|
||||
`claudemesh: ignoring stale CLAUDEMESH_CONFIG_DIR=${envDir} (no config.json there); using ${DEFAULT_CONFIG_DIR}.\n`
|
||||
+ ` Hint: this is usually a leftover env from a previous \`claudemesh launch\`. Clean it with:\n`
|
||||
+ ` ${unsetHint}\n`,
|
||||
);
|
||||
}
|
||||
_resolvedConfigDir = DEFAULT_CONFIG_DIR;
|
||||
return DEFAULT_CONFIG_DIR;
|
||||
}
|
||||
|
||||
export const PATHS = {
|
||||
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
|
||||
get CONFIG_DIR() {
|
||||
return resolveConfigDir();
|
||||
},
|
||||
get CONFIG_FILE() {
|
||||
return join(this.CONFIG_DIR, "config.json");
|
||||
},
|
||||
@@ -20,3 +92,12 @@ export const PATHS = {
|
||||
CLAUDE_JSON: join(home, ".claude.json"),
|
||||
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Test-only: reset the memoized resolution. Not exported from the
|
||||
* package barrel; reach in via the relative path from a test file.
|
||||
*/
|
||||
export function _resetPathsForTest(): void {
|
||||
_resolvedConfigDir = null;
|
||||
_warnedStaleEnv = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user