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

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