feat(cli): 1.34.7 → 1.34.13 — multi-session correctness train
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

Seven-ship sequence that took the daemon from "works for one session"
to "internally consistent for N sessions on one daemon." Architecture
invariant after 1.34.13: every shared store / channel scopes by
recipient (SSE demux at bind layer + token forwarding, inbox per-
recipient columns, outbox sender-session routing).

- 1.34.7  inbox flush + delete commands
- 1.34.8  seen_at column + TTL prune + first echo guard
- 1.34.9  broader echo guard + system-event polish + staleness warning
- 1.34.10 per-session SSE demux (SseFilterOptions) + universal daemon
          (--mesh / --name deprecated) + daemon_started version stamp
- 1.34.11 inbox per-recipient column (storage half of 1.34.10)
- 1.34.12 daemon up detaches by default (logs to ~/.claudemesh/daemon/
          daemon.log; service units explicitly pass --foreground)
- 1.34.13 MCP forwards session token on /v1/events — the actual fix
          that activates 1.34.10's demux. Without this header the
          daemon's session resolved null, filter was empty, every MCP
          received the unfiltered global stream.

Roadmap entry at docs/roadmap.md captures the timeline + the four
known gaps tracked for follow-ups (launch env-var leak, broker
listPeers mesh-filter, kick on control-plane no-op, session caps as
first-class concept).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 21:10:07 +01:00
parent cba4a938ec
commit 6780899185
24 changed files with 2568 additions and 143 deletions

View File

@@ -29,6 +29,14 @@ const BOOLEAN_FLAGS = new Set([
"dry-run",
"verbose",
"skip-service",
// 1.34.8: `--unread` filters `claudemesh inbox` to rows whose
// seen_at is NULL. No value — pure switch.
"unread",
// 1.34.12: `--foreground` keeps `claudemesh daemon up` attached
// to the terminal (pre-1.34.12 behavior). Default is detached now.
"foreground",
"no-tcp",
"public-health",
]);
export function parseArgv(argv: string[]): ParsedArgs {

View File

@@ -1,3 +1,7 @@
import { spawn } from "node:child_process";
import { existsSync, openSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { runDaemon } from "~/daemon/run.js";
import { ipc, IpcError } from "~/daemon/ipc/client.js";
import { readRunningPid } from "~/daemon/lock.js";
@@ -9,6 +13,15 @@ export interface DaemonOptions {
publicHealth?: boolean;
mesh?: string;
displayName?: string;
/** 1.34.12: keep the daemon attached to the current shell instead
* of double-forking. Default behavior changed in 1.34.12 — `up`
* now detaches by default and writes JSON logs to
* ~/.claudemesh/daemon/daemon.log. Pass `--foreground` to get the
* pre-1.34.12 behavior (logs streaming to stdout, blocks the
* terminal until Ctrl-C). install-service and `claudemesh launch`'s
* auto-spawn path always pass --foreground because their parents
* (launchd / the launch helper) own the lifecycle. */
foreground?: boolean;
/** outbox-list status filter, set from boolean flags --failed/--pending/etc. */
outboxStatus?: "pending" | "inflight" | "done" | "dead" | "aborted";
/** outbox requeue: optional id to mint a fresh client_message_id with. */
@@ -26,11 +39,40 @@ export async function runDaemonCommand(
case "up":
case "start":
// 1.34.10: `--mesh` and `--name` deprecated.
// --mesh: daemon attaches to every joined mesh automatically;
// pinning at start time blocks new meshes from being picked up.
// --name: overrides the daemon-WS display name GLOBALLY across
// every mesh, but each mesh has its own per-mesh display name
// in config.json (set at `claudemesh join` time). Passing one
// name flattens that out. Sessions advertise their own
// CLAUDEMESH_DISPLAY_NAME at `claudemesh launch` time anyway,
// and the daemon-WS presence is hidden from peer lists since
// 1.32, so the daemon's display name isn't user-visible.
if (opts.mesh) {
process.stderr.write(
`[claudemesh] --mesh on \`daemon up\` is deprecated; the daemon attaches to every joined mesh automatically. ` +
`Ignoring --mesh ${opts.mesh}.\n`,
);
}
if (opts.displayName) {
process.stderr.write(
`[claudemesh] --name on \`daemon up\` is deprecated; per-mesh display names live in config.json (set at join time), ` +
`and session display names come from \`claudemesh launch --name\`. Ignoring --name ${opts.displayName}.\n`,
);
}
// 1.34.12: detach by default. The pre-1.34.12 behavior streamed
// JSON logs to the controlling terminal and blocked the shell —
// fine for debugging, surprising for users who just want the
// daemon "up." `--foreground` opts back into the old behavior;
// launchd / systemd-user units always pass it because the unit
// manager owns lifecycle and stdio redirection.
if (!opts.foreground) {
return spawnDetachedDaemon(opts);
}
return runDaemon({
tcpEnabled: !opts.noTcp,
publicHealthCheck: opts.publicHealth,
mesh: opts.mesh,
displayName: opts.displayName,
});
case "help":
@@ -74,19 +116,18 @@ USAGE
claudemesh daemon <command> [options]
COMMANDS
up | start start the daemon in the foreground
up | start start the daemon (detached by default)
status show running pid + IPC health
version ipc + schema version of the running daemon
down | stop stop the running daemon (SIGTERM, then wait)
accept-host pin the current host fingerprint
outbox list list local outbox rows (newest first)
outbox requeue <id> re-enqueue an aborted / dead outbox row
install-service --mesh <s> write launchd (macOS) / systemd-user (Linux) unit
install-service write launchd (macOS) / systemd-user (Linux) unit
uninstall-service remove the platform service unit
OPTIONS
--mesh <slug> attach to / target this mesh
--name <displayName> override CLAUDEMESH_DISPLAY_NAME
--foreground keep daemon attached to terminal, JSON logs to stdout (1.34.12+)
--no-tcp disable the loopback TCP listener (UDS only)
--public-health expose /v1/health unauthenticated on TCP
--json machine-readable output where supported
@@ -192,9 +233,12 @@ async function runInstallService(opts: DaemonOptions): Promise<number> {
}
// Resolve the binary path. Prefer the running argv[0] when it's an
// installed claudemesh binary; fall back to whichever `claudemesh` is
// first on PATH. --mesh is now optional: omit it to attach to every
// joined mesh (the 1.26.0 multi-mesh default); pass it to lock the
// unit to a single mesh for testing or single-mesh hosts.
// first on PATH.
// 1.34.10: install-service no longer bakes --mesh into the unit. The
// daemon attaches to every joined mesh by default, and pinning the
// unit to one slug at install time was the source of the "joined a
// new mesh but my service ignores it" footgun. If the user passes
// --mesh anyway, we warn + ignore.
let binary = process.argv[1] ?? "";
if (!binary || /\.ts$/.test(binary) || /node_modules|src\/entrypoints/.test(binary)) {
try {
@@ -205,11 +249,19 @@ async function runInstallService(opts: DaemonOptions): Promise<number> {
return 1;
}
}
if (opts.mesh) {
process.stderr.write(
`[claudemesh] --mesh on \`daemon install-service\` is deprecated and ignored; the daemon attaches to every joined mesh.\n`,
);
}
if (opts.displayName) {
process.stderr.write(
`[claudemesh] --name on \`daemon install-service\` is deprecated and ignored; per-mesh names live in config.json, session names come from \`claudemesh launch --name\`.\n`,
);
}
try {
const r = installService({
binaryPath: binary,
...(opts.mesh ? { meshSlug: opts.mesh } : {}),
...(opts.displayName ? { displayName: opts.displayName } : {}),
});
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: true, ...r }) + "\n");
@@ -309,3 +361,71 @@ async function runStop(opts: DaemonOptions): Promise<number> {
else process.stdout.write(`daemon: signaled but did not exit within 5s (pid ${pid})\n`);
return 1;
}
/**
* 1.34.12: spawn the daemon as a detached background process. Re-execs
* the same `claudemesh` binary with `daemon up --foreground` (so the
* child runs the long-lived loop), redirects stdout/stderr to
* ~/.claudemesh/daemon/daemon.log, and `unref()`s so the parent shell
* can exit cleanly.
*
* The parent waits up to ~3s for the UDS socket to appear before
* declaring success — that's the same liveness check `claudemesh launch`
* uses, and it catches the "child crashed during boot" case (config
* read failed, port bind failed, etc.) with an actionable error
* pointing at the log file rather than silent loss.
*/
async function spawnDetachedDaemon(opts: DaemonOptions): Promise<number> {
// Ensure the log directory exists before opening the FDs.
mkdirSync(DAEMON_PATHS.DAEMON_DIR, { recursive: true, mode: 0o700 });
const logPath = join(DAEMON_PATHS.DAEMON_DIR, "daemon.log");
// The CLI binary path. process.argv[1] is the entrypoint script the
// node runtime is currently executing — for an installed CLI that's
// .../bin/claudemesh, for `bun run` dev that's the local dist file.
// Either way it's the right thing to re-exec.
const binary = process.argv[1] ?? "claudemesh";
const args = ["daemon", "up", "--foreground"];
if (opts.noTcp) args.push("--no-tcp");
if (opts.publicHealth) args.push("--public-health");
const out = openSync(logPath, "a");
const err = openSync(logPath, "a");
const child = spawn(process.execPath, [binary, ...args], {
detached: true,
stdio: ["ignore", out, err],
env: process.env,
});
// Decouple the child from the parent's process group so closing the
// shell doesn't SIGHUP the daemon.
child.unref();
// Wait for the socket to appear — the daemon's IPC listener binds
// ~immediately after the broker WS handshake starts, so socket
// existence is a reliable "the daemon is alive enough to accept
// requests" signal.
const sockPath = DAEMON_PATHS.SOCK_FILE;
const startedAt = Date.now();
while (Date.now() - startedAt < 3_000) {
if (existsSync(sockPath)) {
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: true, detached: true, pid: child.pid, log: logPath }) + "\n");
} else {
process.stdout.write(` ✔ daemon started (pid ${child.pid})\n`);
process.stdout.write(` → log: ${logPath}\n`);
process.stdout.write(` → stop: claudemesh daemon down\n`);
}
return 0;
}
await new Promise<void>((r) => setTimeout(r, 100));
}
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: false, detached: true, pid: child.pid, reason: "socket_not_appeared", log: logPath }) + "\n");
} else {
process.stderr.write(` ✘ daemon spawn timeout: socket did not appear within 3s\n`);
process.stderr.write(` → check log: ${logPath}\n`);
process.stderr.write(` → run foreground for live output: claudemesh daemon up --foreground\n`);
}
return 1;
}

View File

@@ -0,0 +1,91 @@
/**
* `claudemesh inbox flush` and `claudemesh inbox delete <id>` —
* mutate the daemon's persistent inbox store
* (`~/.claudemesh/daemon/inbox.db`) over IPC.
*
* 1.34.7: until this version, the only way to clean the inbox was a
* raw `sqlite3 inbox.db "DELETE FROM inbox"` against the daemon's
* private DB. That works but bypasses the IPC layer (and any future
* lifecycle hooks on row removal), and is invisible to a user who
* doesn't know the schema. These two verbs make the operation visible
* + safe + scriptable.
*/
import {
tryFlushInboxViaDaemon,
tryDeleteInboxRowViaDaemon,
} from "~/services/bridge/daemon-route.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
export interface InboxFlushFlags {
mesh?: string;
/** ISO-8601 timestamp; deletes rows received_at < before. */
before?: string;
/** Required when neither --mesh nor --before is set, to prevent an
* accidental "delete every row on every mesh". */
all?: boolean;
json?: boolean;
}
export async function runInboxFlush(flags: InboxFlushFlags): Promise<void> {
const hasFilter = !!(flags.mesh || flags.before);
if (!hasFilter && !flags.all) {
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "missing_filter" }) + "\n"); return; }
render.info(dim(
"Refusing to flush every row on every mesh.\n" +
" Re-run with --mesh <slug>, --before <iso-timestamp>, or --all to confirm.",
));
process.exit(1);
}
const removed = await tryFlushInboxViaDaemon({
...(flags.mesh ? { mesh: flags.mesh } : {}),
...(flags.before ? { beforeIso: flags.before } : {}),
});
if (removed === null) {
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "daemon_unreachable" }) + "\n"); return; }
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
process.exit(1);
}
if (flags.json) {
process.stdout.write(JSON.stringify({ ok: true, removed }) + "\n");
return;
}
const scope = flags.mesh
? `mesh "${flags.mesh}"`
: flags.before
? `older than ${flags.before}`
: "all meshes";
render.info(`✔ Flushed ${removed} message${removed === 1 ? "" : "s"} from ${scope}.`);
}
export interface InboxDeleteFlags {
json?: boolean;
}
export async function runInboxDelete(id: string, flags: InboxDeleteFlags): Promise<void> {
if (!id) {
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "missing_id" }) + "\n"); return; }
render.info(dim("Usage: claudemesh inbox delete <message-id>"));
process.exit(1);
}
const ok = await tryDeleteInboxRowViaDaemon(id);
if (ok === null) {
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "daemon_unreachable" }) + "\n"); return; }
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
process.exit(1);
}
if (!ok) {
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "not_found", id }) + "\n"); return; }
render.info(dim(`No inbox row with id "${id}".`));
process.exit(1);
}
if (flags.json) {
process.stdout.write(JSON.stringify({ ok: true, id }) + "\n");
return;
}
render.info(`✔ Deleted inbox row ${id}.`);
}

View File

