fix(cli): messages stop landing on the wrong sessions (wrong-peer noise, bug9)

A reply routinely fanned out to every session of the sender's member,
which is the "peers spread messages to the wrong peers" noise. Three
links fed each other:

- channel meta put the MEMBER key in from_pubkey/from_id, and the skill
  told the model to reply by from_name (repeatable across sessions).
  from_pubkey/from_id are now the sender's session key; the member key
  moves to from_member_pubkey; the skill says reply to from_pubkey.
- a DM to a member key was claimed by that member's daemon socket and
  shown to all its sessions. New daemon/resolve-target.ts is the single
  resolver for /v1/send: a member key maps to its only live session, is
  refused (409 member_target_ambiguous + candidates) when it has several,
  and needs --fanout to reach all of them on purpose. A duplicated
  display name or prefix is ambiguous instead of first-match; control-
  plane rows are never addressable.
- a session MCP whose token didn't resolve fell into the unfiltered SSE
  stream and rendered every sibling's DMs. The MCP now subscribes with
  scope=session and gets no message events without a resolved session.

Also: --self lists only the current mesh and requires a registered
session (it could message its own new row); a DM to a control-plane row
says so instead of the wrong "session pubkeys are ephemeral" hint, and
`peer list --all` tags those rows (bug9).

Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §2-3, §6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-09-26 20:58:50 +01:00
parent 52e14d403b
commit 9075ae05df
14 changed files with 285 additions and 56 deletions

View File

