feat(daemon): multi-mesh — attach to all joined meshes simultaneously
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-05-04 02:14:43 +01:00
parent 0e3a5babd9
commit cb90f1ca60
6 changed files with 233 additions and 88 deletions

View File

@@ -1,5 +1,57 @@
# Changelog
## 1.26.0 (2026-05-04) — multi-mesh daemon
The daemon now attaches to **all joined meshes simultaneously** by
default. Ambient mode (raw `claude` after `claudemesh install`) finally
delivers what v2.0.0 promised: one daemon process, one PID per user,
all your meshes available concurrently with no manual switching.
### What changed
- `claudemesh daemon up` (no `--mesh` flag) attaches to every joined
mesh. One `DaemonBrokerClient` per mesh, all in one process. Pass
`--mesh <slug>` to scope to a single mesh (legacy mode).
- `daemon_started` log line now reports `meshes: [...]` (array) instead
of `mesh: <slug>` (single).
- Outbox dispatch picks the broker via the `mesh` column added in
1.25.0. Legacy rows (mesh=NULL) fall back to the only broker if
there's exactly one; otherwise mark dead with a clear error.
### IPC surface
- `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. `GET /v1/skills/:name` walks
attached meshes and returns the first match (or `?mesh=<slug>` to
scope).
- `POST /v1/send` requires `mesh` field when the daemon is attached
to multiple meshes; auto-picks the only one in single-mesh mode.
Returns 400 with the attached mesh list if ambiguous.
- `POST /v1/profile` accepts optional `mesh` field — without it,
applies the update to every attached mesh (presence stays
consistent across meshes by default).
### CLI integration
- `claudemesh send --mesh <slug>` forwards the mesh in the daemon
request body. The CLI's `expectedMesh` argument was previously
informational; now it's authoritative for routing.
- `claudemesh peer list` already aggregates because the IPC endpoint
does — no change needed in the verb.
- Verified end-to-end: `claudemesh send --mesh A` and
`claudemesh send --mesh B` from the same CLI invocation both reach
`outbox.status=done` with broker-issued IDs, dispatched to the
correct broker per row.
### What this unlocks
Ambient mode for users with N meshes. Run `claudemesh install` once,
then `claude` from anywhere — channel push, slash commands, and
resources flow through the daemon for every joined mesh
simultaneously. No more "which mesh is the daemon attached to?"
mental overhead.
## 1.25.0 (2026-05-04) — Sprint 4 outbound routing + ambient mode
### Daemon outbound routing (Sprint 4)

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "1.25.0",
"version": "1.26.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",

View File

@@ -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,

View File

@@ -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);
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) opts.broker.setProfile(profile);
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,
},
};
}

View File

@@ -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).
// 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(),
msg: "broker_status", status: s, mesh: mesh.slug, ts: new Date().toISOString(),
}) + "\n");
bus.publish("broker_status", { mesh: mesh!.slug, status: s });
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,
meshSlug: mesh.slug,
recipientSecretKeyHex: mesh.secretKey,
sessionSecretKeyHex: sessionKeys?.sessionSecretKey,
});
},
});
broker.connect().catch((err) => process.stderr.write(`broker connect failed: ${String(err)}\n`));
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 */ }

View File

@@ -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 } : {}),
},
});