diff --git a/apps/cli/package.json b/apps/cli/package.json index a9c6234..5ba8caa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.16.0", + "version": "1.17.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts index 70a131e..c70de46 100644 --- a/apps/cli/src/commands/peers.ts +++ b/apps/cli/src/commands/peers.ts @@ -26,6 +26,10 @@ export interface PeersFlags { 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; displayName: string; status?: string; summary?: string; @@ -34,6 +38,13 @@ interface PeerRecord { channel?: string; model?: string; cwd?: string; + /** 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; } @@ -52,21 +63,53 @@ function projectFields(record: PeerRecord, fields: string[]): Record { + const config = readConfig(); + const joined = config.meshes.find((m) => m.slug === slug); + const selfMemberPubkey = joined?.pubkey ?? null; + // Try warm path first. const bridged = await tryBridge(slug, "peers"); if (bridged && bridged.ok) { - return bridged.result as PeerRecord[]; + const peers = bridged.result as PeerRecord[]; + return peers.map((p) => annotateSelf(p, selfMemberPubkey, null)); } // Cold path — open our own WS. let result: PeerRecord[] = []; await withMesh({ meshSlug: slug }, async (client) => { - const all = await client.listPeers(); - const selfPubkey = client.getSessionPubkey(); - result = (selfPubkey ? all.filter((p) => p.pubkey !== selfPubkey) : all) as unknown as PeerRecord[]; + 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. + */ +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 + ); + return { ...peer, isSelf, isThisSession }; +} + export async function runPeers(flags: PeersFlags): Promise { const config = readConfig(); const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug); @@ -122,7 +165,14 @@ export async function runPeers(flags: PeersFlags): Promise { const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : ""; const summary = p.summary ? dim(` — ${p.summary}`) : ""; const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}…`); - render.info(`${statusDot} ${name}${groups}${metaStr}${pubkeyTag}${summary}`); + const selfTag = p.isThisSession + ? dim(" ") + yellow("(this session)") + : p.isSelf + ? dim(" ") + yellow("(your other session)") + : ""; + render.info( + `${statusDot} ${name}${selfTag}${groups}${metaStr}${pubkeyTag}${summary}`, + ); if (p.cwd) render.info(dim(` cwd: ${p.cwd}`)); } } catch (e) { diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index 878de19..a5f1b33 100644 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -22,6 +22,11 @@ export interface SendFlags { mesh?: string; priority?: string; json?: boolean; + /** Allow sending to a target that resolves to one of the caller's + * own sessions. Off by default — trying to message your own + * sibling session is almost always an accident (copying a hex + * pubkey from `peer list` without realizing it was your own row). */ + self?: boolean; } export async function runSend(flags: SendFlags, to: string, message: string): Promise { @@ -42,6 +47,23 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr flags.mesh ?? (config.meshes.length === 1 ? config.meshes[0]!.slug : null); + // 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) { + const joined = config.meshes.find((m) => m.slug === meshSlug); + if (joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase()) { + render.err( + `Target "${to.slice(0, 16)}…" is your own member pubkey on mesh "${meshSlug}".`, + ); + render.hint( + "Pass --self to message a sibling session of your own member, or pick a different peer's pubkey.", + ); + process.exit(1); + } + } + // Warm path — only when mesh is unambiguous. if (meshSlug) { const bridged = await tryBridge(meshSlug, "send", { to, message, priority }); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 6e4ee2d..0786941 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -318,7 +318,7 @@ async function main(): Promise { // 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 "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json }, positionals[0] ?? "", positionals.slice(1).join(" ")); 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": { const sub = positionals[0]; diff --git a/docs/roadmap.md b/docs/roadmap.md index 0217fc6..3ed17a8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,6 +298,14 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. default returns last 30d). CLI: omitting `--mesh` on each verb routes through the matching aggregator. *Shipped 2026-05-03 in CLI v1.16.0.* +- **v0.5.1 — peer list self-marking + send self-DM guard** — + `peer list` now tags rows from the caller's own member with + `(this session)` or `(your other session)`, so a paste from + `peer list --json` doesn't silently target your own sibling. + `claudemesh send` rejects targets that resolve to the + caller's own member pubkey unless `--self` is passed. Closes + the "DM looped back to my own inbox" footgun reported on + v1.11.0. *Shipped 2026-05-03 in CLI v1.17.0.* - **v0.3.2 — multi-session DM routing + broadcast self-loopback** — fixes two production bugs: (1) replies via `claudemesh send ` rejected with "no connected peer" when the sender's