feat(cli): 1.32.0 — multi-session UX bundle (self-identity, --self fan-out, broker welcome)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Nine UX bugs surfaced from a real two-session interconnect smoke
test, shipped together.

Self-identity is visible
- peer list now shows the caller as (this session), sorted to top.
  Daemon path resolves session pubkey via /v1/sessions/me so
  isThisSession is set correctly warm.
- whoami shows session pubkey, session id, mesh, role, groups, cwd,
  pid when run inside a launched session.

Sibling-session disambiguation
- peer list rows carry sid:<short> tag so visually-identical rows
  can be told apart at a glance.

Daemon hidden by default
- claudemesh-daemon presence rows hidden from peer list by default.
  --all opts back in. Header shows N daemon hidden when applicable.

--self flag works end-to-end
- Argv parser was greedy: --self ate the next arg as its value.
  BOOLEAN_FLAGS set in cli/argv.ts now lists known no-value switches.
- message send subcommand now passes self through (only legacy send
  was wired before).
- Help text lists --self.

Member-pubkey fan-out
- Sending to your own member pubkey with --self now resolves to every
  connected sibling session and sends one message per recipient.
  Required because the broker drain matches target_spec only against
  full session pubkeys; member-pubkey sends queued but never drained.

Broker welcome at launch
- After the launch banner, one line confirms WS state, peer count,
  and unread inbox count. Best-effort — falls back gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 17:02:28 +01:00
parent 25586d298f
commit 7460d34335
9 changed files with 353 additions and 23 deletions

View File

