Files
claudemesh/apps/cli/src/commands/inbox.ts
Alejandro Gutiérrez 6780899185
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
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>
2026-05-04 21:10:07 +01:00

102 lines
4.1 KiB
TypeScript

/**
* `claudemesh inbox` — read pending peer messages from the daemon's
* persisted inbox (`~/.claudemesh/daemon/inbox.db`).
*
* 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 { 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;
/** 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;
}
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> {
// 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;
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");
}
}