feat(cli): 1.34.7 → 1.34.13 — multi-session correctness train
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
91
apps/cli/src/commands/inbox-actions.ts
Normal file
91
apps/cli/src/commands/inbox-actions.ts
Normal 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}.`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
73
apps/cli/src/daemon/inbox-pruner.ts
Normal file
73
apps/cli/src/daemon/inbox-pruner.ts
Normal 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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user