@@ -2,6 +2,35 @@ import { defineCommand, runMain } from "citty";
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
/**
* Flags that NEVER take a value. The parser's default behavior is greedy
* (any `--flag` consumes the next non-`-` arg as its value), which is
* fine for `--mesh foo` and `--priority now` but breaks for booleans:
* `claudemesh send --self <pubkey> "msg"` was eating the pubkey as the
* value of --self, leaving zero positionals and triggering Usage errors.
*
* Adding to this set: any new boolean / no-arg switch.
*/
const BOOLEAN_FLAGS = new Set([
"self",
"json", // also accepts --json=a,b,c form below
"all",
"yes", "y",
"help", "h",
"version", "v",
"quiet",
"strict",
"continue",
"no-daemon",
"no-color",
"debug",
"allow-ci-persistent",
"force",
"dry-run",
"verbose",
"skip-service",
]);
export function parseArgv(argv: string[]): ParsedArgs {
const args = argv.slice(2);
const flags: Record<string, string | boolean | undefined> = {};
@@ -10,14 +39,26 @@ export function parseArgv(argv: string[]): ParsedArgs {
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
// --flag=value (always parsed as a value, regardless of boolean set)
if (arg.startsWith("--") && arg.includes("=")) {
const eq = arg.indexOf("=");
const key = arg.slice(2, eq);
flags[key] = arg.slice(eq + 1);
continue;
}
if (arg.startsWith("--")) {
const key = arg.slice(2);
// Known boolean → never consume the next token as a value.
if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; }
else flags[key] = true;
} else if (arg.startsWith("-") && arg.length === 2) {
const key = arg.slice(1);
if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; }
else flags[key] = true;
} else if (!command) {
command = arg;
} else {

View File

@@ -349,6 +349,64 @@ async function runLaunchWizard(opts: {
return { mesh, role, groups, messageMode, skipPermissions };
}
/**
* 1.32.0 — broker welcome line printed right after the launch banner.
* Polls the daemon's /v1/health (per-mesh broker WS state) and tries
* to fetch the inbox + peer count via daemon-route helpers. Best-effort:
* if any call fails the welcome simply prints what it knows and moves
* on — never blocks the launch path.
*/
async function printBrokerWelcome(meshSlug: string): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[22m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[22m` : s);
// Probe daemon health for broker WS state.
let brokerState = "unknown";
try {
const { ipc } = await import("~/daemon/ipc/client.js");
const res = await ipc<{ ok?: boolean; brokers?: Record<string, string> }>({
path: "/v1/health",
timeoutMs: 1_500,
});
if (res.status === 200 && res.body?.brokers) {
brokerState = res.body.brokers[meshSlug] ?? "unknown";
}
} catch { /* daemon unreachable — not fatal */ }
// Peer count (best-effort).
let peerCount = -1;
try {
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
const peers = (await tryListPeersViaDaemon()) ?? [];
peerCount = peers.filter((p) =>
(p as { channel?: string }).channel !== "claudemesh-daemon",
).length;
} catch { /* skip peer count */ }
// Unread inbox count (best-effort).
let unread = -1;
try {
const { ipc } = await import("~/daemon/ipc/client.js");
const res = await ipc<{ messages?: unknown[] }>({
path: "/v1/inbox",
timeoutMs: 1_500,
});
if (res.status === 200 && Array.isArray(res.body?.messages)) {
unread = res.body.messages.length;
}
} catch { /* skip unread */ }
const dot = brokerState === "open" ? green("●") : yellow("●");
const parts: string[] = [];
parts.push(`broker ${brokerState === "open" ? "connected" : brokerState}`);
if (peerCount >= 0) parts.push(`${peerCount} peer${peerCount === 1 ? "" : "s"} online`);
if (unread >= 0) parts.push(`${unread} unread`);
console.log(`${dot} ${parts.join(dim(" · "))}`);
console.log("");
}
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
@@ -752,6 +810,10 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
// 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
// 1.32.0+: broker welcome — confirm the per-session WS is actually
// attached and surface peer count + unread inbox so the user lands
// in claude code with a clear state instead of silent assumptions.
await printBrokerWelcome(mesh.slug);
}
// --- Install native MCP entries for deployed mesh services ---

View File

@@ -21,6 +21,12 @@ export interface PeersFlags {
mesh?: string;
/** `true`/`undefined` = full record; comma-separated string = field projection. */
json?: boolean | string;
/** When false (default), hide claudemesh-daemon presence rows from the
* human renderer — they're infrastructure, 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). */
all?: boolean;
}
interface PeerRecord {
@@ -29,6 +35,10 @@ interface PeerRecord {
* 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;
@@ -82,6 +92,20 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
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
@@ -91,7 +115,7 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
const dr = await tryListPeersViaDaemon();
if (dr !== null) {
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, null));
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, selfSessionPubkey));
}
} catch { /* daemon route helper not available; fall through */ }
@@ -184,14 +208,36 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
continue;
}
render.section(`peers on ${slug} (${peers.length})`);
// Hide claudemesh-daemon rows by default — they're infrastructure
// (the daemon's own 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.
const visible = flags.all
? peers
: peers.filter((p) => p.channel !== "claudemesh-daemon");
if (peers.length === 0) {
// 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 hiddenDaemons = peers.length - visible.length;
const header = hiddenDaemons > 0
? `peers on ${slug} (${sorted.length}, ${hiddenDaemons} daemon 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 peers) {
for (const p of sorted) {
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
@@ -201,6 +247,12 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
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
@@ -224,7 +276,7 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
const tagsStr = inlineTags.length ? " [" + inlineTags.join(", ") + "]" : "";
render.info(
`${statusDot} ${name}${selfTag}${tagsStr}${metaStr}${pubkeyTag}${summary}`,
`${statusDot} ${name}${selfTag}${tagsStr}${metaStr}${pubkeyTag}${sidTag}${summary}`,
);
// Second line: cwd + an explicit role/groups footer when both

View File

@@ -101,12 +101,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
}
// Self-DM safety check: if target is a 64-char hex that matches the
// caller's own member pubkey (or any of the caller's session/member
// entries), refuse without --self. Catches the common pasted-from-
// peer-list-not-realizing-it-was-mine footgun.
if (!flags.self && meshSlug) {
// caller's own member pubkey, refuse without --self. Catches the
// common pasted-from-peer-list-not-realizing-it-was-mine footgun.
// With --self, member-pubkey targeting fans out to every connected
// sibling session of your member (the broker's drain only matches
// exact session pubkeys, so we resolve here in the CLI).
if (meshSlug) {
const joined = config.meshes.find((m) => m.slug === meshSlug);
if (joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase()) {
const isOwnMemberKey =
joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase();
if (isOwnMemberKey && !flags.self) {
render.err(
`Target "${to.slice(0, 16)}…" is your own member pubkey on mesh "${meshSlug}".`,
);
@@ -115,6 +120,68 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
);
process.exit(1);
}
if (isOwnMemberKey && flags.self) {
// Member-pubkey fan-out: resolve to every connected sibling
// session pubkey and send one message per recipient. Required
// because the broker's drain query at apps/broker/src/broker.ts
// matches target_spec only against full session pubkeys —
// sending to a member pubkey would queue successfully but no
// drain would fetch.
try {
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
const { getSessionInfo } = await import("~/services/session/resolve.js");
const peers = (await tryListPeersViaDaemon()) ?? [];
const session = await getSessionInfo();
const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase();
const siblings = peers.filter((p) => {
const r = p as { memberPubkey?: string; pubkey?: string; channel?: string };
if (!r.pubkey) return false;
if (ownSessionPk && r.pubkey.toLowerCase() === ownSessionPk) return false;
if (r.channel === "claudemesh-daemon") return false;
return r.memberPubkey?.toLowerCase() === to.toLowerCase();
});
if (siblings.length === 0) {
render.err(`--self fan-out: no other sibling sessions of your member online.`);
process.exit(1);
}
const results: Array<{ pubkey: string; ok: boolean; messageId?: string; error?: string }> = [];
for (const peer of siblings) {
const pk = (peer as { pubkey: string }).pubkey;
const dr = await trySendViaDaemon({ to: pk, message, priority, expectedMesh: meshSlug ?? undefined });
if (dr === null) {
results.push({ pubkey: pk, ok: false, error: "daemon path unavailable" });
continue;
}
if (dr.ok) {
results.push({
pubkey: pk,
ok: true,
...(dr.messageId ? { messageId: dr.messageId } : {}),
});
} else {
results.push({ pubkey: pk, ok: false, error: dr.error });
}
}
const okCount = results.filter((r) => r.ok).length;
if (flags.json) {
console.log(JSON.stringify({ ok: okCount > 0, fanout: results, via: "daemon" }));
} else if (okCount === results.length) {
render.ok(`fanned out to ${okCount} sibling session${okCount === 1 ? "" : "s"} (daemon)`);
for (const r of results) render.info(dim(`${r.pubkey.slice(0, 16)}${r.messageId ? dim(r.messageId.slice(0, 8)) : ""}`));
} else {
render.warn(`fanned out: ${okCount}/${results.length} delivered`);
for (const r of results) {
const tag = r.ok ? "✔" : "✘";
render.info(` ${tag} ${r.pubkey.slice(0, 16)}${r.error ? dim(`${r.error}`) : ""}`);
}
}
return;
} catch (e) {
render.err(`--self fan-out failed: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
}
}
// Daemon path — preferred when a long-lived daemon is local. UDS at

View File

@@ -1,25 +1,51 @@
import { whoAmI } from "~/services/auth/facade.js";
import { getSessionInfo } from "~/services/session/resolve.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { bold, clay, dim, yellow } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function whoami(opts: { json?: boolean }): Promise<number> {
const result = await whoAmI();
// 1.32.0+: surface the calling session's identity when whoami is run
// from inside a `claudemesh launch`-spawned shell. Previously the
// command only reported web sign-in + local mesh memberships, and a
// launched session had to dig env vars + parse config.json to figure
// out its own session pubkey.
const session = await getSessionInfo();
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
return result.signed_in || result.local ? EXIT.SUCCESS : EXIT.AUTH_FAILED;
console.log(JSON.stringify({ schema_version: "1.0", ...result, session }, null, 2));
return result.signed_in || result.local || session ? EXIT.SUCCESS : EXIT.AUTH_FAILED;
}
// Show whatever we have. Both the web session and the local mesh
// config are independent surfaces of identity; suppress sections that
// are empty.
if (!result.signed_in && !result.local) {
// Show whatever we have. Web session, local mesh config, and the
// launched-session identity are three independent surfaces.
if (!result.signed_in && !result.local && !session) {
render.err("Not signed in", "Run `claudemesh login` to sign in or `claudemesh <invite>` to join.");
return EXIT.AUTH_FAILED;
}
render.section("whoami");
if (session) {
const sessionPk = session.presence?.sessionPubkey;
const groups = (session.groups ?? []).join(", ") || dim("(none)");
render.kv([
["this session", `${yellow(session.displayName)} on ${bold(session.mesh)}`],
["session id", dim(session.sessionId)],
...(sessionPk
? [["session pubkey", dim(`${sessionPk.slice(0, 16)}… (full: ${sessionPk})`)] as [string, string]]
: []),
...(session.role
? [["role", session.role] as [string, string]]
: []),
["groups", groups],
...(session.cwd ? [["cwd", dim(session.cwd)] as [string, string]] : []),
["pid", String(session.pid)],
]);
render.blank();
}
if (result.signed_in) {
render.kv([
["user", `${bold(result.user!.display_name)} ${dim(`(${result.user!.email})`)}`],

View File

@@ -93,6 +93,10 @@ Peer (resource form, recommended)
Message (resource form)
claudemesh message send <to> <m> send a message (alias: send)
flags: [--priority now|next|low] [--mesh <slug>]
[--self] (allow targeting your own member/session pubkey;
fans out to every sibling session of your member)
[--json] (machine-readable result)
claudemesh message inbox drain pending (alias: inbox)
claudemesh message status <id> delivery status (alias: msg-status)
@@ -388,7 +392,7 @@ async function main(): Promise<void> {
case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; }
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }); break; }
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }); break; }
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
case "state": {
@@ -510,7 +514,7 @@ async function main(): Promise<void> {
case "peer": {
const sub = positionals[0];
const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined };
const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all };
const id = positionals[1] ?? "";
if (sub === "list") { const { runPeers } = await import("~/commands/peers.js"); await runPeers(f); }
else if (sub === "kick") { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(id, { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); }
@@ -525,7 +529,7 @@ async function main(): Promise<void> {
case "message": {
const sub = positionals[0];
if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
else if (sub === "inbox") { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); }
else if (sub === "status") { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
else { console.error("Usage: claudemesh message <send|inbox|status>"); process.exit(EXIT.INVALID_ARGS); }

View File

@@ -28,6 +28,14 @@ export interface ResolvedSession {
cwd?: string;
role?: string;
groups?: string[];
/** 1.32.0+: per-launch presence material lifted from the daemon's
* registry so callers (peer list, whoami) can identify themselves
* in the broker's peer list without re-handshaking a fresh WS. */
presence?: {
sessionPubkey: string;
sessionSecretKey: string;
parentAttestation?: unknown;
};
}
let cached: ResolvedSession | null | undefined = undefined;