diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index f9f10bf..1f024d6 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -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 ` to scope to a single mesh (legacy mode). +- `daemon_started` log line now reports `meshes: [...]` (array) instead + of `mesh: ` (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=` narrows server-side. +- `GET /v1/skills` aggregates similarly. `GET /v1/skills/:name` walks + attached meshes and returns the first match (or `?mesh=` 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 ` 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) diff --git a/apps/cli/package.json b/apps/cli/package.json index 28d6e28..f44fcd4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/src/daemon/drain.ts b/apps/cli/src/daemon/drain.ts index 2aee9f7..12a4a04 100644 --- a/apps/cli/src/daemon/drain.ts +++ b/apps/cli/src/daemon/drain.ts @@ -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; log?: (level: "info" | "warn" | "error", msg: string, meta?: Record) => void; } @@ -100,6 +101,21 @@ async function drainOnce(opts: DrainOptions, log: NonNullable; + /** v1.26.0: per-mesh JoinedMesh entries (carry pubkey + secretKey for crypto). */ + meshConfigs?: Map; /** 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; + meshConfigs?: Map; 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= filter narrows the set server-side. + const all: Array & { 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), 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 & { 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), 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=, 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 | 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 = {}; - 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, }, }; } diff --git a/apps/cli/src/daemon/run.ts b/apps/cli/src/daemon/run.ts index eb05418..43d74da 100644 --- a/apps/cli/src/daemon/run.ts +++ b/apps/cli/src/daemon/run.ts @@ -93,62 +93,65 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { 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 , 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; 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 \` first\n`); releaseSingletonLock(); try { outboxDb.close(); } catch { /* ignore */ } return 2; } else { - process.stderr.write(`multiple meshes joined; pass --mesh \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(); + const meshConfigs = new Map(); + 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 { 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 { 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 { 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 */ } diff --git a/apps/cli/src/services/bridge/daemon-route.ts b/apps/cli/src/services/bridge/daemon-route.ts index 8d227f4..27da1fb 100644 --- a/apps/cli/src/services/bridge/daemon-route.ts +++ b/apps/cli/src/services/bridge/daemon-route.ts @@ -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 } : {}), }, });