Files
claudemesh/apps/cli/src/commands/peers.ts
Alejandro Gutiérrez a2a53ff355 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>
2026-05-04 21:59:06 +01:00

348 lines
14 KiB
TypeScript

/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Shows all meshes by default, or filter with --mesh.
*
* Warm path: dials the per-mesh bridge socket the push-pipe holds open.
* Cold path: opens its own WS via `withMesh`. Bridge fall-through is
* transparent — output is identical.
*
* `--json` accepts an optional comma-separated field list:
* claudemesh peers --json (full record)
* claudemesh peers --json name,pubkey,status (projection)
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
/** `true`/`undefined` = full record; comma-separated string = field projection. */
json?: boolean | string;
/** When false (default), hide control-plane presence rows from the
* human renderer — they're infrastructure (daemon-WS member-keyed
* presence), not interactive peers, and confused users into thinking
* the daemon counted as a "peer". The JSON output still includes them
* so scripts that need a full inventory can opt in via --all (or
* just consume JSON).
*
* Source of truth is the broker-side `role` field
* (`'control-plane' | 'session' | 'service'`). Older brokers don't
* emit `role` yet — this code falls back to treating missing role as
* `'session'` so legacy peer rows stay visible. */
all?: boolean;
}
/**
* Broker-emitted peer classification, added 2026-05-04. Older brokers
* may omit it — treat missing as 'session' so legacy meshes still
* render their peers (and don't accidentally hide them all). The CLI
* never emits 'control-plane' on its own; that comes from the broker.
*/
export type PeerRole = "control-plane" | "session" | "service";
interface PeerRecord {
pubkey: string;
/** Stable member pubkey (independent of session). When sender shares
* this with a peer, they're talking to the same person across all
* their open sessions. */
memberPubkey?: string;
/** Per-launch session identifier (uuid). Used by the renderer to
* disambiguate sibling sessions of the same member that otherwise
* look identical (same name, same cwd). */
sessionId?: string;
displayName: string;
status?: string;
summary?: string;
groups: Array<{ name: string; role?: string }>;
/** Top-level convenience alias for `profile.role`, lifted by the CLI
* since 1.31.5 so JSON consumers (the agent-vibes claudemesh skill,
* launched-session LLMs) see the user-supplied role string at the
* shape's top level. Same value as `profile.role`. Distinct from
* `peerRole` below — that's the broker's presence-class taxonomy. */
role?: string;
/** Broker-emitted presence classification: 'control-plane' | 'session'
* | 'service'. Source of truth for the --all visibility filter and
* the default-hide rule. Older brokers omit this; the CLI fills
* missing values with 'session' so legacy peer rows stay visible.
*
* Renamed from `role` to avoid collision with 1.31.5's profile.role
* lift above. Wire-level field on the broker is also `peerRole`. */
peerRole?: PeerRole;
peerType?: string;
channel?: string;
model?: string;
cwd?: string;
/** Peer-level profile metadata (set via `claudemesh profile`). The
* broker passes this through verbatim; the most common field is
* `role` ("lead", "reviewer", "human", etc.) but capabilities, bio,
* avatar, and title also live here when set. */
profile?: {
role?: string;
title?: string;
bio?: string;
avatar?: string;
capabilities?: string[];
[k: string]: unknown;
};
/** True when this peer is one of the caller's own member's sessions.
* Set in the cli (not the broker) by comparing memberPubkey against
* the caller's stable JoinedMesh.pubkey. */
isSelf?: boolean;
/** When isSelf is true, true if this is the exact session running
* the command (vs a sibling session of the same member). */
isThisSession?: boolean;
[k: string]: unknown;
}
/** Friendly aliases — `name` is what users will type; broker calls it `displayName`. */
const FIELD_ALIAS: Record<string, string> = {
name: "displayName",
};
function projectFields(record: PeerRecord, fields: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const sourceKey = FIELD_ALIAS[f] ?? f;
out[f] = (record as Record<string, unknown>)[sourceKey];
}
return out;
}
async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
const config = readConfig();
const joined = config.meshes.find((m) => m.slug === slug);
const selfMemberPubkey = joined?.pubkey ?? null;
// Resolve our own session pubkey via the daemon's /v1/sessions/me when
// we're inside a launched session. Without this, isThisSession can't
// be set on the daemon path (only on the cold path where a fresh WS
// creates the keypair), and the renderer can't tell the user which
// row in `peer list` is them.
let selfSessionPubkey: string | null = null;
try {
const { getSessionInfo } = await import("~/services/session/resolve.js");
const sess = await getSessionInfo();
if (sess && sess.mesh === slug && sess.presence?.sessionPubkey) {
selfSessionPubkey = sess.presence.sessionPubkey;
}
} catch { /* not in a launched session; isThisSession stays false */ }
// Daemon path — preferred when running. Same routing pattern as send.ts:
// ~1 ms IPC round-trip; broker WS already warm in the daemon. The
// 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(slug);
if (dr !== null) {
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, selfSessionPubkey));
}
} catch { /* daemon route helper not available; fall through */ }
// Cold path — open our own WS. Reached only when the lifecycle helper
// could not bring the daemon up.
let result: PeerRecord[] = [];
await withMesh({ meshSlug: slug }, async (client) => {
const all = (await client.listPeers()) as unknown as PeerRecord[];
const selfSessionPubkey = client.getSessionPubkey();
result = all.map((p) =>
annotateSelf(p, selfMemberPubkey, selfSessionPubkey),
);
});
return result;
}
/**
* Tag each peer record with `isSelf` / `isThisSession` so the renderer
* (and downstream code that picks targets, e.g. `claudemesh send`) can
* tell sender's own sessions from real peers. The broker has always
* surfaced a sender's siblings as separate rows because they're separate
* presence rows; the cli just hadn't been making that visible.
*
* Also normalizes the broker's `peerRole` classification: missing
* values (older brokers) default to 'session' so legacy peer rows stay
* visible under the default `--all=false` filter.
*
* And lifts `profile.role` to a top-level `role` field — the 1.31.5
* convenience alias for JSON consumers (skill SKILL.md, launched-session
* LLMs, jq pipelines). Same value as profile.role; distinct from
* peerRole (presence taxonomy).
*/
function annotateSelf(
peer: PeerRecord,
selfMemberPubkey: string | null,
selfSessionPubkey: string | null,
): PeerRecord {
const isSelf = !!(
selfMemberPubkey &&
peer.memberPubkey &&
peer.memberPubkey === selfMemberPubkey
);
const isThisSession = !!(
isSelf &&
selfSessionPubkey &&
peer.pubkey === selfSessionPubkey
);
const peerRole: PeerRole = peer.peerRole ?? "session";
const profileRole = peer.profile?.role?.trim() || undefined;
return {
...peer,
...(profileRole ? { role: profileRole } : {}),
peerRole,
isSelf,
isThisSession,
};
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const config = readConfig();
// Mesh selection precedence:
// 1. explicit --mesh <slug> (always wins)
// 2. session-token mesh (when invoked from inside a launched session)
// 3. all joined meshes (default for bare shells)
let slugs: string[];
if (flags.mesh) {
slugs = [flags.mesh];
} else {
const { getSessionInfo } = await import("~/services/session/resolve.js");
const sess = await getSessionInfo();
slugs = sess ? [sess.mesh] : config.meshes.map((m) => m.slug);
}
if (slugs.length === 0) {
render.err("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(1);
}
// Field projection: --json a,b,c
const fieldList: string[] | null =
typeof flags.json === "string" && flags.json.length > 0
? flags.json.split(",").map((s) => s.trim()).filter(Boolean)
: null;
const wantsJson = flags.json !== undefined && flags.json !== false;
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
const peers = await listPeersForMesh(slug);
if (wantsJson) {
const projected = fieldList
? peers.map((p) => projectFields(p, fieldList))
: peers;
allJson.push({ mesh: slug, peers: projected });
continue;
}
// Hide control-plane rows by default — they're infrastructure
// (daemon-WS member-keyed presence), not interactive peers, and
// they confused users into thinking the daemon counted as a
// separate peer. --all opts back in for debugging.
//
// Source of truth: broker-emitted `peerRole` field (added
// 2026-05-04). annotateSelf() filled in 'session' for older
// brokers that don't emit peerRole yet, so this filter is
// backwards-compatible by construction — legacy rows show up.
const visible = flags.all
? peers
: peers.filter((p) => p.peerRole !== "control-plane");
// Sort: this-session first, then your-other-sessions, then real
// peers. Within each group, idle/working ahead of dnd. Inside the
// groups, leave broker order. The point is: when you run peer
// list, the row that's YOU is row 1.
const sorted = visible.slice().sort((a, b) => {
const score = (p: PeerRecord) =>
p.isThisSession ? 0 : p.isSelf ? 1 : 2;
return score(a) - score(b);
});
const hiddenControlPlane = peers.length - visible.length;
const header = hiddenControlPlane > 0
? `peers on ${slug} (${sorted.length}, ${hiddenControlPlane} control-plane hidden — use --all)`
: `peers on ${slug} (${sorted.length})`;
render.section(header);
if (sorted.length === 0) {
render.info(dim(" (no peers connected)"));
continue;
}
for (const p of sorted) {
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}`);
// Short sessionId tag — appears for sibling sessions of the same
// member that would otherwise be visually identical (same name,
// same cwd, only the truncated pubkey on the right differs).
const sidTag = p.sessionId
? dim(` · sid:${p.sessionId.slice(0, 8)}`)
: "";
const selfTag = p.isThisSession
? dim(" ") + yellow("(this session)")
: p.isSelf
? dim(" ") + yellow("(your other session)")
: "";
// Inline tags ("role:lead [@flexicar:reviewer, @oncall]") so the
// first thing the user sees beside the name is the access /
// affiliation context. Empty role + empty groups → omit the
// bracket entirely (the dim summary line below carries the
// explicit "(no role / no groups)" so JSON output is unaffected
// and screen readers don't get spammed with literal "no").
const inlineTags: string[] = [];
const peerRole = p.profile?.role?.trim();
if (peerRole) inlineTags.push(`role:${peerRole}`);
if (p.groups.length) {
inlineTags.push(
...p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`),
);
}
const tagsStr = inlineTags.length ? " [" + inlineTags.join(", ") + "]" : "";
render.info(
`${statusDot} ${name}${selfTag}${tagsStr}${metaStr}${pubkeyTag}${sidTag}${summary}`,
);
// Second line: cwd + an explicit role/groups footer when both
// are absent. Surfacing the absence is important — the previous
// renderer hid it, so users couldn't tell "no role set" from
// "the cli isn't showing roles".
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
if (!peerRole && p.groups.length === 0) {
render.info(dim(" role: (none) groups: (none)"));
}
}
} catch (e) {
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (wantsJson) {
process.stdout.write(
JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n",
);
}
}