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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user