@@ -15,7 +15,7 @@ description: Use when the user asks to send a message to a peer Claude session,
## When you receive a `<channel source="claudemesh">` message
Respond IMMEDIATELY (unless `meta.kind` is `"welcome"` or `"system"` — those are informational, no reply needed). Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `<to>` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it.
Respond IMMEDIATELY (unless `meta.kind` is `"welcome"` or `"system"` — those are informational, no reply needed). Pause your current task, reply via `claudemesh send`, then resume. Read `from_pubkey`, `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `<to>` to the sender's **`from_pubkey`** (its session pubkey) — it reaches exactly that session. Don't reply by `from_name` (several sessions can share a name) or by `from_member_pubkey` (it reaches every session of that member). Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it.
### Channel attributes (everything you need to reply is in the push)
@@ -23,10 +23,10 @@ The `<channel>` interrupt carries these attributes — no lookup needed:
| Attribute | What it is |
|---|---|
| `from_name` | Sender's display name. **Use as `to` in your reply** for DMs. Empty/absent on `kind: "welcome"` and `kind: "system"`. |
| `from_pubkey` | Sender's **session pubkey** (hex, ephemeral per-launch). Since 1.34.0 this is the session pubkey of the launched session that originated the send, NOT the daemon's stable member pubkey — sibling sessions of the same human are correctly disambiguated. |
| `from_name` | Sender's display name, for reading. Empty/absent on `kind: "welcome"` and `kind: "system"`. Not a reply address: names can repeat across sessions. |
| `from_pubkey` | Sender's **session pubkey** (hex). **Use as `to` in your reply.** Since 1.38.0 this is always the originating session's key (before, it carried the member key, so replies fanned out to every sibling session). `from_id` is the same value. |
| `from_session_pubkey` | Same as `from_pubkey` for session-originated DMs. Kept as a separate key so the model never confuses session vs member identity when a control-plane source is involved. |
| `from_member_id` / `from_member_pubkey` | Sender's stable mesh.member id / pubkey. Survives display-name and session rotation. Use to recognize "the same human across multiple Claude Code windows". |
| `from_member_pubkey` | Sender's stable mesh.member pubkey. Survives display-name and session rotation. Use to recognize "the same human across multiple Claude Code windows" — **not** as a reply target (a DM to it needs `--fanout` and reaches all their sessions). |
| `mesh_slug` | Mesh the message arrived on. Pass via `--mesh <slug>` if the parent isn't on the same mesh. |
| `priority` | `now` / `next` / `low`. |
| `message_id` | Server-side id of THIS message. **Pass to `--reply-to <id>` to thread your reply** in topic posts. |
@@ -38,14 +38,14 @@ The `<channel>` interrupt carries these attributes — no lookup needed:
**Reply patterns:**
```bash
# DM → use from_name as the target
claudemesh send "<from_name>" "ack — looking now"
# DM → use from_pubkey (the session) as the target
claudemesh send "<from_pubkey>" "ack — looking now"
# Topic reply → thread it onto the message you got
claudemesh topic post "<topic>" "yep, looks good" --reply-to <message_id>
# When the sender is on a different mesh you've joined
claudemesh send "<from_name>" "..." --mesh "<mesh_slug>"
claudemesh send "<from_pubkey>" "..." --mesh "<mesh_slug>"
```
## Performance model (warm vs cold path)

View File

@@ -13,6 +13,7 @@ export interface ParsedArgs { command: string; positionals: string[]; flags: Rec
*/
const BOOLEAN_FLAGS = new Set([
"self",
"fanout",
"json", // also accepts --json=a,b,c form below
"all",
"yes", "y",

View File

@@ -315,6 +315,9 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
// bug9 (2026-09-26): under --all, mark infrastructure rows — they
// look like peers but don't receive DMs.
if ((p as { peerRole?: string }).peerRole === "control-plane") meta.push("control-plane · no DMs");
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);

View File

@@ -27,6 +27,11 @@ export interface SendFlags {
* sibling session is almost always an accident (copying a hex
* pubkey from `peer list` without realizing it was your own row). */
self?: boolean;
/** 1.38.0: a member-pubkey target reaches every live session of that
* member. Without it such a target must resolve to exactly one session
* (spec 2026-09-26 §3) — the default fan-out was the "messages land on
* the wrong peers" noise. */
fanout?: boolean;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
@@ -109,6 +114,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
render.hint("Check `claudemesh peer list` (add --mesh <slug> to scope).");
process.exit(1);
}
} else if (uniq.length > 1 && isFullPubkey && meshesHit.length === 1
&& !uniq.some((m) => m.pubkey.toLowerCase() === lower)) {
// A full MEMBER pubkey with several live sessions. Fan-out only on
// request; otherwise make the caller pick the session.
meshSlug = meshesHit[0]!;
if (!flags.fanout) {
render.err(`${to.slice(0, 16)}… is a member key with ${uniq.length} live sessions — a DM to it reaches all of them.`);
render.hint(`sessions: ${uniq.map((m) => `${m.displayName} ${m.pubkey.slice(0, 16)}…`).join(", ")}`);
render.hint("Send to one session pubkey, or pass --fanout to reach every session on purpose.");
process.exit(1);
}
} else if (uniq.length > 1) {
if (meshesHit.length > 1 && !meshSlug) {
// Target lives on several meshes — disambiguate by mesh, not prefix.
@@ -166,9 +182,16 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
try {
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
const { getSessionInfo } = await import("~/services/session/resolve.js");
const peers = (await tryListPeersViaDaemon()) ?? [];
const peers = (await tryListPeersViaDaemon(meshSlug)) ?? [];
const session = await getSessionInfo();
const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase();
// 1.38.0: without our own session pubkey we can't exclude ourselves
// — an unregistered (resumed) session would message its own new row.
if (!ownSessionPk) {
render.err("--self needs this session to be registered with the daemon, and it isn't.");
render.hint("Run `claudemesh session reattach` from this session, then retry.");
process.exit(1);
}
const siblings = peers.filter((p) => {
const r = p as { memberPubkey?: string; pubkey?: string; channel?: string; peerRole?: string };
if (!r.pubkey) return false;
@@ -250,24 +273,33 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
const peers = await tryListPeersViaDaemon(meshSlug);
if (peers !== null) {
const lower = to.toLowerCase();
const match = peers.find((p) => {
const r = p as { pubkey?: string; memberPubkey?: string; peerRole?: string };
if (r.peerRole === "control-plane") return false;
const isKey = (p: unknown) => {
const r = p as { pubkey?: string; memberPubkey?: string };
return r.pubkey?.toLowerCase() === lower || r.memberPubkey?.toLowerCase() === lower;
});
};
const isControlPlane = (p: unknown) => (p as { peerRole?: string }).peerRole === "control-plane";
const match = peers.find((p) => !isControlPlane(p) && isKey(p));
// bug9 (2026-09-26): the target is a daemon/control-plane row — it's
// listed under `peer list --all` but is infrastructure, not a
// session. Say so instead of blaming "ephemeral keys".
if (!match && peers.some((p) => isControlPlane(p) && (p as { pubkey?: string }).pubkey?.toLowerCase() === lower)) {
render.err(`${to.slice(0, 16)}… is a control-plane (daemon) row, not a session — it doesn't receive DMs.`);
render.hint("Pick a session pubkey from `claudemesh peer list` (without --all).");
process.exit(1);
}
recipientOnline = !!match;
recipientName = match ? (match as { displayName?: string }).displayName : undefined;
}
}
const offlineHint =
"Session pubkeys are ephemeral — a key from an ended session never reconnects, so the message can't be delivered. Re-fetch a live target with `claudemesh peer list --json`.";
"It's queued and delivers when that session reconnects. If the session was relaunched it has a new key — re-fetch a live target with `claudemesh peer list --json`.";
// Daemon path — preferred when a long-lived daemon is local. UDS at
// ~/.claudemesh/daemon/daemon.sock; ~1ms round-trip; persists outbox
// across CLI invocations so a `claudemesh send` survives a daemon
// crash via the on-disk outbox.
{
const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined });
const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined, fanout: !!flags.fanout });
if (dr !== null) {
if (dr.ok) {
if (flags.json) {
@@ -286,7 +318,12 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
}
// Daemon answered but rejected (409 idempotency, 400 schema). Surface; do not fall through.
if (flags.json) console.log(JSON.stringify({ ok: false, error: dr.error, via: "daemon" }));
else render.err(`send failed (daemon): ${dr.error}`);
else {
render.err(`send failed (daemon): ${dr.error}`);
if (dr.candidates?.length) {
render.hint(`candidates: ${dr.candidates.map((c) => `${c.displayName} ${c.pubkey.slice(0, 16)}…`).join(", ")}`);
}
}
process.exit(1);
}
// dr === null → daemon not running and lifecycle couldn't auto-

View File

@@ -62,9 +62,13 @@ export interface SseFilterOptions {
* When set, system events (peer_join etc.) are filtered to this
* mesh; without it every system event surfaces. */
meshSlug?: string;
/** 1.38.0: the subscriber is a session's MCP. Without a resolved
* session it gets no message events at all (spec 2026-09-26 §2). */
requireSession?: boolean;
}
function shouldDeliver(e: DaemonEvent, f: SseFilterOptions): boolean {
export function shouldDeliver(e: DaemonEvent, f: SseFilterOptions): boolean {
if (f.requireSession && !f.sessionPubkey && e.kind === "message") return false;
// No filter set → legacy behavior: deliver everything (used by
// diagnostic tooling like `claudemesh daemon events`).
if (!f.sessionPubkey && !f.memberPubkey && !f.meshSlug) return true;

View File

@@ -45,6 +45,9 @@ export interface SendRequest {
* broker fan-out attributes the push to the session pubkey instead
* of the daemon's member pubkey. */
sender_session_pubkey?: string;
/** 1.38.0: the caller explicitly wants a member-key DM delivered to every
* live session of that member (spec 2026-09-26 §3). Off by default. */
fanout?: boolean;
}
export type AcceptOutcome =

View File

@@ -4,6 +4,7 @@ import { timingSafeEqual } from "node:crypto";
import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js";
import type { SqliteDb } from "../db/sqlite.js";
import { resolveDirectTarget } from "../resolve-target.js";
import { acceptSend, type SendRequest } from "./handlers/send.js";
import { listInbox, deleteInboxRow, flushInbox, markInboxSeen } from "../db/inbox.js";
import { listOutbox, requeueDeadOrPending, type OutboxStatus } from "../db/outbox.js";
@@ -420,8 +421,12 @@ function makeHandler(opts: {
// session token (`claudemesh daemon events`) get the unfiltered
// legacy stream. The bus itself stays single-shot; demux lives
// entirely at the SSE bind layer (events.ts shouldDeliver).
const filter: Record<string, string> = {};
const filter: Record<string, string | boolean> = {};
if (session?.presence?.sessionPubkey) filter.sessionPubkey = session.presence.sessionPubkey;
// 1.38.0: an MCP (scope=session) whose token doesn't resolve — a
// resumed session that lost its registration — used to fall into
// the unfiltered legacy stream and render every sibling's DMs.
if (url.searchParams.get("scope") === "session") filter.requireSession = true;
if (session?.mesh) {
filter.meshSlug = session.mesh;
const meshCfg = opts.meshConfigs?.get(session.mesh);
@@ -932,6 +937,10 @@ function makeHandler(opts: {
parsed.req.sender_session_pubkey = senderSessionPubkey;
}
} catch (e) {
if (e instanceof RouteError) {
respond(res, e.code === "not_found" ? 404 : 409, { error: e.code, detail: e.detail, candidates: e.candidates });
return;
}
respond(res, 502, { error: "route_failed", detail: String(e) });
return;
}
@@ -1041,6 +1050,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] |
const reply_to_id = typeof b.reply_to_id === "string" ? b.reply_to_id : undefined;
const mesh = typeof b.mesh === "string" ? b.mesh.trim() : undefined;
const fanout = b.fanout === true;
return {
req: {
@@ -1053,6 +1063,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] |
destination_kind,
destination_ref,
mesh,
...(fanout ? { fanout } : {}),
},
};
}
@@ -1094,41 +1105,29 @@ async function resolveAndEncrypt(
return { target_spec: to, ciphertext, nonce, mesh: meshSlug ?? "" };
}
// 64-char hex pubkey → DM directly. Encrypt with the daemon's member
// secret: recipient decrypts using THEIR session pubkey's matching
// secret on their session-WS, so the sender side just needs any
// private key whose public counterpart is known to the recipient as
// "the sender". Member key is the stable choice and is what the
// recipient already trusts via mesh membership.
if (/^[0-9a-f]{64}$/i.test(to)) {
const env = await encryptDirect(req.message, to, meshSecretKey);
return { target_spec: to, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" };
}
// Hex prefix (16+ chars but <64) → resolve via peer list prefix match.
// Matches the ergonomics of `claudemesh peer list` which shows 16-char
// prefixes, so users naturally paste prefixes back.
// Direct target (full pubkey, prefix, or display name). 1.38.0 (spec
// 2026-09-26 §3): one resolver for every caller — a member key is mapped
// to its single live session (or refused when it has several), and a
// name/prefix must match exactly one addressable peer. Before, a member
// key went straight out and the recipient's member-WS showed it to every
// sibling session; a duplicated name silently picked the first match.
const peers = await broker.listPeers().catch(() => []);
if (/^[0-9a-f]{16,63}$/i.test(to)) {
const matches = peers.filter((p) =>
p.pubkey.toLowerCase().startsWith(to.toLowerCase()) ||
(p.memberPubkey ?? "").toLowerCase().startsWith(to.toLowerCase()),
);
if (matches.length === 0) throw new Error(`no peer matching prefix "${to}"`);
if (matches.length > 1) throw new Error(`prefix "${to}" is ambiguous (${matches.length} matches)`);
const recipient = matches[0]!.pubkey;
const env = await encryptDirect(req.message, recipient, meshSecretKey);
return { target_spec: recipient, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" };
}
// Otherwise — display name.
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) throw new Error(`peer "${to}" not found`);
const recipient = match.pubkey;
const resolved = resolveDirectTarget(to, peers, { fanout: req.fanout === true });
if (!resolved.ok) throw new RouteError(resolved.code, resolved.detail, resolved.candidates);
const recipient = resolved.pubkey;
const env = await encryptDirect(req.message, recipient, meshSecretKey);
return { target_spec: recipient, ciphertext: env.ciphertext, nonce: env.nonce, mesh: meshSlug ?? "" };
}
/** A send target that can't be resolved unambiguously — a 4xx, not a 502. */
export class RouteError extends Error {
constructor(
readonly code: string,
readonly detail: string,
readonly candidates: Array<{ pubkey: string; displayName: string }>,
) { super(detail); }
}
function respond(res: ServerResponse, status: number, body: unknown) {
const json = JSON.stringify(body);
res.statusCode = status;

View File

@@ -0,0 +1,77 @@
/**
* Direct-message target resolution — one source of truth for every
* caller of the daemon's /v1/send (CLI, MCP tools, scripts).
*
* Spec 2026-09-26 §3. The noisy failure it prevents: a DM addressed to a
* MEMBER pubkey is claimed by any connection of that member — the daemon's
* member-WS then shows it to EVERY session of that member, and each of
* them reacts. So a member key is resolved to its single live session, or
* refused when there are several (unless the caller asked for --fanout).
* Names and prefixes must match exactly one addressable peer.
*/
export interface RosterPeer {
pubkey: string;
memberPubkey?: string;
displayName: string;
/** Broker classification; `peerRole` on some IPC shapes. */
role?: string;
peerRole?: string;
}
export type ResolveResult =
| { ok: true; pubkey: string; note?: "member_to_session" | "member_fanout" | "offline_member_or_session" }
| { ok: false; code: "not_found" | "ambiguous" | "member_target_ambiguous"; detail: string; candidates: Array<{ pubkey: string; displayName: string }> };
function isControlPlane(p: RosterPeer): boolean {
return (p.role ?? p.peerRole) === "control-plane";
}
function uniqByPubkey(peers: RosterPeer[]): RosterPeer[] {
const seen = new Map<string, RosterPeer>();
for (const p of peers) if (!seen.has(p.pubkey.toLowerCase())) seen.set(p.pubkey.toLowerCase(), p);
return [...seen.values()];
}
const cand = (ps: RosterPeer[]) => ps.map((p) => ({ pubkey: p.pubkey, displayName: p.displayName }));
export function resolveDirectTarget(
toRaw: string,
roster: RosterPeer[],
opts: { fanout?: boolean } = {},
): ResolveResult {
const to = toRaw.trim().toLowerCase();
const peers = roster.filter((p) => p.pubkey && !isControlPlane(p));
if (/^[0-9a-f]{64}$/.test(to)) {
if (peers.some((p) => p.pubkey.toLowerCase() === to)) return { ok: true, pubkey: to };
const sessions = uniqByPubkey(peers.filter((p) => (p.memberPubkey ?? "").toLowerCase() === to));
if (sessions.length === 1) return { ok: true, pubkey: sessions[0]!.pubkey.toLowerCase(), note: "member_to_session" };
if (sessions.length > 1) {
if (opts.fanout) return { ok: true, pubkey: to, note: "member_fanout" };
return {
ok: false,
code: "member_target_ambiguous",
detail: `${to.slice(0, 16)}… is a member key with ${sessions.length} live sessions — pick one session pubkey, or pass --fanout to reach all of them`,
candidates: cand(sessions),
};
}
// Unknown to the live roster: an offline peer. Keep store-and-forward.
return { ok: true, pubkey: to, note: "offline_member_or_session" };
}
if (/^[0-9a-f]{4,63}$/.test(to)) {
const bySession = uniqByPubkey(peers.filter((p) => p.pubkey.toLowerCase().startsWith(to)));
const matches = bySession.length > 0
? bySession
: uniqByPubkey(peers.filter((p) => (p.memberPubkey ?? "").toLowerCase().startsWith(to)));
if (matches.length === 1) return { ok: true, pubkey: matches[0]!.pubkey.toLowerCase() };
if (matches.length === 0) return { ok: false, code: "not_found", detail: `no peer matching prefix "${toRaw}"`, candidates: [] };
return { ok: false, code: "ambiguous", detail: `prefix "${toRaw}" matches ${matches.length} peers`, candidates: cand(matches) };
}
const byName = uniqByPubkey(peers.filter((p) => p.displayName.toLowerCase() === to));
if (byName.length === 1) return { ok: true, pubkey: byName[0]!.pubkey.toLowerCase() };
if (byName.length === 0) return { ok: false, code: "not_found", detail: `peer "${toRaw}" not found`, candidates: [] };
return { ok: false, code: "ambiguous", detail: `name "${toRaw}" matches ${byName.length} sessions — use a session pubkey`, candidates: cand(byName) };
}

View File

@@ -403,7 +403,7 @@ async function main(): Promise<void> {
// Messaging
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 "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, fanout: !!flags.fanout }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": {
const sub = positionals[0];
if (sub === "flush") {
@@ -571,7 +571,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, self: !!flags.self }, 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, fanout: !!flags.fanout }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
else if (sub === "inbox") {
const sub2 = positionals[1];
if (sub2 === "flush") {

View File

@@ -218,7 +218,7 @@ export function subscribeEvents(
// receives every event.
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`;
const thisReq = httpRequest({ socketPath, path: "/v1/events", method: "GET", headers });
const thisReq = httpRequest({ socketPath, path: "/v1/events?scope=session", method: "GET", headers });
req = thisReq;
let buffer = "";
@@ -439,6 +439,11 @@ export async function startMcpServer(): Promise<void> {
if (ev.kind === "message") {
const d = ev.data;
const fromName = String(d.sender_name ?? "unknown");
// 1.38.0 (spec 2026-09-26 §3): from_pubkey / from_id are the sender's
// SESSION pubkey — the only key a reply reaches exactly one session
// with. They used to carry the member key, so replying fanned out to
// every session of that member. The member key moves to its own field.
const fromSession = String(d.sender_pubkey ?? "");
const fromMember = String(d.sender_member_pubkey ?? d.sender_pubkey ?? "");
const rawBody = typeof d.body === "string" ? d.body : null;
// 1.38.0 (spec 2026-09-26 §1): second guard — never hand Claude
@@ -458,9 +463,10 @@ export async function startMcpServer(): Promise<void> {
params: {
content,
meta: {
from_id: fromMember,
from_pubkey: fromMember,
from_session_pubkey: String(d.sender_pubkey ?? ""),
from_id: fromSession || fromMember,
from_pubkey: fromSession || fromMember,
from_session_pubkey: fromSession,
from_member_pubkey: fromMember,
from_name: fromName,
mesh_slug: String(d.mesh ?? ""),
priority,

View File

@@ -308,7 +308,7 @@ export type DaemonSendOk = {
duplicate?: boolean;
status?: "queued" | "inflight";
};
export type DaemonSendErr = { ok: false; error: string };
export type DaemonSendErr = { ok: false; error: string; candidates?: Array<{ pubkey: string; displayName: string }> };
export type DaemonSendResult = DaemonSendOk | DaemonSendErr;
export async function trySendViaDaemon(args: {
@@ -322,6 +322,8 @@ export async function trySendViaDaemon(args: {
* so for v0.9.0 this is informational; the caller already picked the
* right mesh by either flag or single-mesh-default. */
expectedMesh?: string;
/** 1.38.0: deliver a member-key DM to every live session of that member. */
fanout?: boolean;
}): Promise<DaemonSendResult | null> {
if (!(await daemonReachable())) return null;
@@ -332,6 +334,8 @@ export async function trySendViaDaemon(args: {
broker_message_id?: string;
duplicate?: boolean;
error?: string;
detail?: string;
candidates?: Array<{ pubkey: string; displayName: string }>;
}>({
method: "POST",
path: "/v1/send",
@@ -346,6 +350,7 @@ export async function trySendViaDaemon(args: {
// daemon still works (auto-pick); omitting it on a multi-mesh
// daemon returns 400 with the attached list.
...(args.expectedMesh ? { mesh: args.expectedMesh } : {}),
...(args.fanout ? { fanout: true } : {}),
},
});
@@ -357,7 +362,11 @@ export async function trySendViaDaemon(args: {
status: res.body.status,
};
}
return { ok: false, error: res.body.error ?? `daemon http ${res.status}` };
return {
ok: false,
error: res.body.detail ?? res.body.error ?? `daemon http ${res.status}`,
...(res.body.candidates?.length ? { candidates: res.body.candidates } : {}),
};
} catch (err) {
// Connection errors → daemon went away mid-call. Treat as "not present"
// so the caller falls back rather than failing.

View File

@@ -0,0 +1,67 @@
/**
* Spec 2026-09-26 §3: the "messages land on the wrong peers" noise.
* A member-key DM reached every session of that member; a duplicated
* display name silently picked the first row; control-plane rows were
* addressable. One resolver now decides for every /v1/send caller.
*/
import { describe, expect, it } from "vitest";
import { resolveDirectTarget, type RosterPeer } from "~/daemon/resolve-target.js";
const M1 = "a".repeat(64);
const M2 = "b".repeat(64);
const S1 = "1".repeat(64);
const S2 = "2".repeat(64);
const S3 = "3".repeat(64);
const roster: RosterPeer[] = [
{ pubkey: M1, memberPubkey: M1, displayName: "daemon", role: "control-plane" },
{ pubkey: S1, memberPubkey: M1, displayName: "Intra-Back" },
{ pubkey: S2, memberPubkey: M1, displayName: "Intra-Back" },
{ pubkey: S3, memberPubkey: M2, displayName: "portal-kit" },
];
describe("resolveDirectTarget", () => {
it("a session pubkey resolves to itself", () => {
expect(resolveDirectTarget(S1, roster)).toMatchObject({ ok: true, pubkey: S1 });
});
it("a member key with one live session resolves to that session", () => {
expect(resolveDirectTarget(M2, roster)).toMatchObject({ ok: true, pubkey: S3, note: "member_to_session" });
});
it("a member key with several sessions is refused, listing them", () => {
const r = resolveDirectTarget(M1, roster);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe("member_target_ambiguous");
expect(r.candidates.map((c) => c.pubkey).sort()).toEqual([S1, S2]);
}
});
it("--fanout keeps the member key on purpose", () => {
expect(resolveDirectTarget(M1, roster, { fanout: true })).toMatchObject({ ok: true, pubkey: M1, note: "member_fanout" });
});
it("an unknown full key stays (offline store-and-forward)", () => {
const k = "f".repeat(64);
expect(resolveDirectTarget(k, roster)).toMatchObject({ ok: true, pubkey: k });
});
it("a duplicated display name is ambiguous, not first-match", () => {
expect(resolveDirectTarget("intra-back", roster)).toMatchObject({ ok: false, code: "ambiguous" });
expect(resolveDirectTarget("portal-kit", roster)).toMatchObject({ ok: true, pubkey: S3 });
});
it("control-plane rows are never addressable by name", () => {
expect(resolveDirectTarget("daemon", roster)).toMatchObject({ ok: false, code: "not_found" });
});
it("a prefix prefers session keys and must be unique", () => {
expect(resolveDirectTarget("3333", roster)).toMatchObject({ ok: true, pubkey: S3 });
// member-key prefix of a member with two sessions → ambiguous
expect(resolveDirectTarget("aaaa", roster)).toMatchObject({ ok: false, code: "ambiguous" });
expect(resolveDirectTarget("bbbb", roster)).toMatchObject({ ok: true, pubkey: S3 });
});
});

View File

@@ -0,0 +1,23 @@
/** Spec 2026-09-26 §2: a session MCP whose token doesn't resolve gets no
* message events — it used to receive every sibling session's DMs. */
import { describe, expect, it } from "vitest";
import { shouldDeliver, type DaemonEvent } from "~/daemon/events.js";
const msg = (data: Record<string, unknown>): DaemonEvent => ({ kind: "message", ts: "", data });
describe("shouldDeliver: requireSession", () => {
it("an unresolved session MCP gets no messages", () => {
expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), { requireSession: true })).toBe(false);
expect(shouldDeliver(msg({}), { requireSession: true })).toBe(false);
});
it("diagnostic subscribers (no filter) keep the full stream", () => {
expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), {})).toBe(true);
});
it("a resolved session still gets its own messages only", () => {
const f = { requireSession: true, sessionPubkey: "ab" };
expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "ab" }), f)).toBe(true);
expect(shouldDeliver(msg({ recipient_kind: "session", recipient_pubkey: "cd" }), f)).toBe(false);
});
});

View File

@@ -37,7 +37,7 @@ interface Fake {
function startFakeDaemon(opts: { keepalive?: boolean } = {}): Promise<Fake> {
const fake: Fake = { connections: 0, responses: [] };
server = createServer((req, res) => {
if (req.url !== "/v1/events") { res.statusCode = 404; res.end(); return; }
if ((req.url ?? "").split("?")[0] !== "/v1/events") { res.statusCode = 404; res.end(); return; }
fake.connections++;
fake.responses.push(res);
res.statusCode = 200;