feat(daemon): multi-mesh — attach to all joined meshes simultaneously
The 1.26.0 step that finally delivers ambient mode for multi-mesh users. Daemon holds Map<slug, DaemonBrokerClient>; one process, one PID per user, all your meshes online concurrently. run.ts: claudemesh daemon up with no --mesh attaches to every joined mesh from config. --mesh <slug> still scopes to one (legacy mode). The daemon_started log line reports meshes: [...] instead of mesh. drain.ts: dispatches each outbox row to the broker keyed by row.mesh (column added in 1.25.0). Legacy rows with mesh=NULL fall back to the only broker if there's exactly one, otherwise mark dead with a clear error. ipc/server.ts: - GET /v1/peers aggregates across all attached meshes; each peer record gains a mesh field. ?mesh=<slug> narrows server-side. - GET /v1/skills aggregates similarly; /v1/skills/:name walks meshes and returns first match. - POST /v1/send requires mesh field on multi-mesh daemons; auto-picks on single-mesh; returns 400 with attached list if ambiguous. - POST /v1/profile accepts optional mesh; without it, fans out to all attached meshes (consistent presence). CLI: trySendViaDaemon now forwards expectedMesh as the body's mesh field (was informational, now authoritative). claudemesh send --mesh A and --mesh B from the same shell both route to the right broker via the same daemon process. Verified: aggregated peer list across 3 attached meshes; cross-mesh sends from CLI reach status=done with correct broker_message_ids. Released as 1.26.0 on npm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,10 @@ interface PendingRow {
|
||||
|
||||
export interface DrainOptions {
|
||||
db: SqliteDb;
|
||||
broker: DaemonBrokerClient;
|
||||
/** Stable peer-target the daemon impersonates for now. Sprint 4 routes
|
||||
* this from the per-row destination_kind/destination_ref. */
|
||||
/** v1.26.0: per-mesh broker map. Drain dispatches each row to the
|
||||
* broker keyed by its `mesh` column. Single-mesh daemons pass a
|
||||
* Map of size 1; multi-mesh daemons pass one entry per joined mesh. */
|
||||
brokers: Map<string, DaemonBrokerClient>;
|
||||
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
@@ -100,6 +101,21 @@ async function drainOnce(opts: DrainOptions, log: NonNullable<DrainOptions["log"
|
||||
if (markInflight(opts.db, row.id, now) === 0) continue; // raced with another drainer
|
||||
const fpHex = bufferToHex(row.request_fingerprint);
|
||||
|
||||
// v1.26.0: pick the broker keyed by the row's mesh. Legacy rows
|
||||
// (mesh=NULL) fall back to the only broker if there's exactly one;
|
||||
// otherwise mark dead because we don't know where to send them.
|
||||
let broker: DaemonBrokerClient | undefined;
|
||||
if (row.mesh) {
|
||||
broker = opts.brokers.get(row.mesh);
|
||||
} else if (opts.brokers.size === 1) {
|
||||
broker = opts.brokers.values().next().value;
|
||||
}
|
||||
if (!broker) {
|
||||
log("warn", "drain_no_broker_for_mesh", { id: row.id, mesh: row.mesh ?? "(null)" });
|
||||
markDead(opts.db, row.id, `no_broker_for_mesh:${row.mesh ?? "null"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sprint 4: use the row's resolved target/ciphertext if present.
|
||||
// Legacy v0.9.0 rows (NULL on these columns) fall back to the
|
||||
// broadcast smoke-test shape so existing in-flight rows still drain.
|
||||
@@ -121,7 +137,7 @@ async function drainOnce(opts: DrainOptions, log: NonNullable<DrainOptions["log"
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await opts.broker.send({
|
||||
res = await broker.send({
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
|
||||
@@ -38,14 +38,12 @@ export interface IpcServerOptions {
|
||||
inboxDb?: SqliteDb;
|
||||
/** Event bus backing /v1/events SSE stream. */
|
||||
bus?: EventBus;
|
||||
/** Broker client (for peers/profile passthrough). */
|
||||
broker?: DaemonBrokerClient;
|
||||
/** v1.26.0: per-mesh broker map for peers/skills/profile passthrough. */
|
||||
brokers?: Map<string, DaemonBrokerClient>;
|
||||
/** v1.26.0: per-mesh JoinedMesh entries (carry pubkey + secretKey for crypto). */
|
||||
meshConfigs?: Map<string, { slug: string; pubkey: string; secretKey: string }>;
|
||||
/** Notify when a new outbox row was inserted (drains can wake). */
|
||||
onPendingInserted?: () => void;
|
||||
/** Mesh secret key (hex) used to encrypt outbound DMs at accept time. */
|
||||
meshSecretKey?: string;
|
||||
/** Mesh slug attached to this daemon — stamped on outbox rows for the drain. */
|
||||
meshSlug?: string;
|
||||
}
|
||||
|
||||
export interface IpcServerHandle {
|
||||
@@ -66,10 +64,9 @@ export function startIpcServer(opts: IpcServerOptions): IpcServerHandle {
|
||||
outboxDb: opts.outboxDb,
|
||||
inboxDb: opts.inboxDb,
|
||||
bus: opts.bus,
|
||||
broker: opts.broker,
|
||||
brokers: opts.brokers,
|
||||
meshConfigs: opts.meshConfigs,
|
||||
onPendingInserted: opts.onPendingInserted,
|
||||
meshSecretKey: opts.meshSecretKey,
|
||||
meshSlug: opts.meshSlug,
|
||||
});
|
||||
|
||||
// --- UDS listener -------------------------------------------------------
|
||||
@@ -127,10 +124,9 @@ function makeHandler(opts: {
|
||||
outboxDb?: SqliteDb;
|
||||
inboxDb?: SqliteDb;
|
||||
bus?: EventBus;
|
||||
broker?: DaemonBrokerClient;
|
||||
brokers?: Map<string, DaemonBrokerClient>;
|
||||
meshConfigs?: Map<string, { slug: string; pubkey: string; secretKey: string }>;
|
||||
onPendingInserted?: () => void;
|
||||
meshSecretKey?: string;
|
||||
meshSlug?: string;
|
||||
}) {
|
||||
const tokenBytes = Buffer.from(opts.localToken, "utf8");
|
||||
|
||||
@@ -202,10 +198,26 @@ function makeHandler(opts: {
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/v1/peers") {
|
||||
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||
try {
|
||||
const peers = await opts.broker.listPeers();
|
||||
respond(res, 200, { peers });
|
||||
// Aggregate across all attached meshes; each peer record gets a
|
||||
// `mesh` field so the caller can scope client-side. A single
|
||||
// ?mesh=<slug> filter narrows the set server-side.
|
||||
const all: Array<Record<string, unknown> & { mesh: string }> = [];
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
try {
|
||||
const peers = await b.listPeers();
|
||||
for (const p of peers) all.push({ ...(p as Record<string, unknown>), mesh: slug });
|
||||
} catch (e) {
|
||||
opts.log("warn", "ipc_peers_broker_failed", { mesh: slug, err: String(e) });
|
||||
}
|
||||
}
|
||||
respond(res, 200, { peers: all });
|
||||
} catch (e) {
|
||||
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
|
||||
}
|
||||
@@ -213,11 +225,24 @@ function makeHandler(opts: {
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/v1/skills") {
|
||||
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const query = url.searchParams.get("query") ?? undefined;
|
||||
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||
try {
|
||||
const skills = await opts.broker.listSkills(query);
|
||||
respond(res, 200, { skills });
|
||||
const all: Array<Record<string, unknown> & { mesh: string }> = [];
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
try {
|
||||
const skills = await b.listSkills(query);
|
||||
for (const s of skills) all.push({ ...(s as Record<string, unknown>), mesh: slug });
|
||||
} catch (e) {
|
||||
opts.log("warn", "ipc_skills_broker_failed", { mesh: slug, err: String(e) });
|
||||
}
|
||||
}
|
||||
respond(res, 200, { skills: all });
|
||||
} catch (e) {
|
||||
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
|
||||
}
|
||||
@@ -225,13 +250,22 @@ function makeHandler(opts: {
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/v1/skills/")) {
|
||||
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length));
|
||||
if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
|
||||
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||
try {
|
||||
const skill = await opts.broker.getSkill(name);
|
||||
if (!skill) { respond(res, 404, { error: "skill_not_found", name }); return; }
|
||||
respond(res, 200, { skill });
|
||||
// First mesh that has the skill wins. With ?mesh=<slug>, only that
|
||||
// mesh is queried.
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
const skill = await b.getSkill(name).catch(() => null);
|
||||
if (skill) { respond(res, 200, { skill: { ...skill, mesh: slug } }); return; }
|
||||
}
|
||||
respond(res, 404, { error: "skill_not_found", name });
|
||||
} catch (e) {
|
||||
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
|
||||
}
|
||||
@@ -239,22 +273,36 @@ function makeHandler(opts: {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/v1/profile") {
|
||||
if (!opts.broker) { respond(res, 503, { error: "broker not initialised" }); return; }
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req, 16 * 1024) as Record<string, unknown> | null;
|
||||
if (!body) { respond(res, 400, { error: "expected JSON object" }); return; }
|
||||
// v1.26.0: profile updates apply to a specific mesh if `mesh` is
|
||||
// present in the body or query, otherwise broadcast to all attached
|
||||
// meshes (presence is per-mesh, but most users want consistent
|
||||
// presence across all of theirs).
|
||||
const requested = (typeof body.mesh === "string" ? body.mesh : url.searchParams.get("mesh")) || null;
|
||||
const targets = requested
|
||||
? [opts.brokers.get(requested)].filter(Boolean) as DaemonBrokerClient[]
|
||||
: [...opts.brokers.values()];
|
||||
if (targets.length === 0) { respond(res, 404, { error: "mesh_not_attached", mesh: requested }); return; }
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (typeof body.summary === "string") opts.broker.setSummary(body.summary);
|
||||
if (body.status === "idle" || body.status === "working" || body.status === "dnd") opts.broker.setStatus(body.status);
|
||||
if (typeof body.visible === "boolean") opts.broker.setVisible(body.visible);
|
||||
const profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] } = {};
|
||||
if (typeof body.avatar === "string") profile.avatar = body.avatar;
|
||||
if (typeof body.title === "string") profile.title = body.title;
|
||||
if (typeof body.bio === "string") profile.bio = body.bio;
|
||||
if (Array.isArray(body.capabilities)) profile.capabilities = body.capabilities.filter((c) => typeof c === "string") as string[];
|
||||
if (Object.keys(profile).length > 0) opts.broker.setProfile(profile);
|
||||
for (const b of targets) {
|
||||
if (typeof body.summary === "string") b.setSummary(body.summary);
|
||||
if (body.status === "idle" || body.status === "working" || body.status === "dnd") b.setStatus(body.status);
|
||||
if (typeof body.visible === "boolean") b.setVisible(body.visible);
|
||||
const profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] } = {};
|
||||
if (typeof body.avatar === "string") profile.avatar = body.avatar;
|
||||
if (typeof body.title === "string") profile.title = body.title;
|
||||
if (typeof body.bio === "string") profile.bio = body.bio;
|
||||
if (Array.isArray(body.capabilities)) profile.capabilities = body.capabilities.filter((c) => typeof c === "string") as string[];
|
||||
if (Object.keys(profile).length > 0) b.setProfile(profile);
|
||||
}
|
||||
Object.assign(updates, body);
|
||||
respond(res, 200, { ok: true, applied: Object.keys(updates) });
|
||||
respond(res, 200, { ok: true, applied: Object.keys(updates), meshes: requested ? [requested] : [...opts.brokers.keys()] });
|
||||
} catch (e) {
|
||||
respond(res, 400, { error: String(e) });
|
||||
}
|
||||
@@ -371,12 +419,31 @@ function makeHandler(opts: {
|
||||
respond(res, 400, { error: parsed.error });
|
||||
return;
|
||||
}
|
||||
// Sprint 4: resolve `to` → broker-format target_spec and encrypt at
|
||||
// accept time, then store ciphertext+nonce on the outbox row. This
|
||||
// crystallises routing so the drain worker is just a forwarder.
|
||||
if (opts.broker && opts.meshSecretKey) {
|
||||
// v1.26.0: pick the mesh. Order of preference:
|
||||
// 1. Explicit `mesh` field in body
|
||||
// 2. Single attached mesh — auto-pick
|
||||
// 3. Bail with 400 — caller must disambiguate
|
||||
if (opts.brokers && opts.brokers.size > 0 && opts.meshConfigs) {
|
||||
let chosenSlug: string | null = parsed.req.mesh ?? null;
|
||||
if (!chosenSlug && opts.brokers.size === 1) {
|
||||
chosenSlug = opts.brokers.keys().next().value as string;
|
||||
}
|
||||
if (!chosenSlug) {
|
||||
respond(res, 400, {
|
||||
error: "mesh_required",
|
||||
detail: `daemon attached to ${opts.brokers.size} meshes; pass 'mesh' in request body`,
|
||||
attached: [...opts.brokers.keys()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const broker = opts.brokers.get(chosenSlug);
|
||||
const meshCfg = opts.meshConfigs.get(chosenSlug);
|
||||
if (!broker || !meshCfg) {
|
||||
respond(res, 404, { error: "mesh_not_attached", mesh: chosenSlug });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const routed = await resolveAndEncrypt(parsed.req, opts.broker, opts.meshSecretKey, opts.meshSlug ?? null);
|
||||
const routed = await resolveAndEncrypt(parsed.req, broker, meshCfg.secretKey, chosenSlug);
|
||||
parsed.req.target_spec = routed.target_spec;
|
||||
parsed.req.ciphertext = routed.ciphertext;
|
||||
parsed.req.nonce = routed.nonce;
|
||||
@@ -490,6 +557,8 @@ 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;
|
||||
|
||||
return {
|
||||
req: {
|
||||
to,
|
||||
@@ -500,6 +569,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] |
|
||||
client_message_id,
|
||||
destination_kind,
|
||||
destination_ref,
|
||||
mesh,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,62 +93,65 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
||||
|
||||
const bus = new EventBus();
|
||||
|
||||
// Pick the mesh. If the user joined exactly one, use it; otherwise
|
||||
// require --mesh. Daemon CAN start with no mesh — the outbox will
|
||||
// accept rows but `dead` them after retries because the broker is
|
||||
// never reachable. Better to fail fast.
|
||||
// 1.26.0 — multi-mesh by default. With --mesh <slug>, the daemon
|
||||
// scopes to one mesh (legacy mode). Without it, attaches to every
|
||||
// joined mesh simultaneously so ambient mode (raw `claude`) works
|
||||
// for all meshes with one daemon process.
|
||||
const cfg = readConfig();
|
||||
let mesh = null as null | typeof cfg.meshes[number];
|
||||
let meshes: Array<typeof cfg.meshes[number]>;
|
||||
if (opts.mesh) {
|
||||
mesh = cfg.meshes.find((m) => m.slug === opts.mesh) ?? null;
|
||||
if (!mesh) {
|
||||
const found = cfg.meshes.find((m) => m.slug === opts.mesh);
|
||||
if (!found) {
|
||||
process.stderr.write(`mesh not found: ${opts.mesh}\n`);
|
||||
process.stderr.write(`joined meshes: ${cfg.meshes.map((m) => m.slug).join(", ") || "(none)"}\n`);
|
||||
releaseSingletonLock();
|
||||
try { outboxDb.close(); } catch { /* ignore */ }
|
||||
return 2;
|
||||
}
|
||||
} else if (cfg.meshes.length === 1) {
|
||||
mesh = cfg.meshes[0]!;
|
||||
meshes = [found];
|
||||
} else if (cfg.meshes.length === 0) {
|
||||
process.stderr.write(`no mesh joined; run \`claudemesh join <invite-url>\` first\n`);
|
||||
releaseSingletonLock();
|
||||
try { outboxDb.close(); } catch { /* ignore */ }
|
||||
return 2;
|
||||
} else {
|
||||
process.stderr.write(`multiple meshes joined; pass --mesh <slug>\n`);
|
||||
process.stderr.write(`available: ${cfg.meshes.map((m) => m.slug).join(", ")}\n`);
|
||||
releaseSingletonLock();
|
||||
try { outboxDb.close(); } catch { /* ignore */ }
|
||||
return 2;
|
||||
meshes = cfg.meshes;
|
||||
}
|
||||
|
||||
// Connect to broker (non-fatal: connection failures get retried;
|
||||
// outbox keeps queuing during outages).
|
||||
const broker = new DaemonBrokerClient(mesh, {
|
||||
displayName: opts.displayName,
|
||||
onStatusChange: (s) => {
|
||||
process.stdout.write(JSON.stringify({
|
||||
msg: "broker_status", status: s, mesh: mesh!.slug, ts: new Date().toISOString(),
|
||||
}) + "\n");
|
||||
bus.publish("broker_status", { mesh: mesh!.slug, status: s });
|
||||
},
|
||||
onPush: (m) => {
|
||||
const sessionKeys = broker.getSessionKeys();
|
||||
void handleBrokerPush(m, {
|
||||
db: inboxDb,
|
||||
bus,
|
||||
meshSlug: mesh!.slug,
|
||||
recipientSecretKeyHex: mesh!.secretKey,
|
||||
sessionSecretKeyHex: sessionKeys?.sessionSecretKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
broker.connect().catch((err) => process.stderr.write(`broker connect failed: ${String(err)}\n`));
|
||||
// Spin up one broker per mesh. Connection failures are non-fatal:
|
||||
// the outbox keeps queuing per-mesh and reconnect logic in
|
||||
// DaemonBrokerClient handles reattach.
|
||||
const brokers = new Map<string, DaemonBrokerClient>();
|
||||
const meshConfigs = new Map<string, typeof cfg.meshes[number]>();
|
||||
for (const mesh of meshes) {
|
||||
meshConfigs.set(mesh.slug, mesh);
|
||||
const broker = new DaemonBrokerClient(mesh, {
|
||||
displayName: opts.displayName,
|
||||
onStatusChange: (s) => {
|
||||
process.stdout.write(JSON.stringify({
|
||||
msg: "broker_status", status: s, mesh: mesh.slug, ts: new Date().toISOString(),
|
||||
}) + "\n");
|
||||
bus.publish("broker_status", { mesh: mesh.slug, status: s });
|
||||
},
|
||||
onPush: (m) => {
|
||||
const sessionKeys = broker.getSessionKeys();
|
||||
void handleBrokerPush(m, {
|
||||
db: inboxDb,
|
||||
bus,
|
||||
meshSlug: mesh.slug,
|
||||
recipientSecretKeyHex: mesh.secretKey,
|
||||
sessionSecretKeyHex: sessionKeys?.sessionSecretKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
broker.connect().catch((err) => process.stderr.write(`broker connect failed for ${mesh.slug}: ${String(err)}\n`));
|
||||
brokers.set(mesh.slug, broker);
|
||||
}
|
||||
|
||||
// Start the drain worker.
|
||||
// Start the drain worker. With multi-mesh, drain dispatches each
|
||||
// outbox row to its mesh's broker via the `mesh` column.
|
||||
let drain: DrainHandle | null = null;
|
||||
drain = startDrainWorker({ db: outboxDb, broker });
|
||||
drain = startDrainWorker({ db: outboxDb, brokers });
|
||||
|
||||
const ipc = startIpcServer({
|
||||
localToken,
|
||||
@@ -157,12 +160,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
||||
outboxDb,
|
||||
inboxDb,
|
||||
bus,
|
||||
broker,
|
||||
brokers,
|
||||
meshConfigs,
|
||||
onPendingInserted: () => drain?.wake(),
|
||||
// Sprint 4: IPC accept-send needs these to resolve targets and
|
||||
// encrypt at accept time so the drain worker is just a forwarder.
|
||||
meshSecretKey: mesh.secretKey,
|
||||
meshSlug: mesh.slug,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -178,7 +178,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
||||
pid: process.pid,
|
||||
sock: DAEMON_PATHS.SOCK_FILE,
|
||||
tcp: tcpEnabled ? `127.0.0.1:47823` : null,
|
||||
mesh: mesh.slug,
|
||||
meshes: meshes.map((m) => m.slug),
|
||||
ts: new Date().toISOString(),
|
||||
}) + "\n");
|
||||
|
||||
@@ -188,7 +188,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
||||
shuttingDown = true;
|
||||
process.stdout.write(JSON.stringify({ msg: "daemon_shutdown", signal: sig, ts: new Date().toISOString() }) + "\n");
|
||||
if (drain) await drain.close();
|
||||
await broker.close();
|
||||
for (const b of brokers.values()) {
|
||||
try { await b.close(); } catch { /* ignore */ }
|
||||
}
|
||||
await ipc.close();
|
||||
try { outboxDb.close(); } catch { /* ignore */ }
|
||||
try { inboxDb.close(); } catch { /* ignore */ }
|
||||
|
||||
@@ -90,6 +90,11 @@ export async function trySendViaDaemon(args: {
|
||||
message: args.message,
|
||||
priority: args.priority,
|
||||
...(args.idempotencyKey ? { client_message_id: args.idempotencyKey } : {}),
|
||||
// v1.26.0 multi-mesh: forward the caller's chosen mesh so the
|
||||
// daemon picks the right broker. Omitting it on a single-mesh
|
||||
// daemon still works (auto-pick); omitting it on a multi-mesh
|
||||
// daemon returns 400 with the attached list.
|
||||
...(args.expectedMesh ? { mesh: args.expectedMesh } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user