@@ -1,49 +1,101 @@
/**
* `claudemesh inbox` — read pending peer messages.
* `claudemesh inbox` — read pending peer messages from the daemon's
* persisted inbox (`~/.claudemesh/daemon/inbox.db`).
*
* Connects, waits briefly for push delivery, drains the buffer, prints.
* Works best when message-mode is "inbox" or "off" (messages held at broker).
* 1.34.0: switched from the legacy cold-path "open fresh broker WS,
* drain in-memory buffer" flow to a daemon IPC read against `/v1/inbox`.
* The cold path was structurally broken — the persistent inbox lives in
* the daemon, and pushes land on its session-WS, not on a freshly-opened
* standalone WS. The daemon-route `tryListInboxViaDaemon` returns rows
* persisted across daemon restarts and surfaces them with the correct
* mesh scoping (server-side mesh filter added in 1.34.0).
*
* Cold-path fallback removed: when the daemon isn't reachable, the
* prior implementation returned an empty list anyway (no broker state
* = no buffered pushes), so removing that path doesn't lose any
* functionality. Strict mode emits a clear error via daemon-route.
*/
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
import { tryListInboxViaDaemon } from "~/services/bridge/daemon-route.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
export interface InboxFlags {
mesh?: string;
json?: boolean;
wait?: number;
/** Cap the number of rows returned by the daemon. Default 100. */
limit?: number;
/** 1.34.8: only show rows whose seen_at is NULL (i.e., never
* surfaced via an interactive listing or live channel reminder).
* When omitted, every row is returned and an interactive listing
* stamps them seen as a side effect. */
unread?: boolean;
}
function formatMessage(msg: InboundPush): string {
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
interface FormattedItem {
sender_pubkey: string;
sender_name: string;
body: string | null;
topic: string | null;
received_at: string;
mesh: string;
}
function formatMessage(msg: FormattedItem, includeMesh: boolean): string {
const text = msg.body ?? "[encrypted]";
const from = msg.sender_name && msg.sender_name !== msg.sender_pubkey.slice(0, 8)
? `${msg.sender_name} (${msg.sender_pubkey.slice(0, 8)})`
: msg.sender_pubkey.slice(0, 8);
const time = new Date(msg.received_at).toLocaleTimeString();
const topicTag = msg.topic ? ` (#${msg.topic})` : "";
const meshTag = includeMesh ? ` [${msg.mesh}]` : "";
return ` ${bold(from)} ${dim(`${meshTag}${topicTag} ${time}`)}\n ${text}`;
}
export async function runInbox(flags: InboxFlags): Promise<void> {
const waitMs = (flags.wait ?? 1) * 1000;
// Mesh resolution is owned by the daemon (it knows which meshes are
// attached) — the CLI just forwards the user's --mesh flag through.
// When omitted, the daemon's `/v1/inbox` honors the session-default
// mesh on auth-token requests; out-of-session callers see rows from
// every attached mesh. We don't pre-validate the mesh slug here so
// the command works even from a launch tmpdir whose local
// `config.json` only knows about the launch's mesh.
const meshSlug = flags.mesh;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer();
if (flags.json) {
process.stdout.write(JSON.stringify(messages, null, 2) + "\n");
return;
}
if (messages.length === 0) {
render.info(dim(`No messages on mesh "${mesh.slug}".`));
return;
}
render.section(`inbox — ${mesh.slug} (${messages.length} message${messages.length === 1 ? "" : "s"})`);
for (const msg of messages) {
process.stdout.write(formatMessage(msg) + "\n\n");
}
const items = await tryListInboxViaDaemon(meshSlug, flags.limit ?? 100, {
unreadOnly: flags.unread === true,
// CLI is the canonical "I'm reading my inbox" path — let the daemon
// auto-stamp seen_at on the rows we just rendered. The MCP welcome
// path passes mark_seen=false instead and stamps explicitly after
// the channel notification succeeds.
markSeen: true,
});
if (items === null) {
if (flags.json) { process.stdout.write("[]\n"); return; }
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
return;
}
if (flags.json) {
process.stdout.write(JSON.stringify(items, null, 2) + "\n");
return;
}
if (items.length === 0) {
const scope = meshSlug ? `mesh "${meshSlug}"` : "any mesh";
const filter = flags.unread ? "unread " : "";
render.info(dim(`No ${filter}messages on ${scope}.`));
return;
}
const filterTag = flags.unread ? " unread" : "";
const heading = meshSlug
? `inbox — ${meshSlug} (${items.length}${filterTag} message${items.length === 1 ? "" : "s"})`
: `inbox (${items.length}${filterTag} message${items.length === 1 ? "" : "s"})`;
render.section(heading);
// When the user didn't filter by mesh, surface the mesh slug per row
// so they can tell apart rows from different meshes at a glance.
for (const msg of items) {
process.stdout.write(formatMessage(msg, !meshSlug) + "\n\n");
}
}

View File

@@ -63,6 +63,7 @@ async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<vo
const res = await ensureDaemonReady({ budgetMs: 10_000, mesh: meshSlug });
if (res.state === "up") {
if (!quiet) render.ok("daemon already running");
await warnIfDaemonStale(quiet);
return;
}
if (res.state === "started") {
@@ -71,10 +72,34 @@ async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<vo
}
render.warn(
`daemon ${res.state}${res.reason ? `: ${res.reason}` : ""}`,
"Run `claudemesh daemon up --mesh " + meshSlug + "` manually, then re-launch.",
"Run `claudemesh daemon up` manually, then re-launch.",
);
}
/** 1.34.9: warn when the running daemon's version doesn't match the CLI
* that's about to launch a session. `npm i -g claudemesh-cli` upgrades
* the binaries on disk but doesn't restart a launchd / systemd-user
* service or a foreground `claudemesh daemon up`, so users routinely
* ship a fix to the CLI side and never see it because the WS lifecycle,
* echo guards, and self-join filters all live in the long-running
* daemon process. We probe `/v1/version` and emit a one-shot stderr
* warning when CLI ≠ daemon. Best-effort; failures are silent. */
async function warnIfDaemonStale(quiet: boolean): Promise<void> {
if (quiet) return;
try {
const { ipc } = await import("~/daemon/ipc/client.js");
const { VERSION } = await import("~/constants/urls.js");
const res = await ipc<{ daemon_version?: string }>({ path: "/v1/version", timeoutMs: 1_500 });
if (res.status !== 200) return;
const daemonVersion = res.body.daemon_version ?? "";
if (!daemonVersion || daemonVersion === VERSION) return;
render.warn(
`daemon is ${daemonVersion}, CLI is ${VERSION} — restart to pick up new fixes.`,
"Run: `claudemesh daemon down && claudemesh daemon up` (no --mesh — daemon attaches to every joined mesh; restart the launchd / systemd-user unit if you installed one).",
);
} catch { /* swallow — version probe is best-effort */ }
}
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;

View File

