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:
@@ -1,5 +1,57 @@
|
|||||||
# Changelog
|
# 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
|
## 1.25.0 (2026-05-04) — Sprint 4 outbound routing + ambient mode
|
||||||
|
|
||||||
### Daemon outbound routing (Sprint 4)
|
### Daemon outbound routing (Sprint 4)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ interface PendingRow {
|
|||||||
|
|
||||||
export interface DrainOptions {
|
export interface DrainOptions {
|
||||||
db: SqliteDb;
|
db: SqliteDb;
|
||||||
broker: DaemonBrokerClient;
|
/** v1.26.0: per-mesh broker map. Drain dispatches each row to the
|
||||||
/** Stable peer-target the daemon impersonates for now. Sprint 4 routes
|
* broker keyed by its `mesh` column. Single-mesh daemons pass a
|
||||||
* this from the per-row destination_kind/destination_ref. */
|
* 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;
|
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
|
if (markInflight(opts.db, row.id, now) === 0) continue; // raced with another drainer
|
||||||
const fpHex = bufferToHex(row.request_fingerprint);
|
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.
|
// Sprint 4: use the row's resolved target/ciphertext if present.
|
||||||
// Legacy v0.9.0 rows (NULL on these columns) fall back to the
|
// Legacy v0.9.0 rows (NULL on these columns) fall back to the
|
||||||
// broadcast smoke-test shape so existing in-flight rows still drain.
|
// 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;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await opts.broker.send({
|
res = await broker.send({
|
||||||
targetSpec,
|
targetSpec,
|
||||||
priority,
|
priority,
|
||||||
nonce,
|
nonce,
|
||||||
|
|||||||
@@ -38,14 +38,12 @@ export interface IpcServerOptions {
|
|||||||
inboxDb?: SqliteDb;
|
inboxDb?: SqliteDb;
|
||||||
/** Event bus backing /v1/events SSE stream. */
|
/** Event bus backing /v1/events SSE stream. */
|
||||||
bus?: EventBus;
|
bus?: EventBus;
|
||||||
/** Broker client (for peers/profile passthrough). */
|
/** v1.26.0: per-mesh broker map for peers/skills/profile passthrough. */
|
||||||
broker?: DaemonBrokerClient;
|
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). */
|
/** Notify when a new outbox row was inserted (drains can wake). */
|
||||||
onPendingInserted?: () => void;
|
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 {
|
export interface IpcServerHandle {
|
||||||
@@ -66,10 +64,9 @@ export function startIpcServer(opts: IpcServerOptions): IpcServerHandle {
|
|||||||
outboxDb: opts.outboxDb,
|
outboxDb: opts.outboxDb,
|
||||||
inboxDb: opts.inboxDb,
|
inboxDb: opts.inboxDb,
|
||||||
bus: opts.bus,
|
bus: opts.bus,
|
||||||
broker: opts.broker,
|
brokers: opts.brokers,
|
||||||
|
meshConfigs: opts.meshConfigs,
|
||||||
onPendingInserted: opts.onPendingInserted,
|
onPendingInserted: opts.onPendingInserted,
|
||||||
meshSecretKey: opts.meshSecretKey,
|
|
||||||
meshSlug: opts.meshSlug,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- UDS listener -------------------------------------------------------
|
// --- UDS listener -------------------------------------------------------
|
||||||
@@ -127,10 +124,9 @@ function makeHandler(opts: {
|
|||||||
outboxDb?: SqliteDb;
|
outboxDb?: SqliteDb;
|
||||||
inboxDb?: SqliteDb;
|
inboxDb?: SqliteDb;
|
||||||
bus?: EventBus;
|
bus?: EventBus;
|
||||||
broker?: DaemonBrokerClient;
|
brokers?: Map<string, DaemonBrokerClient>;
|
||||||
|
meshConfigs?: Map<string, { slug: string; pubkey: string; secretKey: string }>;
|
||||||
onPendingInserted?: () => void;
|
onPendingInserted?: () => void;
|
||||||
meshSecretKey?: string;
|
|
||||||
meshSlug?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const tokenBytes = Buffer.from(opts.localToken, "utf8");
|
const tokenBytes = Buffer.from(opts.localToken, "utf8");
|
||||||
|
|
||||||
@@ -202,10 +198,26 @@ function makeHandler(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/v1/peers") {
|
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 {
|
try {
|
||||||
const peers = await opts.broker.listPeers();
|
// Aggregate across all attached meshes; each peer record gets a
|
||||||
respond(res, 200, { peers });
|
// `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) {
|
} catch (e) {
|
||||||
respond(res, 502, { error: "broker_unreachable", detail: String(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 (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 query = url.searchParams.get("query") ?? undefined;
|
||||||
|
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||||
try {
|
try {
|
||||||
const skills = await opts.broker.listSkills(query);
|
const all: Array<Record<string, unknown> & { mesh: string }> = [];
|
||||||
respond(res, 200, { skills });
|
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) {
|
} catch (e) {
|
||||||
respond(res, 502, { error: "broker_unreachable", detail: String(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 (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));
|
const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length));
|
||||||
if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
|
if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
|
||||||
|
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||||
try {
|
try {
|
||||||
const skill = await opts.broker.getSkill(name);
|
// First mesh that has the skill wins. With ?mesh=<slug>, only that
|
||||||
if (!skill) { respond(res, 404, { error: "skill_not_found", name }); return; }
|
// mesh is queried.
|
||||||
respond(res, 200, { skill });
|
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) {
|
} catch (e) {
|
||||||
respond(res, 502, { error: "broker_unreachable", detail: String(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 (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 {
|
try {
|
||||||
const body = await readJsonBody(req, 16 * 1024) as Record<string, unknown> | null;
|
const body = await readJsonBody(req, 16 * 1024) as Record<string, unknown> | null;
|
||||||
if (!body) { respond(res, 400, { error: "expected JSON object" }); return; }
|
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> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (typeof body.summary === "string") opts.broker.setSummary(body.summary);
|
for (const b of targets) {
|
||||||
if (body.status === "idle" || body.status === "working" || body.status === "dnd") opts.broker.setStatus(body.status);
|
if (typeof body.summary === "string") b.setSummary(body.summary);
|
||||||
if (typeof body.visible === "boolean") opts.broker.setVisible(body.visible);
|
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[] } = {};
|
const profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] } = {};
|
||||||
if (typeof body.avatar === "string") profile.avatar = body.avatar;
|
if (typeof body.avatar === "string") profile.avatar = body.avatar;
|
||||||
if (typeof body.title === "string") profile.title = body.title;
|
if (typeof body.title === "string") profile.title = body.title;
|
||||||
if (typeof body.bio === "string") profile.bio = body.bio;
|
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 (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);
|
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) {
|
} catch (e) {
|
||||||
respond(res, 400, { error: String(e) });
|
respond(res, 400, { error: String(e) });
|
||||||
}
|
}
|
||||||
@@ -371,12 +419,31 @@ function makeHandler(opts: {
|
|||||||
respond(res, 400, { error: parsed.error });
|
respond(res, 400, { error: parsed.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sprint 4: resolve `to` → broker-format target_spec and encrypt at
|
// v1.26.0: pick the mesh. Order of preference:
|
||||||
// accept time, then store ciphertext+nonce on the outbox row. This
|
// 1. Explicit `mesh` field in body
|
||||||
// crystallises routing so the drain worker is just a forwarder.
|
// 2. Single attached mesh — auto-pick
|
||||||
if (opts.broker && opts.meshSecretKey) {
|
// 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 {
|
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.target_spec = routed.target_spec;
|
||||||
parsed.req.ciphertext = routed.ciphertext;
|
parsed.req.ciphertext = routed.ciphertext;
|
||||||
parsed.req.nonce = routed.nonce;
|
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 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 {
|
return {
|
||||||
req: {
|
req: {
|
||||||
to,
|
to,
|
||||||
@@ -500,6 +569,7 @@ function parseSendRequest(body: unknown, idempotencyHeader: string | string[] |
|
|||||||
client_message_id,
|
client_message_id,
|
||||||
destination_kind,
|
destination_kind,
|
||||||
destination_ref,
|
destination_ref,
|
||||||
|
mesh,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,62 +93,65 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
|
|
||||||
const bus = new EventBus();
|
const bus = new EventBus();
|
||||||
|
|
||||||
// Pick the mesh. If the user joined exactly one, use it; otherwise
|
// 1.26.0 — multi-mesh by default. With --mesh <slug>, the daemon
|
||||||
// require --mesh. Daemon CAN start with no mesh — the outbox will
|
// scopes to one mesh (legacy mode). Without it, attaches to every
|
||||||
// accept rows but `dead` them after retries because the broker is
|
// joined mesh simultaneously so ambient mode (raw `claude`) works
|
||||||
// never reachable. Better to fail fast.
|
// for all meshes with one daemon process.
|
||||||
const cfg = readConfig();
|
const cfg = readConfig();
|
||||||
let mesh = null as null | typeof cfg.meshes[number];
|
let meshes: Array<typeof cfg.meshes[number]>;
|
||||||
if (opts.mesh) {
|
if (opts.mesh) {
|
||||||
mesh = cfg.meshes.find((m) => m.slug === opts.mesh) ?? null;
|
const found = cfg.meshes.find((m) => m.slug === opts.mesh);
|
||||||
if (!mesh) {
|
if (!found) {
|
||||||
process.stderr.write(`mesh not found: ${opts.mesh}\n`);
|
process.stderr.write(`mesh not found: ${opts.mesh}\n`);
|
||||||
process.stderr.write(`joined meshes: ${cfg.meshes.map((m) => m.slug).join(", ") || "(none)"}\n`);
|
process.stderr.write(`joined meshes: ${cfg.meshes.map((m) => m.slug).join(", ") || "(none)"}\n`);
|
||||||
releaseSingletonLock();
|
releaseSingletonLock();
|
||||||
try { outboxDb.close(); } catch { /* ignore */ }
|
try { outboxDb.close(); } catch { /* ignore */ }
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
} else if (cfg.meshes.length === 1) {
|
meshes = [found];
|
||||||
mesh = cfg.meshes[0]!;
|
|
||||||
} else if (cfg.meshes.length === 0) {
|
} else if (cfg.meshes.length === 0) {
|
||||||
process.stderr.write(`no mesh joined; run \`claudemesh join <invite-url>\` first\n`);
|
process.stderr.write(`no mesh joined; run \`claudemesh join <invite-url>\` first\n`);
|
||||||
releaseSingletonLock();
|
releaseSingletonLock();
|
||||||
try { outboxDb.close(); } catch { /* ignore */ }
|
try { outboxDb.close(); } catch { /* ignore */ }
|
||||||
return 2;
|
return 2;
|
||||||
} else {
|
} else {
|
||||||
process.stderr.write(`multiple meshes joined; pass --mesh <slug>\n`);
|
meshes = cfg.meshes;
|
||||||
process.stderr.write(`available: ${cfg.meshes.map((m) => m.slug).join(", ")}\n`);
|
|
||||||
releaseSingletonLock();
|
|
||||||
try { outboxDb.close(); } catch { /* ignore */ }
|
|
||||||
return 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to broker (non-fatal: connection failures get retried;
|
// Spin up one broker per mesh. Connection failures are non-fatal:
|
||||||
// outbox keeps queuing during outages).
|
// 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, {
|
const broker = new DaemonBrokerClient(mesh, {
|
||||||
displayName: opts.displayName,
|
displayName: opts.displayName,
|
||||||
onStatusChange: (s) => {
|
onStatusChange: (s) => {
|
||||||
process.stdout.write(JSON.stringify({
|
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");
|
}) + "\n");
|
||||||
bus.publish("broker_status", { mesh: mesh!.slug, status: s });
|
bus.publish("broker_status", { mesh: mesh.slug, status: s });
|
||||||
},
|
},
|
||||||
onPush: (m) => {
|
onPush: (m) => {
|
||||||
const sessionKeys = broker.getSessionKeys();
|
const sessionKeys = broker.getSessionKeys();
|
||||||
void handleBrokerPush(m, {
|
void handleBrokerPush(m, {
|
||||||
db: inboxDb,
|
db: inboxDb,
|
||||||
bus,
|
bus,
|
||||||
meshSlug: mesh!.slug,
|
meshSlug: mesh.slug,
|
||||||
recipientSecretKeyHex: mesh!.secretKey,
|
recipientSecretKeyHex: mesh.secretKey,
|
||||||
sessionSecretKeyHex: sessionKeys?.sessionSecretKey,
|
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;
|
let drain: DrainHandle | null = null;
|
||||||
drain = startDrainWorker({ db: outboxDb, broker });
|
drain = startDrainWorker({ db: outboxDb, brokers });
|
||||||
|
|
||||||
const ipc = startIpcServer({
|
const ipc = startIpcServer({
|
||||||
localToken,
|
localToken,
|
||||||
@@ -157,12 +160,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
outboxDb,
|
outboxDb,
|
||||||
inboxDb,
|
inboxDb,
|
||||||
bus,
|
bus,
|
||||||
broker,
|
brokers,
|
||||||
|
meshConfigs,
|
||||||
onPendingInserted: () => drain?.wake(),
|
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 {
|
try {
|
||||||
@@ -178,7 +178,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
sock: DAEMON_PATHS.SOCK_FILE,
|
sock: DAEMON_PATHS.SOCK_FILE,
|
||||||
tcp: tcpEnabled ? `127.0.0.1:47823` : null,
|
tcp: tcpEnabled ? `127.0.0.1:47823` : null,
|
||||||
mesh: mesh.slug,
|
meshes: meshes.map((m) => m.slug),
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
}) + "\n");
|
}) + "\n");
|
||||||
|
|
||||||
@@ -188,7 +188,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
process.stdout.write(JSON.stringify({ msg: "daemon_shutdown", signal: sig, ts: new Date().toISOString() }) + "\n");
|
process.stdout.write(JSON.stringify({ msg: "daemon_shutdown", signal: sig, ts: new Date().toISOString() }) + "\n");
|
||||||
if (drain) await drain.close();
|
if (drain) await drain.close();
|
||||||
await broker.close();
|
for (const b of brokers.values()) {
|
||||||
|
try { await b.close(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
await ipc.close();
|
await ipc.close();
|
||||||
try { outboxDb.close(); } catch { /* ignore */ }
|
try { outboxDb.close(); } catch { /* ignore */ }
|
||||||
try { inboxDb.close(); } catch { /* ignore */ }
|
try { inboxDb.close(); } catch { /* ignore */ }
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export async function trySendViaDaemon(args: {
|
|||||||
message: args.message,
|
message: args.message,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
...(args.idempotencyKey ? { client_message_id: args.idempotencyKey } : {}),
|
...(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