feat(workspace): claudemesh me notifications + dashboard parity
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

ships v0.4.0 phase 3.

api: GET /v1/me/notifications aggregates the mesh.notification
table across every joined mesh in a 7-day window (?since=iso
overrides, ?include=all surfaces already-read). returns sender +
topic + mesh context plus a 240-char snippet for v1 plaintext
messages or raw ciphertext for v2 (the dashboard topic-key cache
decrypts client-side).

cli (1.12.0): claudemesh me notifications — terse unread feed
with @ dot, --all to include read, --since for custom window.

web: /dashboard/notifications mirrors the cli view in card form,
adds a notifications entry to the dashboard sidebar between
topics and invites. each card links straight to the topic chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 02:35:57 +01:00
parent 1c335e8daa
commit 43e429f204
8 changed files with 487 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "1.11.0",
"version": "1.12.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",

View File

@@ -200,6 +200,100 @@ export async function runMeTopics(flags: MeTopicsFlags): Promise<number> {
);
}
interface WorkspaceNotification {
notificationId: string;
messageId: string;
topicId: string;
topicName: string;
meshId: string;
meshSlug: string;
meshName: string;
senderName: string | null;
snippet: string | null;
ciphertext: string | null;
bodyVersion: number;
read: boolean;
readAt: string | null;
createdAt: string;
}
interface WorkspaceNotificationsResponse {
notifications: WorkspaceNotification[];
totals: { unread: number; total: number };
}
export interface MeNotificationsFlags extends MeFlags {
all?: boolean;
since?: string;
}
export async function runMeNotifications(
flags: MeNotificationsFlags,
): Promise<number> {
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: "workspace-notifications",
capabilities: ["read"],
},
async ({ secret }) => {
const params = new URLSearchParams();
if (flags.all) params.set("include", "all");
if (flags.since) params.set("since", flags.since);
const path =
"/api/v1/me/notifications" +
(params.toString() ? `?${params.toString()}` : "");
const ws = await request<WorkspaceNotificationsResponse>({
path,
token: secret,
});
if (flags.json) {
console.log(JSON.stringify(ws, null, 2));
return EXIT.SUCCESS;
}
const headerLabel = flags.all ? "@-mentions (all)" : "@-mentions (unread)";
render.section(
`${clay(headerLabel)}${ws.totals.total} ${dim(
ws.totals.unread > 0 ? `· ${ws.totals.unread} unread` : "· nothing pending",
)}`,
);
if (ws.notifications.length === 0) {
process.stdout.write(
dim(
flags.all
? " no @-mentions in window\n"
: " inbox zero — nothing waiting\n",
),
);
return EXIT.SUCCESS;
}
const slugWidth = Math.max(
...ws.notifications.map((n) => n.meshSlug.length),
6,
);
for (const n of ws.notifications) {
const slug = dim(n.meshSlug.padEnd(slugWidth));
const topic = cyan(`#${n.topicName}`);
const sender = n.senderName ? `from ${n.senderName}` : "from ?";
const ago = formatRelativeTime(n.createdAt);
const dot = n.read ? dim("·") : yellow("●");
const snippet =
n.snippet ?? (n.ciphertext ? dim("[encrypted]") : dim("[empty]"));
process.stdout.write(
` ${dot} ${slug} ${topic} ${dim(sender)} ${dim(ago)}\n` +
` ${snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet}\n`,
);
}
return EXIT.SUCCESS;
},
);
}
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();

View File

@@ -125,6 +125,7 @@ Topic (conversation scope, v0.2.0)
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
claudemesh me cross-mesh workspace overview (v0.4.0)
claudemesh me topics cross-mesh topic list [--unread]
claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO]
claudemesh member list mesh roster with online state [--online]
claudemesh notification list recent @-mentions of you [--since <ISO>]
@@ -687,11 +688,23 @@ async function main(): Promise<void> {
} else if (sub === "topics") {
const { runMeTopics } = await import("~/commands/me.js");
process.exit(await runMeTopics({ ...f, unread: !!flags.unread }));
} else if (sub === "notifications" || sub === "notifs") {
const { runMeNotifications } = await import("~/commands/me.js");
process.exit(
await runMeNotifications({
...f,
all: !!flags.all,
since: flags.since as string | undefined,
}),
);
} else {
console.error(
"Usage: claudemesh me (cross-mesh overview)\n" +
" claudemesh me topics (cross-mesh topic list)\n" +
" claudemesh me topics --unread (only unread topics)",
"Usage: claudemesh me (cross-mesh overview)\n" +
" claudemesh me topics (cross-mesh topic list)\n" +
" claudemesh me topics --unread (only unread topics)\n" +
" claudemesh me notifications (unread @-mentions, last 7d)\n" +
" claudemesh me notifications --all (include already-read)\n" +
" claudemesh me notifications --since=ISO (custom window)",
);
process.exit(EXIT.INVALID_ARGS);
}