@@ -15,6 +15,25 @@ export interface InboxRow {
meta: string | null;
received_at: number;
reply_to_id: string | null;
/** 1.34.8: Unix ms of when this row was first surfaced to the user
* (returned by an interactive `inbox` listing or pushed via channel
* reminder). NULL = never seen. Welcome filters on `seen_at IS NULL`
* so freshly-launched sessions only see what they actually missed. */
seen_at: number | null;
/** 1.34.11: pubkey of the WS that received this push. Either the
* daemon's member pubkey for member-keyed broadcasts, or one of
* our session pubkeys for session-targeted DMs. Without this, two
* sessions on the same daemon shared one inbox table and each saw
* every other session's messages — same bug shape the 1.34.10 SSE
* demux fixed for the live event path, just at the storage layer.
* Pre-1.34.11 rows have NULL here and are visible to every session
* on the same mesh (best-effort back-compat for already-stored
* history). */
recipient_pubkey: string | null;
/** 1.34.11: matches `recipient_kind` on the bus event. "session" =
* scoped to one session pubkey; "member" = visible to every
* session of that member on the mesh. NULL on legacy rows. */
recipient_kind: string | null;
}
export function migrateInbox(db: SqliteDb): void {
@@ -36,6 +55,24 @@ export function migrateInbox(db: SqliteDb): void {
CREATE INDEX IF NOT EXISTS inbox_topic ON inbox(topic);
CREATE INDEX IF NOT EXISTS inbox_sender ON inbox(sender_pubkey);
`);
// 1.34.8: read-state tracking. Pre-1.34.8 rows land with seen_at=NULL
// (treated as unread); welcome surfaces them once and the listing
// marks them seen. Indexed because welcome queries WHERE seen_at IS
// NULL on every launch.
const cols = db.prepare(`PRAGMA table_info(inbox)`).all<{ name: string }>();
if (!cols.some((c) => c.name === "seen_at")) {
db.exec(`ALTER TABLE inbox ADD COLUMN seen_at INTEGER`);
db.exec(`CREATE INDEX IF NOT EXISTS inbox_seen_at ON inbox(seen_at)`);
}
// 1.34.11: per-recipient scoping. Two sessions on the same daemon
// share one inbox table; without this column, listInbox returns
// every row regardless of which session is asking. Indexed
// because every interactive listing + welcome path filters by it.
if (!cols.some((c) => c.name === "recipient_pubkey")) {
db.exec(`ALTER TABLE inbox ADD COLUMN recipient_pubkey TEXT`);
db.exec(`ALTER TABLE inbox ADD COLUMN recipient_kind TEXT`);
db.exec(`CREATE INDEX IF NOT EXISTS inbox_recipient ON inbox(recipient_pubkey)`);
}
}
/**
@@ -45,7 +82,14 @@ export function migrateInbox(db: SqliteDb): void {
* Returns the new row id when this was a fresh insert, or null when the
* message id was already known (idempotent receive).
*/
export function insertIfNew(db: SqliteDb, row: Omit<InboxRow, "id"> & { id: string }): string | null {
export function insertIfNew(
db: SqliteDb,
// 1.34.8: callers don't pass `seen_at` — it's always NULL on insert
// (a freshly-received row is by definition unread). Stripping the
// field from the input type keeps inbound.ts callers from having to
// construct it.
row: Omit<InboxRow, "id" | "seen_at"> & { id: string },
): string | null {
// node:sqlite does support RETURNING. bun:sqlite does too. We branch on
// the row count instead so it works on both.
const before = db.prepare(`SELECT id FROM inbox WHERE client_message_id = ?`).get<{ id: string }>(row.client_message_id);
@@ -53,12 +97,14 @@ export function insertIfNew(db: SqliteDb, row: Omit<InboxRow, "id"> & { id: stri
db.prepare(`
INSERT INTO inbox (
id, client_message_id, broker_message_id, mesh, topic,
sender_pubkey, sender_name, body, meta, received_at, reply_to_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender_pubkey, sender_name, body, meta, received_at, reply_to_id,
recipient_pubkey, recipient_kind
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_message_id) DO NOTHING
`).run(
row.id, row.client_message_id, row.broker_message_id, row.mesh, row.topic,
row.sender_pubkey, row.sender_name, row.body, row.meta, row.received_at, row.reply_to_id,
row.recipient_pubkey, row.recipient_kind,
);
// Confirm the insert landed (handles the conflict-noop race).
const after = db.prepare(`SELECT id FROM inbox WHERE client_message_id = ?`).get<{ id: string }>(row.client_message_id);
@@ -69,6 +115,21 @@ export interface ListInboxParams {
since?: number; // received_at >= since
topic?: string;
fromPubkey?: string;
/** 1.34.0: filter by mesh slug. Omit to return rows across all meshes. */
mesh?: string;
/** 1.34.8: only rows with `seen_at IS NULL`. Used by the welcome
* push so a freshly-launched session surfaces what it actually
* missed instead of every row from the last 24h. */
unreadOnly?: boolean;
/** 1.34.11: scope to rows whose recipient is this session pubkey,
* PLUS member-keyed rows for the same member, PLUS legacy rows
* with a NULL recipient (best-effort back-compat with pre-1.34.11
* history). Set by the IPC `/v1/inbox` route from the bearer
* session token; without it the listing returns everything.
* `recipientMemberPubkey` widens the match to include broadcasts
* / member DMs that should reach every session of this member. */
recipientPubkey?: string;
recipientMemberPubkey?: string;
limit?: number;
}
@@ -78,9 +139,28 @@ export function listInbox(db: SqliteDb, p: ListInboxParams): InboxRow[] {
if (p.since !== undefined) { where.push("received_at >= ?"); args.push(p.since); }
if (p.topic !== undefined) { where.push("topic = ?"); args.push(p.topic); }
if (p.fromPubkey !== undefined){ where.push("sender_pubkey = ?"); args.push(p.fromPubkey); }
if (p.mesh !== undefined) { where.push("mesh = ?"); args.push(p.mesh); }
if (p.unreadOnly === true) { where.push("seen_at IS NULL"); }
// 1.34.11: recipient scoping. A session sees:
// - rows whose recipient_pubkey === its session pubkey (its DMs),
// - rows whose recipient_pubkey === the daemon's member pubkey
// (broadcasts / member-keyed DMs to anyone in this member's
// identity — every sibling session sees them),
// - legacy rows where recipient_pubkey IS NULL (pre-1.34.11
// history; we can't tell who they were for, so surface to all).
if (p.recipientPubkey) {
const ors: string[] = ["recipient_pubkey IS NULL", "recipient_pubkey = ?"];
args.push(p.recipientPubkey);
if (p.recipientMemberPubkey) {
ors.push("recipient_pubkey = ?");
args.push(p.recipientMemberPubkey);
}
where.push(`(${ors.join(" OR ")})`);
}
const sql = `
SELECT id, client_message_id, broker_message_id, mesh, topic,
sender_pubkey, sender_name, body, meta, received_at, reply_to_id
sender_pubkey, sender_name, body, meta, received_at, reply_to_id, seen_at,
recipient_pubkey, recipient_kind
FROM inbox
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY received_at DESC
@@ -89,3 +169,57 @@ export function listInbox(db: SqliteDb, p: ListInboxParams): InboxRow[] {
args.push(Math.min(Math.max(p.limit ?? 100, 1), 1000));
return db.prepare(sql).all<InboxRow>(...args);
}
/** 1.34.8: stamp `seen_at = now` on every row whose id is in `ids`,
* but only when `seen_at IS NULL` so re-marking doesn't bump the
* timestamp on a row the user already knew about. Returns the number
* of rows that flipped from unread → seen. Used by:
* - the IPC `/v1/inbox` route when called by an interactive
* listing (the daemon stamps after returning rows so the human
* who just looked at their inbox doesn't see the same rows
* flagged "unread" on next launch);
* - the MCP server when the SSE message event surfaces a live
* `<channel>` reminder (Claude Code already saw the row inline,
* no need to surface it again on welcome). */
export function markInboxSeen(db: SqliteDb, ids: readonly string[], now = Date.now()): number {
if (ids.length === 0) return 0;
const placeholders = ids.map(() => "?").join(",");
const r = db.prepare(
`UPDATE inbox SET seen_at = ? WHERE seen_at IS NULL AND id IN (${placeholders})`,
).run(now, ...ids);
return Number(r.changes);
}
/** 1.34.8: TTL prune. Removes inbox rows older than `cutoffMs`
* (received_at < cutoffMs). Daemon schedules this hourly with a 30-day
* default retention (see startInboxPruner). Returns the number of
* rows removed so the caller can log the volume. */
export function pruneInboxBefore(db: SqliteDb, cutoffMs: number): number {
const r = db.prepare(`DELETE FROM inbox WHERE received_at < ?`).run(cutoffMs);
return Number(r.changes);
}
/** 1.34.7: delete a single inbox row by id. Returns true iff a row was
* removed. The CLI exposes this as `claudemesh inbox delete <id>`. */
export function deleteInboxRow(db: SqliteDb, id: string): boolean {
const r = db.prepare(`DELETE FROM inbox WHERE id = ?`).run(id);
return Number(r.changes) > 0;
}
/** 1.34.7: bulk delete with mesh / age filters. Returns the number of
* rows removed. With no filter, deletes ALL rows on ALL meshes —
* caller is expected to gate this behind a `--all` confirmation. */
export interface FlushInboxParams {
mesh?: string;
/** Unix ms — delete rows received_at < before. */
before?: number;
}
export function flushInbox(db: SqliteDb, p: FlushInboxParams): number {
const where: string[] = [];
const args: unknown[] = [];
if (p.mesh !== undefined) { where.push("mesh = ?"); args.push(p.mesh); }
if (p.before !== undefined) { where.push("received_at < ?"); args.push(p.before); }
const sql = `DELETE FROM inbox ${where.length ? "WHERE " + where.join(" AND ") : ""}`;
const r = db.prepare(sql).run(...args);
return Number(r.changes);
}

View File

@@ -26,6 +26,15 @@ export interface OutboxRow {
nonce: string | null;
ciphertext: string | null;
priority: string | null;
/**
* 1.34.0: hex pubkey of the launched session that originated this row.
* NULL when the send came from outside a registered session
* (cold-path CLI, system-issued sends, etc.) — drain falls through to
* the daemon-WS in that case. When set, drain prefers the matching
* SessionBrokerClient so the broker fan-out attributes the push to
* the session pubkey instead of the daemon's stable member pubkey.
*/
sender_session_pubkey: string | null;
}
export function migrateOutbox(db: SqliteDb): void {
@@ -68,6 +77,14 @@ export function migrateOutbox(db: SqliteDb): void {
if (!hasNonce) db.exec(`ALTER TABLE outbox ADD COLUMN nonce TEXT`);
if (!hasCiphertext) db.exec(`ALTER TABLE outbox ADD COLUMN ciphertext TEXT`);
if (!hasPriority) db.exec(`ALTER TABLE outbox ADD COLUMN priority TEXT`);
// 1.34.0: per-row sender session pubkey, used by the drain worker to
// route via the originating session's WS so broker fan-out attributes
// the push to the session pubkey, not the daemon's member pubkey.
// Pre-1.34.0 rows land with NULL — drain falls back to the daemon-WS
// path (legacy attribution).
const hasSenderSessionPk = columnExists(db, "outbox", "sender_session_pubkey");
if (!hasSenderSessionPk) db.exec(`ALTER TABLE outbox ADD COLUMN sender_session_pubkey TEXT`);
}
function columnExists(db: SqliteDb, table: string, column: string): boolean {
@@ -80,7 +97,8 @@ export function findByClientId(db: SqliteDb, clientMessageId: string): OutboxRow
SELECT id, client_message_id, request_fingerprint, payload, enqueued_at,
attempts, next_attempt_at, status, last_error, delivered_at,
broker_message_id, aborted_at, aborted_by, superseded_by,
mesh, target_spec, nonce, ciphertext, priority
mesh, target_spec, nonce, ciphertext, priority,
sender_session_pubkey
FROM outbox WHERE client_message_id = ?
`).get<OutboxRow>(clientMessageId);
return row ?? null;
@@ -98,6 +116,9 @@ export interface InsertPendingInput {
nonce?: string;
ciphertext?: string;
priority?: string;
/** 1.34.0: hex pubkey of the originating session (omit for cold-path
* CLI sends — drain will use the daemon-WS). */
sender_session_pubkey?: string;
}
export function insertPending(db: SqliteDb, input: InsertPendingInput): void {
@@ -105,8 +126,9 @@ export function insertPending(db: SqliteDb, input: InsertPendingInput): void {
INSERT INTO outbox (
id, client_message_id, request_fingerprint, payload,
enqueued_at, attempts, next_attempt_at, status,
mesh, target_spec, nonce, ciphertext, priority
) VALUES (?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?, ?, ?, ?)
mesh, target_spec, nonce, ciphertext, priority,
sender_session_pubkey
) VALUES (?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?, ?, ?, ?, ?)
`).run(
input.id,
input.client_message_id,
@@ -114,11 +136,12 @@ export function insertPending(db: SqliteDb, input: InsertPendingInput): void {
input.payload,
input.now,
input.now,
input.mesh ?? null,
input.target_spec ?? null,
input.nonce ?? null,
input.ciphertext ?? null,
input.priority ?? null,
input.mesh ?? null,
input.target_spec ?? null,
input.nonce ?? null,
input.ciphertext ?? null,
input.priority ?? null,
input.sender_session_pubkey ?? null,
);
}
@@ -149,7 +172,8 @@ export function listOutbox(db: SqliteDb, p: ListOutboxParams = {}): OutboxRow[]
SELECT id, client_message_id, request_fingerprint, payload, enqueued_at,
attempts, next_attempt_at, status, last_error, delivered_at,
broker_message_id, aborted_at, aborted_by, superseded_by,
mesh, target_spec, nonce, ciphertext, priority
mesh, target_spec, nonce, ciphertext, priority,
sender_session_pubkey
FROM outbox
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY enqueued_at DESC
@@ -164,7 +188,8 @@ export function findById(db: SqliteDb, id: string): OutboxRow | null {
SELECT id, client_message_id, request_fingerprint, payload, enqueued_at,
attempts, next_attempt_at, status, last_error, delivered_at,
broker_message_id, aborted_at, aborted_by, superseded_by,
mesh, target_spec, nonce, ciphertext, priority
mesh, target_spec, nonce, ciphertext, priority,
sender_session_pubkey
FROM outbox WHERE id = ?
`).get<OutboxRow>(id) ?? null;
}

View File

@@ -13,6 +13,7 @@
import type { SqliteDb } from "./db/sqlite.js";
import type { DaemonBrokerClient } from "./broker.js";
import type { SessionBrokerClient } from "./session-broker.js";
import type { OutboxStatus } from "./db/outbox.js";
const POLL_INTERVAL_MS = 500;
@@ -32,6 +33,10 @@ interface PendingRow {
ciphertext: string | null;
priority: string | null;
mesh: string | null;
/** 1.34.0: hex pubkey of the originating session — drain prefers
* routing via that session's WS so broker fan-out attributes the
* push to the session pubkey. NULL on cold-path / pre-1.34.0 rows. */
sender_session_pubkey: string | null;
}
export interface DrainOptions {
@@ -40,6 +45,20 @@ export interface DrainOptions {
* 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>;
/**
* 1.34.0: lookup for the per-session WS keyed by hex session pubkey.
* When an outbox row has `sender_session_pubkey` set and this lookup
* returns an open client, the drain routes via the session-WS so the
* broker fan-out attributes the push to the session pubkey instead
* of the daemon's stable member pubkey.
*
* Returning `undefined` (or an unopened client) signals "no session
* WS available" — the drain backs off and retries; it does NOT fall
* back to the daemon-WS, because the row was encrypted with the
* session secret and would fail to decrypt on the recipient side
* if attribution silently changed mid-flight.
*/
getSessionBrokerByPubkey?: (sessionPubkey: string) => SessionBrokerClient | undefined;
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void;
}
@@ -88,7 +107,8 @@ async function drainOnce(opts: DrainOptions, log: NonNullable<DrainOptions["log"
const now = Date.now();
const rows = opts.db.prepare(`
SELECT id, client_message_id, request_fingerprint, payload, attempts,
target_spec, nonce, ciphertext, priority, mesh
target_spec, nonce, ciphertext, priority, mesh,
sender_session_pubkey
FROM outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY enqueued_at
@@ -101,21 +121,34 @@ 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;
// v1.26.0: pick the daemon-WS 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 daemonBroker: DaemonBrokerClient | undefined;
if (row.mesh) {
broker = opts.brokers.get(row.mesh);
daemonBroker = opts.brokers.get(row.mesh);
} else if (opts.brokers.size === 1) {
broker = opts.brokers.values().next().value;
daemonBroker = opts.brokers.values().next().value;
}
if (!broker) {
if (!daemonBroker) {
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;
}
// 1.34.0: when the row was written by an authenticated session,
// dispatch via the matching SessionBrokerClient so broker fan-out
// attributes the push to the session pubkey. Encryption is
// session-secret based on those rows, so we MUST NOT silently fall
// back to the daemon-WS — the recipient's decrypt would fail. If
// the session-WS is closed (reconnecting / session terminated), we
// back off and retry.
let sessionBroker: SessionBrokerClient | undefined;
if (row.sender_session_pubkey && opts.getSessionBrokerByPubkey) {
sessionBroker = opts.getSessionBrokerByPubkey(row.sender_session_pubkey);
}
// 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.
@@ -135,16 +168,31 @@ async function drainOnce(opts: DrainOptions, log: NonNullable<DrainOptions["log"
priority = "next";
}
const sendArgs = {
targetSpec,
priority,
nonce,
ciphertext,
client_message_id: row.client_message_id,
request_fingerprint_hex: fpHex,
};
let res;
try {
res = await broker.send({
targetSpec,
priority,
nonce,
ciphertext,
client_message_id: row.client_message_id,
request_fingerprint_hex: fpHex,
});
if (row.sender_session_pubkey) {
// Session-attributed row. Require an open session-WS — see comment
// above on why we don't fall back to the daemon-WS.
if (!sessionBroker || !sessionBroker.isOpen()) {
log("info", "drain_session_ws_not_ready", {
id: row.id, session_pubkey: row.sender_session_pubkey.slice(0, 12),
});
backoffPending(opts.db, row.id, row.attempts + 1, "session_ws_not_open", "session_ws_not_open");
continue;
}
res = await sessionBroker.send(sendArgs);
} else {
res = await daemonBroker.send(sendArgs);
}
} catch (e) {
log("warn", "drain_send_threw", { id: row.id, err: String(e) });
backoffPending(opts.db, row.id, row.attempts + 1, "exception", String(e));

View File

@@ -41,8 +41,68 @@ export function writeSse(res: ServerResponse, e: DaemonEvent, idCounter: number)
res.write(`data: ${JSON.stringify({ ts: e.ts, ...e.data })}\n\n`);
}
/** Open an SSE stream on the response and route bus events to it. */
export function bindSseStream(res: ServerResponse, bus: EventBus): () => void {
/** 1.34.10: per-subscriber demux options. The MCP server passes its
* own session pubkey + member pubkey when binding so the bus only
* sends events meant for that session. Without this, every MCP on a
* multi-session daemon receives every inbox row and emits a
* duplicate channel notification — manifests as session A seeing its
* own outbound DM to B because B's session-WS published the row to
* the shared bus. */
export interface SseFilterOptions {
/** Session pubkey the subscribing MCP serves. Events tagged
* `recipient_kind: "session"` only flow when their
* `recipient_pubkey` matches this. */
sessionPubkey?: string;
/** Daemon's member pubkey for this mesh. Events tagged
* `recipient_kind: "member"` flow when their `recipient_pubkey`
* matches — those are member-keyed broadcasts / DMs that should
* reach every session of this member, but not OTHER members. */
memberPubkey?: string;
/** Mesh slug the subscriber is bound to (from session registry).
* When set, system events (peer_join etc.) are filtered to this
* mesh; without it every system event surfaces. */
meshSlug?: string;
}
function shouldDeliver(e: DaemonEvent, f: SseFilterOptions): boolean {
// No filter set → legacy behavior: deliver everything (used by
// diagnostic tooling like `claudemesh daemon events`).
if (!f.sessionPubkey && !f.memberPubkey && !f.meshSlug) return true;
// Mesh scoping for events that carry a mesh slug. peer_join /
// peer_leave / broker_status all carry `data.mesh`; if the
// subscriber is bound to a specific mesh, drop events from other
// meshes.
if (f.meshSlug) {
const eventMesh = typeof e.data.mesh === "string" ? e.data.mesh : null;
if (eventMesh && eventMesh !== f.meshSlug) return false;
}
// System events (peer_join etc.) flow to every session on the same
// mesh — they're informational, not addressed.
if (e.kind !== "message") return true;
const recipientKind = typeof e.data.recipient_kind === "string" ? e.data.recipient_kind : null;
const recipientPubkey = typeof e.data.recipient_pubkey === "string" ? e.data.recipient_pubkey.toLowerCase() : null;
// Legacy publish without recipient context → everyone gets it. Keeps
// backward compatibility with older daemon code paths until they're
// migrated. Also covers test paths that don't thread context.
if (!recipientKind || !recipientPubkey) return true;
if (recipientKind === "session") {
return !!f.sessionPubkey && f.sessionPubkey.toLowerCase() === recipientPubkey;
}
if (recipientKind === "member") {
return !!f.memberPubkey && f.memberPubkey.toLowerCase() === recipientPubkey;
}
return true;
}
/** Open an SSE stream on the response and route bus events to it.
* 1.34.10: optional `filter` scopes the stream to one session/member;
* see SseFilterOptions. */
export function bindSseStream(res: ServerResponse, bus: EventBus, filter: SseFilterOptions = {}): () => void {
res.statusCode = 200;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
@@ -51,7 +111,10 @@ export function bindSseStream(res: ServerResponse, bus: EventBus): () => void {
res.write(": connected\n\n");
let counter = 0;
const unsubscribe = bus.subscribe((e) => writeSse(res, e, ++counter));
const unsubscribe = bus.subscribe((e) => {
if (!shouldDeliver(e, filter)) return;
writeSse(res, e, ++counter);
});
const heartbeat = setInterval(() => {
try { res.write(": keepalive\n\n"); }

View File

@@ -18,6 +18,20 @@ export interface InboundContext {
/** Daemon's session secret key hex (rotates per connect). When the
* sender encrypted to our session pubkey, decrypt with this instead. */
sessionSecretKeyHex?: string;
/** 1.34.10: recipient pubkey of the WS that received this push.
* Either the daemon's member pubkey (member-WS) or one of our
* session pubkeys (session-WS). Threaded through to the bus event
* so each MCP subscriber can filter to events meant for its own
* session — without it, every MCP on the same daemon renders every
* inbox row, which manifests as session A seeing its own outbound
* to B (because A's MCP also picks up the bus event B's WS just
* published). */
recipientPubkey?: string;
/** 1.34.10: kind of WS this push arrived on. "session" pushes only
* surface to the matching session's MCP; "member" pushes surface to
* every session on the same mesh (member-keyed broadcasts, member
* DMs that don't have a session). */
recipientKind?: "session" | "member";
/** v2 agentic-comms (M1): emit `client_ack` back to the broker after
* the message lands in inbox.db. Broker uses the ack to set
* `delivered_at` (atomic at-least-once). Without it, the broker's
@@ -25,6 +39,16 @@ export interface InboundContext {
* client owns this callback because it's the one that owns the
* socket; inbound.ts just signals "I accepted this id." */
ackClientMessage?: (clientMessageId: string, brokerMessageId: string | null) => void;
/** 1.34.9: drops system events (peer_joined / peer_left /
* peer_returned) whose eventData.pubkey is one of our own. The broker
* fans peer_joined to every OTHER connection in the mesh — but our
* daemon's member-WS counts as "other" relative to our session-WS,
* so without this filter the user sees `[system] Peer "<self>"
* joined the mesh` every time their own session reconnects.
* Implementation passes a closure that walks the live broker map
* rather than a static set, so newly-spawned sessions are visible
* immediately. */
isOwnPubkey?: (pubkey: string) => boolean;
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void;
}
@@ -38,10 +62,21 @@ export interface InboundContext {
export async function handleBrokerPush(msg: Record<string, unknown>, ctx: InboundContext): Promise<void> {
// System/topology pushes (peer_join, tick, …) — emit verbatim.
if (msg.subtype === "system" && typeof msg.event === "string") {
const eventData = (msg.eventData as Record<string, unknown> | undefined) ?? {};
// 1.34.9: drop self-joins. The broker excludes the JOINING
// connection from the fan-out, but our daemon owns multiple
// connections per mesh (member-WS + N session-WSs), and each is a
// distinct "other" from the broker's view — so a session's own
// peer_joined arrives at the same daemon's member-WS and used to
// surface as `[system] Peer "<self>" joined`. The session-WS path
// already skips system events entirely (see session-broker.ts
// 1.34.9), and this filter handles the member-WS path.
const eventPubkey = typeof eventData.pubkey === "string" ? eventData.pubkey : "";
if (eventPubkey && ctx.isOwnPubkey?.(eventPubkey)) return;
ctx.bus.publish(mapSystemEventKind(msg.event), {
mesh: ctx.meshSlug,
event: msg.event,
...(msg.eventData as Record<string, unknown> | undefined ?? {}),
...eventData,
});
return;
}
@@ -78,6 +113,12 @@ export async function handleBrokerPush(msg: Record<string, unknown>, ctx: Inboun
meta: createdAt ? JSON.stringify({ created_at: createdAt }) : null,
received_at: Date.now(),
reply_to_id: replyToId,
// 1.34.11: persist the recipient context so /v1/inbox can scope
// queries to the asking session. Mirrors the same fields on the
// bus event added in 1.34.10. Falls back to NULL when the caller
// didn't pass them (legacy paths, tests).
recipient_pubkey: ctx.recipientPubkey ?? null,
recipient_kind: ctx.recipientKind ?? null,
});
// Whether the row was newly inserted or already existed (dedupe), the
@@ -102,6 +143,14 @@ export async function handleBrokerPush(msg: Record<string, unknown>, ctx: Inboun
...(subtype ? { subtype } : {}),
body,
created_at: createdAt,
// 1.34.10: per-recipient routing context. SSE subscribers (the
// MCP servers that translate bus events into channel notifications)
// use this to filter to events meant for their own session. Without
// it, every MCP on the same daemon emits a channel push for every
// inbox row, which means session A sees its own outbound to B
// because B's session-WS published the inbox row to the shared bus.
...(ctx.recipientPubkey ? { recipient_pubkey: ctx.recipientPubkey } : {}),
...(ctx.recipientKind ? { recipient_kind: ctx.recipientKind } : {}),
});
}

View File

@@ -0,0 +1,73 @@
// 1.34.8: TTL prune for inbox.db.
//
// The inbox grows monotonically — every received DM lands as a row and
// nothing removes it except an explicit `claudemesh inbox flush`. For
// chatty meshes that's tens of thousands of rows over a few weeks.
// SQLite handles that volume fine, but the rows are sitting there
// forever and `claudemesh inbox` queries get slower as the table grows.
//
// The pruner runs hourly inside the daemon process and deletes rows
// whose received_at is older than `retentionMs`. Default is 30 days,
// which is generous for the "I went on holiday and want to see what I
// missed" case but won't carry old rows into next year.
//
// Best-effort: a failure logs a warning and the pruner keeps trying on
// the next interval. There's no shared state to corrupt — pruneInboxBefore
// is a single DELETE statement.
import { pruneInboxBefore } from "./db/inbox.js";
import type { SqliteDb } from "./db/sqlite.js";
export interface InboxPrunerOptions {
db: SqliteDb;
/** Retention window in ms. Rows with received_at < (now - retentionMs)
* are deleted. Default: 30 days. */
retentionMs?: number;
/** How often to run the prune. Default: 1 hour. */
intervalMs?: number;
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void;
}
export interface InboxPrunerHandle {
stop: () => void;
}
const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000;
export function startInboxPruner(opts: InboxPrunerOptions): InboxPrunerHandle {
const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
const log = opts.log ?? defaultLog;
const tick = (): void => {
try {
const cutoff = Date.now() - retentionMs;
const removed = pruneInboxBefore(opts.db, cutoff);
if (removed > 0) {
log("info", "inbox_prune_completed", {
removed,
retention_days: Math.round(retentionMs / (24 * 60 * 60 * 1000)),
});
}
} catch (e) {
log("warn", "inbox_prune_failed", { err: String(e) });
}
};
// Run once at startup so a daemon that's been down for weeks reaps
// immediately rather than waiting an hour.
tick();
const handle = setInterval(tick, intervalMs);
// Don't let the pruner block daemon shutdown.
if (typeof handle.unref === "function") handle.unref();
return { stop: () => clearInterval(handle) };
}
function defaultLog(level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) {
const line = JSON.stringify({ level, msg, ...meta, ts: new Date().toISOString() });
if (level === "info") process.stdout.write(line + "\n");
else process.stderr.write(line + "\n");
}

View File

@@ -39,6 +39,12 @@ export interface SendRequest {
nonce?: string;
/** Sprint 4: which mesh this send is for (single-mesh daemon today; multi-mesh later). */
mesh?: string;
/** 1.34.0: when the IPC request authenticated as a launched session,
* the IPC layer fills this with the session's hex pubkey. The drain
* worker uses it to route via the matching SessionBrokerClient so
* broker fan-out attributes the push to the session pubkey instead
* of the daemon's member pubkey. */
sender_session_pubkey?: string;
}
export type AcceptOutcome =
@@ -93,6 +99,7 @@ export function acceptSend(req: SendRequest, deps: AcceptDeps): AcceptOutcome {
nonce: req.nonce,
ciphertext: req.ciphertext,
priority: req.priority,
sender_session_pubkey: req.sender_session_pubkey,
});
return { kind: "accepted_pending", status: 202, client_message_id: clientId };
}

View File

@@ -5,7 +5,7 @@ import { timingSafeEqual } from "node:crypto";
import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js";
import type { SqliteDb } from "../db/sqlite.js";
import { acceptSend, type SendRequest } from "./handlers/send.js";
import { listInbox } from "../db/inbox.js";
import { listInbox, deleteInboxRow, flushInbox, markInboxSeen } from "../db/inbox.js";
import { listOutbox, requeueDeadOrPending, type OutboxStatus } from "../db/outbox.js";
import { randomUUID } from "node:crypto";
import { bindSseStream, type EventBus } from "../events.js";
@@ -319,7 +319,21 @@ function makeHandler(opts: {
respond(res, 503, { error: "event bus not initialised" });
return;
}
bindSseStream(res, opts.bus);
// 1.34.10: per-session SSE demux. When the subscriber presented
// a ClaudeMesh-Session token (the MCP server always does post-
// 1.34.10), scope the stream to that session's pubkey + the
// matching mesh's member pubkey. Diagnostic callers without a
// session token (`claudemesh daemon events`) get the unfiltered
// legacy stream. The bus itself stays single-shot; demux lives
// entirely at the SSE bind layer (events.ts shouldDeliver).
const filter: Record<string, string> = {};
if (session?.presence?.sessionPubkey) filter.sessionPubkey = session.presence.sessionPubkey;
if (session?.mesh) {
filter.meshSlug = session.mesh;
const meshCfg = opts.meshConfigs?.get(session.mesh);
if (meshCfg?.pubkey) filter.memberPubkey = meshCfg.pubkey;
}
bindSseStream(res, opts.bus, filter);
return;
}
@@ -579,12 +593,46 @@ function makeHandler(opts: {
const fromPubkey = url.searchParams.get("from") ?? undefined;
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
// 1.34.0: mesh filter. Falls back to session-default if header set.
const meshFilter = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
// 1.34.8: read-state filter. ?unread_only=true narrows to rows
// whose seen_at is NULL — used by the welcome push so a freshly
// launched session surfaces only what it actually missed.
const unreadOnly = url.searchParams.get("unread_only") === "true";
// 1.34.8: ?mark_seen=false opts out of the auto-stamp behavior. By
// default an interactive listing flips seen_at on the rows it just
// returned (the user "saw" them), which is what we want for the
// CLI but not for diagnostic tooling that wants to peek without
// affecting state. The MCP server uses mark_seen=false on the
// welcome path; it stamps explicitly via /v1/inbox/seen instead.
const markSeen = url.searchParams.get("mark_seen") !== "false";
// 1.34.11: scope by recipient when the caller is an authenticated
// session. The daemon receives every inbox row for every session
// it hosts, so a query without scoping returns the global table —
// session A would see B's DMs (the bug 1.34.10 fixed for the
// live event path; this is the storage half). Scope = session
// pubkey (DMs) + member pubkey (broadcasts/member DMs the whole
// member should see) + NULL (legacy rows we can't attribute).
const recipientPubkey = session?.presence?.sessionPubkey;
const meshCfgForRecipient = session?.mesh ? opts.meshConfigs?.get(session.mesh) : undefined;
const recipientMemberPubkey = meshCfgForRecipient?.pubkey;
const rows = listInbox(opts.inboxDb, {
since: Number.isFinite(since) ? since : undefined,
topic,
fromPubkey,
...(meshFilter ? { mesh: meshFilter } : {}),
unreadOnly,
...(recipientPubkey ? { recipientPubkey } : {}),
...(recipientMemberPubkey ? { recipientMemberPubkey } : {}),
limit: Number.isFinite(limit ?? NaN) ? limit : undefined,
});
let flippedCount = 0;
if (markSeen) {
const unreadIds = rows.filter((r) => r.seen_at == null).map((r) => r.id);
if (unreadIds.length > 0) {
flippedCount = markInboxSeen(opts.inboxDb, unreadIds);
}
}
respond(res, 200, {
items: rows.map((r) => ({
id: r.id,
@@ -597,11 +645,72 @@ function makeHandler(opts: {
body: r.body,
received_at: new Date(r.received_at).toISOString(),
reply_to_id: r.reply_to_id,
// 1.34.8: surface read-state. `null` = never seen (welcome
// candidate). Note that if mark_seen=true (default), we just
// stamped these rows — but the snapshot reflects the value
// BEFORE the stamp so callers can still tell which rows were
// unread when they asked.
seen_at: r.seen_at ? new Date(r.seen_at).toISOString() : null,
// 1.34.11: recipient context. Lets `--json` consumers tell
// a session DM apart from a member-keyed broadcast, and
// distinguishes pre-1.34.11 legacy rows (NULL) from
// properly-scoped ones.
recipient_pubkey: r.recipient_pubkey,
recipient_kind: r.recipient_kind,
})),
// 1.34.8: how many rows just flipped from unread → seen. Useful
// for telemetry and lets the CLI render "marked N as read".
marked_seen: flippedCount,
});
return;
}
// 1.34.8: explicit mark-seen endpoint. Used by the MCP server after
// it surfaces a live `<channel>` reminder for an inbox row — Claude
// Code already saw the row inline, so welcome shouldn't re-surface
// it on the next launch. Body: { ids: string[] }. Returns the
// number of rows that flipped from unread → seen.
if (req.method === "POST" && url.pathname === "/v1/inbox/seen") {
if (!opts.inboxDb) { respond(res, 503, { error: "inbox not initialised" }); return; }
try {
const body = await readJsonBody(req, 64 * 1024) as Record<string, unknown> | null;
const ids = Array.isArray(body?.ids)
? (body!.ids as unknown[]).filter((x): x is string => typeof x === "string")
: [];
if (ids.length === 0) { respond(res, 400, { error: "missing 'ids' (string[])" }); return; }
const flipped = markInboxSeen(opts.inboxDb, ids);
respond(res, 200, { marked_seen: flipped });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
// 1.34.7: inbox flush + per-row delete. The inbox is the daemon's
// local persisted SQLite store — there's no broker-side state to
// coordinate, so these are simple local writes.
if (req.method === "DELETE" && url.pathname === "/v1/inbox") {
if (!opts.inboxDb) { respond(res, 503, { error: "inbox not initialised" }); return; }
const meshFilter = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
const beforeRaw = url.searchParams.get("before");
const before = beforeRaw ? Date.parse(beforeRaw) : undefined;
const removed = flushInbox(opts.inboxDb, {
...(meshFilter ? { mesh: meshFilter } : {}),
...(Number.isFinite(before) ? { before } : {}),
});
respond(res, 200, { removed });
return;
}
if (req.method === "DELETE" && url.pathname.startsWith("/v1/inbox/")) {
if (!opts.inboxDb) { respond(res, 503, { error: "inbox not initialised" }); return; }
const id = url.pathname.slice("/v1/inbox/".length);
if (!id) { respond(res, 400, { error: "missing id" }); return; }
const ok = deleteInboxRow(opts.inboxDb, id);
if (!ok) { respond(res, 404, { error: "not found", id }); return; }
respond(res, 200, { removed: 1, id });
return;
}
if (req.method === "GET" && url.pathname === "/v1/outbox") {
if (!opts.outboxDb) { respond(res, 503, { error: "outbox not initialised" }); return; }
const statusParam = url.searchParams.get("status") ?? undefined;
@@ -701,12 +810,23 @@ function makeHandler(opts: {
respond(res, 404, { error: "mesh_not_attached", mesh: chosenSlug });
return;
}
// 1.34.0: authenticated session sends encrypt with the session
// secret key + carry the session pubkey through to the outbox
// row, so the drain worker can route via SessionBrokerClient
// and the broker fan-out attributes the push to the session
// pubkey instead of the daemon's member pubkey. Cold-path
// sends (no session token) keep the legacy member-key flow.
const senderSessionPubkey = session?.presence?.sessionPubkey;
const senderSecretKey = session?.presence?.sessionSecretKey ?? meshCfg.secretKey;
try {
const routed = await resolveAndEncrypt(parsed.req, broker, meshCfg.secretKey, chosenSlug);
const routed = await resolveAndEncrypt(parsed.req, broker, senderSecretKey, chosenSlug);
parsed.req.target_spec = routed.target_spec;
parsed.req.ciphertext = routed.ciphertext;
parsed.req.nonce = routed.nonce;
parsed.req.mesh = routed.mesh;
if (senderSessionPubkey) {
parsed.req.sender_session_pubkey = senderSessionPubkey;
}
} catch (e) {
respond(res, 502, { error: "route_failed", detail: String(e) });
return;

View File

@@ -11,19 +11,17 @@ import { migrateInbox } from "./db/inbox.js";
import { DaemonBrokerClient } from "./broker.js";
import { SessionBrokerClient } from "./session-broker.js";
import { startDrainWorker, type DrainHandle } from "./drain.js";
import { startInboxPruner, type InboxPrunerHandle } from "./inbox-pruner.js";
import { handleBrokerPush } from "./inbound.js";
import { EventBus } from "./events.js";
import { checkFingerprint, type ClonePolicy } from "./identity.js";
import { readConfig } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
export interface RunDaemonOptions {
/** Disable TCP loopback (UDS-only). Defaults true in container envs. */
tcpEnabled?: boolean;
publicHealthCheck?: boolean;
/** Mesh slug to attach to. Required when the user has joined multiple meshes. */
mesh?: string;
/** Daemon's display name on the mesh. */
displayName?: string;
/** Behavior on host_fingerprint mismatch. Defaults 'refuse'. */
clonePolicy?: ClonePolicy;
}
@@ -95,30 +93,27 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
const bus = new EventBus();
// 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.
// 1.34.10: the daemon is universal — attaches to every mesh listed
// in config.json. Single-mesh isolation is handled by simply joining
// only one mesh in that environment (containers, etc.). No --mesh
// flag, no per-mesh service unit; one daemon, every mesh.
const cfg = readConfig();
let meshes: Array<typeof cfg.meshes[number]>;
if (opts.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;
}
meshes = [found];
} else if (cfg.meshes.length === 0) {
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 {
meshes = cfg.meshes;
}
const meshes = cfg.meshes;
// 1.34.9 — declared upfront so the daemon-WS onPush closure can
// reach into the per-session map for the isOwnPubkey filter (drops
// peer_joined / peer_left events for our own session pubkeys before
// they surface as `[system] Peer "<self>" joined`). Populated below
// by setRegistryHooks; empty until the first session registers, but
// that's fine — the closure walks it lazily.
const sessionBrokers = new Map<string, SessionBrokerClient>();
const sessionBrokersByPubkey = new Map<string, SessionBrokerClient>();
// Spin up one broker per mesh. Connection failures are non-fatal:
// the outbox keeps queuing per-mesh and reconnect logic in
@@ -127,8 +122,11 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
const meshConfigs = new Map<string, typeof cfg.meshes[number]>();
for (const mesh of meshes) {
meshConfigs.set(mesh.slug, mesh);
// 1.34.10: no global displayName override anymore. Each mesh's
// hello uses its own per-mesh display name from config.json (set
// at `claudemesh join` time). Sessions advertise their own name
// via `claudemesh launch --name`.
const broker: DaemonBrokerClient = 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(),
@@ -141,6 +139,22 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
// 1.32.1 and decrypt with the session secret there. Anything that
// arrives here can only be member-keyed (broadcasts, member DMs,
// system events) — pass member secret only.
// 1.34.9: drop self-echoes — broker fan-out paths mirror an
// outbound back to the SAME daemon's member-WS even when the
// send originated on a session-WS (because both connections
// belong to the same member from the broker's view). Filter on
// senderMemberPubkey alone: anything attributed to OUR member is
// either our own send echoing back or, theoretically, a peer
// send from a different connection that happens to share our
// pubkey — but two-different-clients-same-pubkey is impossible
// by construction (member pubkeys are stable + unique per
// identity). Sibling-session DMs don't fan to our member-WS;
// they fan session-to-session. So this is safe.
const senderMemberPk = String((m as Record<string, unknown>).senderMemberPubkey ?? "").toLowerCase();
const ownMember = mesh.pubkey.toLowerCase();
if (senderMemberPk && senderMemberPk === ownMember) {
return;
}
void handleBrokerPush(m, {
db: inboxDb,
bus,
@@ -149,6 +163,18 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
// v2 agentic-comms (M1): client_ack closes the at-least-once
// loop. Broker holds the row claimed (not delivered) until ack.
ackClientMessage: (cmid, bmid) => broker.sendClientAck(cmid, bmid),
// 1.34.9: drop self-join system events. Member pubkey + every
// live session pubkey on this daemon all count as "us".
isOwnPubkey: (pubkey) => {
const lower = pubkey.toLowerCase();
if (lower === ownMember) return true;
return sessionBrokersByPubkey.has(lower);
},
// 1.34.10: tag the bus event with our member pubkey so the
// SSE demux only fans this row to MCPs whose subscriber
// matches (member-keyed broadcasts / DMs).
recipientPubkey: mesh.pubkey,
recipientKind: "member",
});
},
});
@@ -156,16 +182,33 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
brokers.set(mesh.slug, broker);
}
// 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, brokers });
// 1.30.0 — per-session broker presence. Always on. Older CLIs that
// don't include `presence` material in the register body just won't
// get a session WS; the daemon's own member-keyed broker still
// covers them.
const sessionBrokers = new Map<string, SessionBrokerClient>();
//
// The two index maps (sessionBrokers by token, sessionBrokersByPubkey
// by session pubkey) are declared earlier in this function so the
// daemon-WS onPush closure can reference them for the isOwnPubkey
// self-join filter.
// Start the drain worker. With multi-mesh, drain dispatches each
// outbox row to its mesh's broker via the `mesh` column.
// 1.34.0: drain also accepts a session-pubkey lookup so rows
// written by authenticated sessions route via the matching session-WS
// (broker fan-out then attributes the push to the session pubkey).
let drain: DrainHandle | null = null;
drain = startDrainWorker({
db: outboxDb,
brokers,
getSessionBrokerByPubkey: (pubkey) => sessionBrokersByPubkey.get(pubkey),
});
// 1.34.8 — TTL prune for inbox.db. Runs hourly with a 30-day default
// retention. Without this the inbox grows unbounded; even on a moderate
// mesh that's tens of thousands of rows over a few weeks. Prune is a
// single DELETE; failures are non-fatal and the next interval retries.
const inboxPruner: InboxPrunerHandle = startInboxPruner({ db: inboxDb });
setRegistryHooks({
onRegister: (info) => {
if (!info.presence) return;
@@ -181,6 +224,10 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
const prior = sessionBrokers.get(info.token);
if (prior) {
sessionBrokers.delete(info.token);
// 1.34.0: keep both indices in sync.
if (sessionBrokersByPubkey.get(prior.sessionPubkey) === prior) {
sessionBrokersByPubkey.delete(prior.sessionPubkey);
}
prior.close().catch(() => { /* ignore */ });
}
// 1.32.1 — wire push delivery. Messages targeted at the launched
@@ -190,6 +237,10 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
// session secret key; member key remains the fallback for legacy
// member-targeted traffic that happens to fan out here.
const sessionSecretKeyHex = info.presence.sessionSecretKey;
// Capture the pubkey for the onPush closure below — TS can't
// narrow `info.presence` inside the async arrow even though we
// guard `if (!info.presence) return` earlier.
const sessionPubkeyHex = info.presence.sessionPubkey;
const client: SessionBrokerClient = new SessionBrokerClient({
mesh: meshConfig,
sessionPubkey: info.presence.sessionPubkey,
@@ -209,10 +260,18 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
sessionSecretKeyHex,
// v2 agentic-comms (M1): close the at-least-once loop.
ackClientMessage: (cmid, bmid) => client.sendClientAck(cmid, bmid),
// 1.34.10: tag the bus event with this session's pubkey so
// the SSE demux only delivers to the MCP serving THIS
// session — not its siblings on the same daemon. Without
// this, A's MCP also rendered DMs intended for B because
// the bus was a single shared stream.
recipientPubkey: sessionPubkeyHex,
recipientKind: "session",
});
},
});
sessionBrokers.set(info.token, client);
sessionBrokersByPubkey.set(info.presence.sessionPubkey, client);
client.connect().catch((err) =>
process.stderr.write(JSON.stringify({
level: "warn", msg: "session_broker_connect_failed",
@@ -224,6 +283,11 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
const client = sessionBrokers.get(info.token);
if (!client) return;
sessionBrokers.delete(info.token);
// 1.34.0: drop the pubkey index iff this client still owns it
// (a re-register may have already swapped the entry).
if (sessionBrokersByPubkey.get(client.sessionPubkey) === client) {
sessionBrokersByPubkey.delete(client.sessionPubkey);
}
client.close().catch(() => { /* ignore */ });
},
});
@@ -252,6 +316,10 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
process.stdout.write(JSON.stringify({
msg: "daemon_started",
// 1.34.10: stamp the version so users can tell whether the
// running daemon picked up a recent CLI ship. Read off the same
// VERSION constant the IPC `/v1/version` endpoint serves.
version: VERSION,
pid: process.pid,
sock: DAEMON_PATHS.SOCK_FILE,
tcp: tcpEnabled ? `127.0.0.1:47823` : null,
@@ -264,6 +332,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write(JSON.stringify({ msg: "daemon_shutdown", signal: sig, ts: new Date().toISOString() }) + "\n");
inboxPruner.stop();
if (drain) await drain.close();
for (const b of brokers.values()) {
try { await b.close(); } catch { /* ignore */ }

View File

@@ -98,10 +98,16 @@ function installDarwin(args: InstallArgs): InstallResult {
// one that installed claudemesh-cli. Pinning process.execPath here means
// the daemon always runs under the same Node that ran `claudemesh install`.
const nodeBin = process.execPath;
// 1.34.12: --foreground because launchd manages lifecycle + stdio.
// Without it, the daemon would re-spawn itself detached (the new
// default) and launchd would lose track of the actual long-lived
// process — KeepAlive wouldn't work and stdout redirect would
// capture only the parent's brief boot.
const meshArgs = [
`<string>${escapeXml(args.binaryPath)}</string>`,
"<string>daemon</string>",
"<string>up</string>",
"<string>--foreground</string>",
...(args.meshSlug
? ["<string>--mesh</string>", `<string>${escapeXml(args.meshSlug)}</string>`]
: []),
@@ -180,8 +186,11 @@ function installLinux(args: InstallArgs): InstallResult {
// Same node-pinning rationale as macOS — systemd's User= environment is
// similarly minimal; resolve node by absolute path.
const nodeBin = process.execPath;
// 1.34.12: --foreground because systemd-user owns process lifecycle
// and stdio capture; we don't want the child to double-fork into a
// detached grandchild systemd can't track.
const execArgs = [
"daemon", "up",
"daemon", "up", "--foreground",
...(args.meshSlug ? ["--mesh", args.meshSlug] : []),
...(args.displayName ? ["--name", args.displayName] : []),
].map(shellQuote).join(" ");

View File

@@ -11,14 +11,22 @@
* Differences from `DaemonBrokerClient`:
* - Uses session_hello (1.30.0+ broker), with a parent-vouched
* attestation provided at construction time.
* - Does NOT drain the outbox — that stays the parent member-keyed
* DaemonBrokerClient's job. Keeps the responsibility split clean
* and avoids two clients fighting over the same outbox row.
* - Does NOT carry list_peers / state / memory RPCs. This client is
* presence-only PLUS inbound DM delivery for messages targeted at
* the session pubkey — pushes are forwarded via the `onPush`
* callback to the daemon's shared handleBrokerPush, decrypted with
* this session's secret key.
* presence + inbound DM delivery + (1.34.0) outbound send for
* messages that originate from this session. Routing those through
* here is what makes the broker fan-out attribute the push to the
* session pubkey instead of the daemon's stable member pubkey.
*
* Outbox routing (1.34.0): the drain worker now consults
* `outbox.sender_session_pubkey`. If a row was written by an
* authenticated session and the matching session-WS is `open`, the
* drain dispatches via `SessionBrokerClient.send()` — this
* connection's `conn.sessionPubkey` server-side is the session pubkey,
* so the broker's existing fan-out attribution
* (`senderPubkey: conn.sessionPubkey ?? conn.memberPubkey`) just works.
* Pre-1.34.0 every drain went through DaemonBrokerClient (member-WS),
* so every push showed up as "from <daemon-member-pubkey>" regardless
* of which session typed `claudemesh send`.
*
* Old brokers reply with `unknown_message_type` on session_hello — we
* surface that as a one-shot `error` event and the daemon decides
@@ -37,9 +45,27 @@ import { hostname as osHostname } from "node:os";
import type { JoinedMesh } from "~/services/config/facade.js";
import { signSessionHello } from "~/services/broker/session-hello-sig.js";
import { connectWsWithBackoff, type WsLifecycle, type WsStatus } from "./ws-lifecycle.js";
import type { BrokerSendArgs, BrokerSendResult } from "./broker.js";
export type SessionBrokerStatus = WsStatus;
/** Ack-tracking shape, mirrors DaemonBrokerClient.PendingAck. Kept
* internal — callers see only the resolved BrokerSendResult. */
interface PendingAck {
resolve: (r: BrokerSendResult) => void;
timer: NodeJS.Timeout;
}
const SEND_ACK_TIMEOUT_MS = 15_000;
/** Heuristic: which broker-reported send errors are permanent enough
* that the drain worker should give up rather than retry. Mirrors the
* daemon-WS classifier so behavior is identical regardless of which
* socket the row went out on. */
function classifyPermanent(error: string): boolean {
return /unknown|invalid|forbidden|not_authorized|target_not_found/i.test(error);
}
export interface ParentAttestation {
sessionPubkey: string;
parentMemberPubkey: string;
@@ -86,6 +112,14 @@ export class SessionBrokerClient {
/** Set when the broker rejects session_hello with `unknown_message_type` —
* older brokers without the 1.30.0 surface. We stop retrying. */
private brokerUnsupported = false;
/** 1.34.0: outbound send tracking. Keyed by client_message_id. The
* drain worker registers an entry on dispatch; the WS message
* handler resolves it on broker `ack`. Times out after 15s. */
private pendingAcks = new Map<string, PendingAck>();
/** 1.34.0: dispatchers queued while the WS is reconnecting — flushed
* in onStatusChange when status flips to `open`. Mirrors the
* daemon-WS `opens` array. */
private opens: Array<() => void> = [];
constructor(private opts: SessionBrokerOptions) {}
@@ -151,10 +185,50 @@ export class SessionBrokerClient {
return;
}
// 1.34.0: outbox `send` ack arriving on the session-WS. Resolves
// the Promise the drain worker is awaiting. Mirrors the
// daemon-WS handler exactly.
if (msg.type === "ack") {
const id = String(msg.id ?? "");
const ack = this.pendingAcks.get(id);
if (ack) {
this.pendingAcks.delete(id);
clearTimeout(ack.timer);
if (typeof msg.error === "string" && msg.error.length > 0) {
ack.resolve({ ok: false, error: msg.error, permanent: classifyPermanent(msg.error) });
} else {
ack.resolve({ ok: true, messageId: String(msg.messageId ?? id) });
}
}
return;
}
// 1.32.1 — DMs targeted at the launched session's pubkey arrive
// here, NOT on the daemon's member-keyed WS. Forward to the
// daemon-level push handler so they land in inbox.db.
if (msg.type === "push" || msg.type === "inbound") {
// 1.34.9: skip system events on the session-WS — the daemon-WS
// already receives the same broker broadcast and publishes it
// to the bus, so forwarding here just produces duplicate
// `[system] Peer "X" joined the mesh` channel pushes (one per
// connection: 1 member-WS + 1 session-WS = 2 messages, +
// another set per sibling session). Caught in the 2026-05-04
// peer-rejoin smoke.
if ((msg as Record<string, unknown>).subtype === "system") return;
// 1.34.8: drop self-echoes. Some broker fan-out paths mirror an
// outbound DM back to the originating session-WS; without this
// guard the sender's own message lands in inbox.db, publishes a
// `message` bus event, and Claude Code surfaces it as
// `← claudemesh: <self>: <text>` immediately after the user
// typed `claudemesh send`. Caught in the 2026-05-04 two-session
// smoke. Match on session pubkey only — sibling sessions of the
// same member share `senderMemberPubkey`, so a member-level
// filter would wrongly drop legit sibling DMs.
const senderPubkey = String((msg as Record<string, unknown>).senderPubkey ?? "").toLowerCase();
if (senderPubkey && senderPubkey === this.opts.sessionPubkey.toLowerCase()) {
this.log("info", "self_echo_dropped", { sender: senderPubkey.slice(0, 12) });
return;
}
this.opts.onPush?.(msg);
return;
}
@@ -162,6 +236,21 @@ export class SessionBrokerClient {
onStatusChange: (s) => {
this._status = s;
this.opts.onStatusChange?.(s);
if (s === "open") {
// 1.34.0: flush queued send dispatchers so any outbox row that
// tried to dispatch while we were reconnecting goes out now.
const queued = this.opens.slice();
this.opens.length = 0;
for (const fn of queued) {
try { fn(); } catch (e) { this.log("warn", "session_open_handler_failed", { err: String(e) }); }
}
} else if (s === "closed" || s === "reconnecting") {
// Fail any in-flight acks so the drain worker can retry/backoff
// instead of hanging on a dead promise. The daemon-WS does the
// same thing via onBeforeReconnect; we centralize it here
// because session-broker uses status transitions directly.
this.failPendingAcks(`session_ws_${s}`);
}
},
log: (level, msg, meta) => this.log(level, `session_broker_${msg}`, meta),
});
@@ -181,6 +270,72 @@ export class SessionBrokerClient {
} catch { /* drop; lease re-delivers */ }
}
/** True when underlying socket is OPEN-ready for direct sends. */
isOpen(): boolean {
const sock = this.lifecycle?.ws;
return !!sock && sock.readyState === sock.OPEN;
}
/**
* 1.34.0 — Send one outbox row over the session-WS. Same wire format
* as DaemonBrokerClient.send, but routed via this connection so the
* broker's fan-out attributes the push to the session pubkey.
*
* Used by the drain worker for rows whose `sender_session_pubkey`
* matches this client's session pubkey. When the WS is reconnecting
* the dispatcher is queued via `opens` and flushed on the next
* status flip.
*/
send(req: BrokerSendArgs): Promise<BrokerSendResult> {
return new Promise<BrokerSendResult>((resolve) => {
const dispatch = () => {
if (!this.isOpen() || !this.lifecycle) {
resolve({ ok: false, error: "session_ws_not_open", permanent: false });
return;
}
const id = req.client_message_id;
const timer = setTimeout(() => {
if (this.pendingAcks.delete(id)) {
resolve({ ok: false, error: "ack_timeout", permanent: false });
}
}, SEND_ACK_TIMEOUT_MS);
this.pendingAcks.set(id, { resolve, timer });
try {
this.lifecycle.send({
type: "send",
id,
client_message_id: id,
request_fingerprint: req.request_fingerprint_hex,
targetSpec: req.targetSpec,
priority: req.priority,
nonce: req.nonce,
ciphertext: req.ciphertext,
});
} catch (e) {
this.pendingAcks.delete(id);
clearTimeout(timer);
resolve({ ok: false, error: `ws_write_failed: ${String(e)}`, permanent: false });
}
};
if (this._status === "open") dispatch();
else this.opens.push(dispatch);
});
}
/** Resolve every in-flight ack with a synthetic failure. Called on
* WS close so the drain worker stops waiting and either retries or
* reroutes via the daemon-WS. */
private failPendingAcks(reason: string): void {
if (this.pendingAcks.size === 0) return;
const entries = [...this.pendingAcks.entries()];
this.pendingAcks.clear();
for (const [, ack] of entries) {
clearTimeout(ack.timer);
ack.resolve({ ok: false, error: reason, permanent: false });
}
}
async close(): Promise<void> {
this.closed = true;
if (this.lifecycle) {

View File

@@ -97,7 +97,15 @@ Message (resource form)
[--self] (allow targeting your own member/session pubkey;
fans out to every sibling session of your member)
[--json] (machine-readable result)
claudemesh message inbox drain pending (alias: inbox)
claudemesh message inbox read persisted inbox (alias: inbox)
flags: [--mesh <slug>] [--limit N] [--unread] [--json]
reads ~/.claudemesh/daemon/inbox.db via daemon
--unread → only rows never surfaced before (seen_at IS NULL);
listing stamps returned rows seen as a side effect
claudemesh inbox flush bulk-delete inbox rows
flags: [--mesh <slug>] [--before <iso-timestamp>] [--all]
--all required when neither --mesh nor --before is set
claudemesh inbox delete <id> delete one inbox row by id (alias: rm)
claudemesh message status <id> delivery status (alias: msg-status)
Memory (resource form)
@@ -190,16 +198,18 @@ Security
claudemesh backup [file] encrypt config → portable recovery file
claudemesh restore <file> restore config from a backup file
Daemon (long-lived peer mesh runtime, v0.9.0)
claudemesh daemon up start daemon (alias: start) [--mesh <slug>] [--no-tcp]
Daemon (long-lived peer mesh runtime — universal across every joined mesh)
claudemesh daemon up start daemon (alias: start) [--no-tcp]
claudemesh daemon status show running pid + IPC health [--json]
claudemesh daemon down stop daemon (alias: stop)
claudemesh daemon version ipc + schema version of running daemon
claudemesh daemon outbox list list local outbox rows [--failed|--pending|--inflight|--done]
claudemesh daemon outbox requeue <id> re-enqueue an aborted/dead row [--new-client-id <id>]
claudemesh daemon accept-host pin current host fingerprint
claudemesh daemon install-service --mesh <slug> write launchd / systemd-user unit
claudemesh daemon uninstall-service remove the unit
claudemesh daemon install-service write launchd / systemd-user unit
claudemesh daemon uninstall-service remove the unit
Note: the daemon attaches to every mesh in ~/.claudemesh/config.json
automatically; --mesh on up / install-service is deprecated and ignored.
Setup
claudemesh install register MCP server + hooks
@@ -394,7 +404,30 @@ async function main(): Promise<void> {
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }); break; }
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
case "inbox": {
const sub = positionals[0];
if (sub === "flush") {
const { runInboxFlush } = await import("~/commands/inbox-actions.js");
await runInboxFlush({
mesh: flags.mesh as string | undefined,
before: flags.before as string | undefined,
all: !!flags.all,
json: !!flags.json,
});
} else if (sub === "delete" || sub === "rm") {
const { runInboxDelete } = await import("~/commands/inbox-actions.js");
await runInboxDelete(positionals[1] ?? "", { json: !!flags.json });
} else {
const { runInbox } = await import("~/commands/inbox.js");
await runInbox({
mesh: flags.mesh as string | undefined,
json: !!flags.json,
limit: typeof flags.limit === "number" ? flags.limit : (typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined),
unread: !!flags.unread,
});
}
break;
}
case "state": {
const sub = positionals[0];
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
@@ -466,6 +499,11 @@ async function main(): Promise<void> {
publicHealth: !!flags["public-health"],
mesh: flags.mesh as string | undefined,
displayName: flags.name as string | undefined,
// 1.34.12: --foreground opts out of the new "detach by default"
// behavior. install-service and `claudemesh launch`'s auto-spawn
// path always run with --foreground so their parents (launchd /
// the launch helper) own lifecycle and stdio redirection.
foreground: !!flags.foreground,
outboxStatus,
newClientId: flags["new-client-id"] as string | undefined,
}, rest);
@@ -530,7 +568,29 @@ async function main(): Promise<void> {
case "message": {
const sub = positionals[0];
if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
else if (sub === "inbox") { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); }
else if (sub === "inbox") {
const sub2 = positionals[1];
if (sub2 === "flush") {
const { runInboxFlush } = await import("~/commands/inbox-actions.js");
await runInboxFlush({
mesh: flags.mesh as string | undefined,
before: flags.before as string | undefined,
all: !!flags.all,
json: !!flags.json,
});
} else if (sub2 === "delete" || sub2 === "rm") {
const { runInboxDelete } = await import("~/commands/inbox-actions.js");
await runInboxDelete(positionals[2] ?? "", { json: !!flags.json });
} else {
const { runInbox } = await import("~/commands/inbox.js");
await runInbox({
mesh: flags.mesh as string | undefined,
json: !!flags.json,
limit: typeof flags.limit === "number" ? flags.limit : (typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined),
unread: !!flags.unread,
});
}
}
else if (sub === "status") { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
else { console.error("Usage: claudemesh message <send|inbox|status>"); process.exit(EXIT.INVALID_ARGS); }
break;

View File

@@ -30,8 +30,9 @@ import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { existsSync } from "node:fs";
import { existsSync, appendFileSync } from "node:fs";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { join } from "node:path";
import { DAEMON_PATHS } from "~/daemon/paths.js";
import { VERSION } from "~/constants/urls.js";
@@ -69,10 +70,15 @@ function bailNoDaemon(): never {
interface DaemonGetResult { status: number; body: any }
function daemonGet(path: string): Promise<DaemonGetResult> {
function daemonGet(path: string, opts: { sessionToken?: string | null } = {}): Promise<DaemonGetResult> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
// 1.34.2+: when the launched process gave us a session token, forward
// it on every IPC. Routes like `/v1/sessions/me` 401 without it, and
// routes like `/v1/peers` use it for default-mesh scoping.
if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`;
const req = httpRequest(
{ socketPath: DAEMON_PATHS.SOCK_FILE, path, method: "GET", timeout: 5_000 },
{ socketPath: DAEMON_PATHS.SOCK_FILE, path, method: "GET", timeout: 5_000, headers },
(res: IncomingMessage) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
@@ -90,21 +96,54 @@ function daemonGet(path: string): Promise<DaemonGetResult> {
});
}
/** 1.34.8: best-effort POST /v1/inbox/seen so the MCP can stamp rows it
* just surfaced via a `<channel>` reminder. Failures are swallowed —
* read-state is a UX optimization, not a correctness gate. */
function daemonMarkSeen(ids: string[], sessionToken?: string | null): Promise<void> {
return new Promise((resolve) => {
if (ids.length === 0) { resolve(); return; }
const body = JSON.stringify({ ids });
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
};
if (sessionToken) headers.Authorization = `ClaudeMesh-Session ${sessionToken}`;
const req = httpRequest(
{ socketPath: DAEMON_PATHS.SOCK_FILE, path: "/v1/inbox/seen", method: "POST", timeout: 3_000, headers },
(res: IncomingMessage) => { res.on("data", () => { /* drain */ }); res.on("end", () => resolve()); },
);
req.on("error", () => resolve());
req.on("timeout", () => { req.destroy(); resolve(); });
req.write(body);
req.end();
});
}
// ── daemon SSE subscription ────────────────────────────────────────────
interface DaemonEvent { kind: string; ts: string; data: Record<string, any> }
function subscribeEvents(onEvent: (e: DaemonEvent) => void): { close: () => void } {
function subscribeEvents(onEvent: (e: DaemonEvent) => void, opts: { sessionToken?: string | null } = {}): { close: () => void } {
let active = true;
let req: ReturnType<typeof httpRequest> | null = null;
const connect = (): void => {
if (!active) return;
// 1.34.13: forward the session token on the SSE subscription so the
// daemon's `/v1/events` route can scope the stream to this session
// via the SseFilterOptions demux added in 1.34.10. Without this
// header, `session` resolves to null in the IPC handler, the filter
// is empty, and every MCP receives every event — manifests as
// session A rendering DMs that arrived on B's session-WS. The
// launch helper sets CLAUDEMESH_IPC_TOKEN_FILE in the child env;
// readSessionTokenFromEnv() picks it up at MCP boot time.
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (opts.sessionToken) headers.Authorization = `ClaudeMesh-Session ${opts.sessionToken}`;
req = httpRequest({
socketPath: DAEMON_PATHS.SOCK_FILE,
path: "/v1/events",
method: "GET",
headers: { Accept: "text/event-stream" },
headers,
});
let buffer = "";
req.on("response", (res: IncomingMessage) => {
@@ -166,7 +205,26 @@ export async function startMcpServer(): Promise<void> {
const server = new Server(
{ name: "claudemesh", version: VERSION },
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
{
capabilities: {
tools: {},
prompts: {},
resources: {},
// 1.34.1 — declare the experimental `claude/channel` capability.
// Claude Code v2.1.x gates `notifications/claude/channel` on this
// exact key: its `xJ_(serverName, capabilities, pluginSource)` check
// returns {action:"skip", kind:"capability"} when
// `capabilities.experimental?.["claude/channel"]` is missing, and
// the notification handler is never registered → every channel
// emit lands on the floor, regardless of the
// `--dangerously-load-development-channels server:claudemesh` flag.
// This was the silent regression: pre-2.1.x clients didn't gate on
// this key, so the same MCP wire shape "worked" until Claude Code
// tightened the check. Verified by reading the binary at the
// offsets near `notifications/claude/channel` in the strings dump.
experimental: { "claude/channel": {} },
},
},
);
// Tools: empty. The CLI is the API; the model invokes it via Bash.
@@ -264,8 +322,33 @@ export async function startMcpServer(): Promise<void> {
return { contents: [{ uri, mimeType: "text/markdown", text: fm.join("\n") + skill.instructions }] };
});
// 1.34.1: every channel emit (and SSE event arrival) writes to a
// per-pid log file under ~/.claudemesh/daemon/. Stderr from a Claude
// Code-spawned MCP server isn't surfaced anywhere visible to the
// user; without an on-disk trace we can't tell whether the SSE
// delivered the event, whether the bus reached the MCP, or whether
// server.notification rejected. The file path is stable across MCP
// restarts so users can `tail -f` to watch live.
const mcpLogPath = join(DAEMON_PATHS.DAEMON_DIR, `mcp-${process.pid}.log`);
const mcpLog = (msg: string, meta?: Record<string, unknown>): void => {
const line = JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, msg, ...meta }) + "\n";
try { appendFileSync(mcpLogPath, line); } catch { /* logging must never crash */ }
};
mcpLog("mcp_started", { version: VERSION });
// 1.34.8: forward session token on /v1/inbox/seen so the daemon can
// resolve mesh scoping if it ever needs to. We read it once here and
// capture it in the closure since the MCP runs for the lifetime of
// the session; the env var doesn't rotate mid-process.
const { readSessionTokenFromEnv } = await import("~/services/session/token.js");
const sessionTokenForSeen = readSessionTokenFromEnv();
// Subscribe to daemon events; translate to channel notifications.
// 1.34.13: pass the session token so the daemon scopes the SSE
// stream via SseFilterOptions. Re-uses the same token already read
// for /v1/inbox/seen above.
const sub = subscribeEvents(async (ev) => {
mcpLog("sse_event_received", { kind: ev.kind });
if (ev.kind === "message") {
const d = ev.data;
const fromName = String(d.sender_name ?? "unknown");
@@ -295,17 +378,51 @@ export async function startMcpServer(): Promise<void> {
},
},
});
mcpLog("channel_emitted", { content_preview: content.slice(0, 80), mesh: String(d.mesh ?? "") });
// 1.34.8: this row was just surfaced inline as a channel
// reminder; mark it seen so the next launch's welcome doesn't
// re-surface it as "unread." Best-effort: a failure here just
// means the welcome will list one extra row, not data loss.
const inboxRowId = String(d.id ?? "");
if (inboxRowId) {
void daemonMarkSeen([inboxRowId], sessionTokenForSeen).catch(() => { /* swallow */ });
}
} catch (err) {
mcpLog("channel_emit_failed", { err: String(err) });
process.stderr.write(`[claudemesh-mcp] channel emit failed: ${err}\n`);
}
} else if (ev.kind === "peer_join" || ev.kind === "peer_leave" || ev.kind === "system") {
const d = ev.data;
const eventName = String(d.event ?? ev.kind);
// 1.34.9: enrich peer_join/leave with the context the broker
// already ships (name, pubkey prefix, groups, returning summary).
// Pre-1.34.9 we surfaced just the displayName, which is ambiguous
// when two sessions share a name (e.g. two `agutierrez` peers in
// different cwds). Pubkey prefix disambiguates; groups hint at
// role (e.g. "[ops, devs]"). cwd / role aren't in the broker
// event yet, so they're skipped — adding them broker-side is a
// separate ship.
const renderPeerLine = (verb: string): string => {
const name = String(d.name ?? "unknown");
const pubkey = String(d.pubkey ?? "");
const pubkeyTag = pubkey ? ` (${pubkey.slice(0, 8)})` : "";
const groups = Array.isArray(d.groups) ? d.groups : [];
const groupNames = groups
.map((g) => (typeof g === "object" && g !== null && "name" in g ? String((g as { name: unknown }).name) : typeof g === "string" ? g : ""))
.filter(Boolean);
const groupsTag = groupNames.length > 0 ? ` [${groupNames.join(", ")}]` : "";
const lastSeen = typeof d.lastSeenAt === "string" ? d.lastSeenAt : null;
const summary = typeof d.summary === "string" && d.summary.trim() ? d.summary.trim() : null;
const returningTail = lastSeen
? ` — last seen ${new Date(lastSeen).toLocaleTimeString()}${summary ? ` · "${summary.slice(0, 80)}"` : ""}`
: "";
return `[system] Peer "${name}"${pubkeyTag}${groupsTag} ${verb} the mesh${returningTail}`;
};
let content: string;
if (ev.kind === "peer_join") {
content = `[system] Peer "${String(d.name ?? "unknown")}" joined the mesh`;
content = renderPeerLine(eventName === "peer_returned" ? "returned to" : "joined");
} else if (ev.kind === "peer_leave") {
content = `[system] Peer "${String(d.name ?? "unknown")}" left the mesh`;
content = renderPeerLine("left");
} else {
content = `[system] ${eventName}: ${JSON.stringify(d).slice(0, 240)}`;
}
@@ -318,12 +435,55 @@ export async function startMcpServer(): Promise<void> {
kind: "system",
event: eventName,
mesh_slug: String(d.mesh ?? ""),
...(typeof d.name === "string" ? { peer_name: d.name } : {}),
...(typeof d.pubkey === "string" ? { peer_pubkey: d.pubkey } : {}),
...(Array.isArray(d.groups) ? { peer_groups: JSON.stringify(d.groups) } : {}),
...(typeof d.lastSeenAt === "string" ? { peer_last_seen_at: d.lastSeenAt } : {}),
...(typeof d.summary === "string" ? { peer_summary: d.summary } : {}),
},
},
});
} catch { /* best effort */ }
}
});
}, { sessionToken: sessionTokenForSeen });
// 1.34.6 — Welcome: single emit on oninitialized + 3s grace.
//
// The earlier "timing race" theory was wrong. Reading Claude Code's
// binary at the `notifications/claude/channel` Zod schema:
//
// IJ_ = y.object({
// method: y.literal("notifications/claude/channel"),
// params: y.object({
// content: y.string(),
// meta: y.record(y.string(), y.string()).optional()
// })
// })
//
// `meta` MUST be a record of string-to-string. Pre-1.34.6 the
// welcome shipped numbers (`peer_count`, `unread_count`) and arrays
// (`peer_names`, `latest_message_ids`) — Zod rejected the entire
// notification before it ever reached the channel handler.
//
// Live peer DMs always survived because their meta values all went
// through `String(...)`. The welcome was the only notification
// shape with non-string meta — uniquely affected, schema-rejected,
// silently dropped.
//
// 1.34.6 fixes the meta values (see `emitMeshWelcome`) so the
// notification passes validation; the dual-lane retry from 1.34.5
// is no longer necessary and would now surface a duplicate. Back to
// a single emit, with a 3s grace after `oninitialized` — enough for
// the React effect that registers the channel handler to run, but
// tight enough to feel like a launch handshake.
const WELCOME_GRACE_MS = 3_000;
let welcomeSent = false;
server.oninitialized = () => {
mcpLog("server_initialized");
if (welcomeSent) return;
welcomeSent = true;
setTimeout(() => { void emitMeshWelcome(server, mcpLog); }, WELCOME_GRACE_MS);
};
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -341,6 +501,193 @@ export async function startMcpServer(): Promise<void> {
process.on("SIGINT", shutdown);
}
/**
* Mesh-connected welcome. Runs once 5s after the MCP transport is up,
* regardless of inbox state. The point isn't just to summarize unread —
* an empty welcome still confirms to the user that the mesh pipe is
* live, names the session, says how many peers are visible, and lists
* the canonical CLI commands so the model can use them mid-turn.
*
* Composes from up to three best-effort daemon queries:
* - `/v1/sessions/me` → display name + session pubkey + mesh
* (requires session token; absent on bare `claudemesh mcp`)
* - `/v1/peers?mesh=…` → live peer count, filtered to non-control-plane
* - `/v1/inbox?…` → recent message count + up to 3 previews
*
* Each query degrades silently — a missing field becomes "unknown" or
* is omitted. The welcome ALWAYS emits unless the IPC socket is
* unreachable; that's the design contract: "you launched into the
* mesh, here's what you've got."
*/
async function emitMeshWelcome(
server: import("@modelcontextprotocol/sdk/server/index.js").Server,
mcpLog: (msg: string, meta?: Record<string, unknown>) => void,
): Promise<void> {
const { readSessionTokenFromEnv } = await import("~/services/session/token.js");
const sessionToken = readSessionTokenFromEnv();
// 1) Self identity. Token-less path (bare `claudemesh mcp` outside a
// launch) just leaves these undefined; the welcome still goes out.
let selfDisplayName: string | undefined;
let selfSessionPubkey: string | undefined;
let selfMeshSlug: string | undefined;
let selfRole: string | undefined;
if (sessionToken) {
try {
const { status, body } = await daemonGet("/v1/sessions/me", { sessionToken });
if (status === 200 && body?.session) {
selfDisplayName = body.session.displayName;
selfMeshSlug = body.session.mesh;
selfRole = body.session.role;
selfSessionPubkey = body.session.presence?.sessionPubkey;
}
} catch (e) { mcpLog("welcome_self_lookup_failed", { err: String(e) }); }
}
// 2) Live peer count. Match the same filter the launch banner uses
// (`channel !== "claudemesh-daemon"`) so the welcome's number agrees
// with the "N peers online" line that just printed in the terminal.
// We also fall back to `peerRole !== "control-plane"` for newer
// brokers that emit the role taxonomy. Excluding self uses both
// session pubkey AND session id (older brokers may not surface
// peerRole, so name-only matching would fail).
let peerCount = -1;
let peerNames: string[] = [];
try {
const path = selfMeshSlug ? `/v1/peers?mesh=${encodeURIComponent(selfMeshSlug)}` : "/v1/peers";
const { status, body } = await daemonGet(path, { sessionToken });
if (status === 200 && Array.isArray(body?.peers)) {
const peers = body.peers as Array<Record<string, unknown>>;
const real = peers.filter((p) => {
const channel = String(p.channel ?? "");
const peerRole = String(p.peerRole ?? "");
const isInfra = channel === "claudemesh-daemon" || peerRole === "control-plane";
if (isInfra) return false;
if (selfSessionPubkey && p.pubkey === selfSessionPubkey) return false;
return true;
});
peerCount = real.length;
peerNames = real
.map((p) => String(p.displayName ?? "unknown"))
.filter((n, i, arr) => arr.indexOf(n) === i)
.slice(0, 5);
mcpLog("welcome_peers_resolved", { total: peers.length, real: real.length });
} else {
mcpLog("welcome_peers_status", { status });
}
} catch (e) { mcpLog("welcome_peers_lookup_failed", { err: String(e) }); }
// 3) Unread inbox. 1.34.8 replaced the "last 24h" window with the
// proper read-state filter — `?unread_only=true` returns rows whose
// `seen_at` is NULL. The list call uses `mark_seen=false` so the
// welcome doesn't auto-stamp; we stamp explicitly via /v1/inbox/seen
// *after* we know the channel notification went out (otherwise a
// schema rejection would silently mark rows seen that the user
// never actually saw — the original 1.34.6 bug shape).
const inboxPath = selfMeshSlug
? `/v1/inbox?mesh=${encodeURIComponent(selfMeshSlug)}&unread_only=true&mark_seen=false&limit=50`
: `/v1/inbox?unread_only=true&mark_seen=false&limit=50`;
let inboxItems: Array<Record<string, unknown>> = [];
try {
const { status, body } = await daemonGet(inboxPath, { sessionToken });
if (status === 200 && Array.isArray(body?.items)) {
inboxItems = body.items as Array<Record<string, unknown>>;
}
} catch (e) { mcpLog("welcome_inbox_lookup_failed", { err: String(e) }); }
// Compose the body. Markdown-friendly so it renders cleanly in the
// Claude Code channel reminder block.
const lines: string[] = [];
const idTag = selfDisplayName
? `${selfDisplayName}${selfSessionPubkey ? ` (${selfSessionPubkey.slice(0, 8)})` : ""}${selfRole ? ` [${selfRole}]` : ""}`
: "session";
const meshTag = selfMeshSlug ? ` on mesh \`${selfMeshSlug}\`` : "";
lines.push(`🌐 [welcome] claudemesh connected — you are **${idTag}**${meshTag}.`);
if (peerCount === 0) {
lines.push(`👥 No other peers online right now.`);
} else if (peerCount > 0) {
const namesPreview = peerNames.join(", ");
const more = peerCount > peerNames.length ? ` …and ${peerCount - peerNames.length} more` : "";
lines.push(`👥 ${peerCount} peer${peerCount === 1 ? "" : "s"} online: ${namesPreview}${more}`);
} else {
lines.push(`👥 Peer list unavailable (daemon query failed).`);
}
if (inboxItems.length === 0) {
lines.push(`📥 No unread messages.`);
} else {
lines.push(`📥 ${inboxItems.length} unread message${inboxItems.length === 1 ? "" : "s"}:`);
for (const it of inboxItems.slice(0, 3)) {
const sender = String(it.sender_name ?? "unknown");
const senderPub = String(it.sender_pubkey ?? "").slice(0, 8);
const tag = sender !== senderPub ? `${sender} (${senderPub})` : senderPub;
const bodyText = (typeof it.body === "string" ? it.body : "(encrypted)").slice(0, 60);
const time = it.received_at ? new Date(String(it.received_at)).toLocaleTimeString() : "";
lines.push(` ${tag} ${time}: ${bodyText}`);
}
if (inboxItems.length > 3) lines.push(` …and ${inboxItems.length - 3} more`);
}
// CLI hints — what the model should call when the user asks. Listed
// here as a one-liner so the welcome stays compact.
lines.push(`💡 Use: \`claudemesh peer list\` · \`claudemesh send <peer> <msg>\` · \`claudemesh inbox\``);
// Skill pointer — the `claudemesh` skill in the user's Claude install
// documents every CLI verb, JSON shapes, channel attributes, and
// common patterns. If the model isn't already loaded with it, this is
// the cue to read it once before acting on the mesh.
lines.push(`📚 Read the \`claudemesh\` skill (SKILL.md) for full CLI / channel / inbox reference if not yet in context.`);
const content = lines.join("\n");
try {
// Claude Code's `notifications/claude/channel` schema is
// `meta: y.record(y.string(), y.string())` — string values only.
// Pre-1.34.6 we sent numbers / arrays in `peer_count`, `unread_count`,
// `peer_names`, `latest_message_ids`; Zod silently rejected the
// whole notification before it reached the channel handler. Live
// peer DMs survived because their meta values all went through
// `String(...)`. Coerce everything here too — arrays stringify as
// JSON so downstream consumers can re-parse if they want, and the
// counts become digit strings (parseable on the receiving side).
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
kind: "welcome",
self_display_name: selfDisplayName ?? "",
self_session_pubkey: selfSessionPubkey ?? "",
self_role: selfRole ?? "",
mesh_slug: selfMeshSlug ?? "",
peer_count: peerCount >= 0 ? String(peerCount) : "",
peer_names: JSON.stringify(peerNames),
unread_count: String(inboxItems.length),
latest_message_ids: JSON.stringify(
inboxItems.slice(0, 10).map((it) => String(it.id ?? "")),
),
},
},
});
mcpLog("welcome_emitted", {
mesh: selfMeshSlug ?? "",
peer_count: peerCount,
unread_count: inboxItems.length,
});
// 1.34.8: stamp the rows we just surfaced. Done AFTER the
// notification succeeds so a Zod-rejected welcome (the 1.34.6 bug
// shape) doesn't silently mark rows seen that the user never
// actually saw. Best-effort.
if (inboxItems.length > 0) {
const ids = inboxItems.map((it) => String(it.id ?? "")).filter(Boolean);
if (ids.length > 0) {
void daemonMarkSeen(ids, sessionToken).catch(() => { /* swallow */ });
}
}
} catch (err) {
mcpLog("welcome_emit_failed", { err: String(err) });
}
}
// ── mesh-service proxy mode (unchanged from prior versions) ────────────
/**

View File

@@ -52,6 +52,105 @@ export async function tryListPeersViaDaemon(mesh?: string): Promise<unknown[] |
}
}
/**
* 1.34.0 — Try fetching the persisted inbox from the daemon.
*
* Reads from `~/.claudemesh/daemon/inbox.db` via `/v1/inbox`. This is
* the authoritative source of received messages — pushes from the
* broker land here through the daemon's session-WS / member-WS push
* handler. The pre-1.34.0 cold-path inbox command opened a fresh
* BrokerClient and drained an empty in-memory buffer, which never
* matched what the daemon was actually receiving.
*/
export interface InboxItem {
id: string;
client_message_id: string;
broker_message_id: string | null;
mesh: string;
topic: string | null;
sender_pubkey: string;
sender_name: string;
body: string | null;
received_at: string;
reply_to_id: string | null;
/** 1.34.8: ISO timestamp of when the row was first surfaced to the
* user (interactive listing or live channel reminder). `null` =
* never seen. */
seen_at?: string | null;
}
export async function tryListInboxViaDaemon(
mesh?: string,
limit = 100,
opts: { unreadOnly?: boolean; markSeen?: boolean } = {},
): Promise<InboxItem[] | null> {
if (!(await daemonReachable())) return null;
try {
const params: string[] = [`limit=${limit}`];
if (mesh) params.push(`mesh=${encodeURIComponent(mesh)}`);
// 1.34.8: read-state filters. `unread_only=true` narrows to seen_at
// IS NULL; `mark_seen=false` lets the caller peek without flipping
// the seen flag (used by the welcome push on the MCP side, not the
// CLI). Default behavior matches pre-1.34.8 — return everything
// and stamp it seen — so existing callers keep working.
if (opts.unreadOnly) params.push("unread_only=true");
if (opts.markSeen === false) params.push("mark_seen=false");
const path = `/v1/inbox?${params.join("&")}`;
const res = await ipc<{ items?: InboxItem[] }>({ path, timeoutMs: 3_000 });
if (res.status !== 200) return null;
return Array.isArray(res.body.items) ? res.body.items : [];
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
/**
* 1.34.7: bulk-delete inbox rows. `mesh` scopes to one mesh (omit =
* across every attached mesh); `beforeIso` filters by `received_at <
* Date.parse(beforeIso)`. Returns the number of rows removed, or null
* when the daemon couldn't be reached.
*/
export async function tryFlushInboxViaDaemon(
args: { mesh?: string; beforeIso?: string } = {},
): Promise<number | null> {
if (!(await daemonReachable())) return null;
try {
const params: string[] = [];
if (args.mesh) params.push(`mesh=${encodeURIComponent(args.mesh)}`);
if (args.beforeIso) params.push(`before=${encodeURIComponent(args.beforeIso)}`);
const path = `/v1/inbox${params.length ? `?${params.join("&")}` : ""}`;
const res = await ipc<{ removed?: number }>({ path, method: "DELETE", timeoutMs: 3_000 });
if (res.status !== 200) return null;
return typeof res.body.removed === "number" ? res.body.removed : null;
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
/** 1.34.7: delete one inbox row by id. Returns true iff the row was
* removed; false on 404; null on transport failure. */
export async function tryDeleteInboxRowViaDaemon(id: string): Promise<boolean | null> {
if (!(await daemonReachable())) return null;
try {
const res = await ipc<{ removed?: number }>({
path: `/v1/inbox/${encodeURIComponent(id)}`,
method: "DELETE",
timeoutMs: 3_000,
});
if (res.status === 404) return false;
if (res.status !== 200) return null;
return (res.body.removed ?? 0) > 0;
} catch (err) {
const msg = String(err);
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
return null;
}
}
/** Try fetching mesh-published skills through the daemon. */
export async function tryListSkillsViaDaemon(mesh?: string): Promise<unknown[] | null> {
if (!(await daemonReachable())) return null;

View File

@@ -220,8 +220,12 @@ async function spawnDaemon(opts: EnsureDaemonOpts): Promise<SpawnResult> {
try {
const { spawn } = await import("node:child_process");
const binary = await resolveCliBinary();
const args = ["daemon", "up"];
if (opts.mesh) args.push("--mesh", opts.mesh);
// 1.34.12: pass --foreground because the lifecycle helper IS the
// detacher in this path — it spawns with detached:true + stdio:
// ignore. If we let the child re-detach (the new default), we'd
// double-fork and orphan the grandchild. --mesh is dropped (1.34.10
// deprecation; daemon attaches to every joined mesh).
const args = ["daemon", "up", "--foreground"];
const child = spawn(binary, args, {
detached: true,