chore: wrap up the gap-closing session
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

- info/inbox commands → unified render.ts
- install route: drop in-memory counter, rely on PostHog + structured logs
- docs: roadmap, CLAUDE.md reflect alpha.31 state
- tests workflow now also builds + smoke-tests the CLI bundle
- homebrew tap bootstrap kit in packaging/homebrew-tap-bootstrap/
  (README + copy of the formula template for dropping into the tap repo)
- upstream Claude Code issue draft for rich <channel> UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:53:59 +01:00
parent ee12510ef1
commit 45d85f5eaa
10 changed files with 333 additions and 54 deletions

View File

@@ -0,0 +1,71 @@
# Feature request draft: rich `<channel>` notification UI
**Target:** `anthropics/claude-code` GitHub issues / feedback channel.
**Drafted:** 2026-04-15.
Paste the section below once the issue template is ready. Adjust tone
to match Claude Code's issue style.
---
### Title
Rich UI for `notifications/claude/channel` messages (first-class chat, not just reminders)
### Body
**Summary**
MCP servers can emit `notifications/claude/channel` notifications which
Claude Code renders inside the current turn as a `<channel>` reminder.
For MCP servers that are conversational in nature (peer messaging,
collaborative sessions, delegated agents), rendering these inline as
plain-text reminders misses the UX affordances users expect from chat:
- sender avatar / identity
- timestamp
- priority badge (urgent / normal / low)
- expandable quote from the original thread
- optional inline reply action that calls a specific MCP tool
**Concrete use case**
[claudemesh](https://claudemesh.com) is a peer mesh for Claude Code
sessions. When a peer sends a message it arrives as
`notifications/claude/channel` with structured metadata in `meta`:
```json
{
"method": "notifications/claude/channel",
"params": {
"content": "alice: can you rebase main before deploy?",
"meta": {
"from_id": "<ed25519 hex>",
"from_name": "alice",
"priority": "now",
"sent_at": "2026-04-15T00:00:00Z",
"mesh_slug": "team-platform",
"kind": "direct"
}
}
}
```
Today this renders as a `<channel>` text block — useful, but the user
can't tell at a glance that it's from another human.
**What we'd like**
A hint on the notification (e.g. `meta.display: "chat"`) that lets
Claude Code render it as a chat bubble with the `from_name` as the
speaker, priority visualised, and an optional "Reply" action bound to
a declared MCP tool (`reply_tool_name`).
**Why users would benefit beyond claudemesh**
- Delegated agent frameworks can render sub-agent responses as chat
- Live-pairing MCP servers get a proper UI without inventing their own
- The existing `<channel>` fallback means older clients still see
the same text — additive, not breaking
**Willing to contribute a PR** if the feature is on-roadmap.

View File

@@ -0,0 +1,84 @@
# Invite v2 — CLI migration (server-side already shipped)
## Current state
**Server-side (broker) — DEPLOYED**
- `canonicalInviteV2` bytes format (crypto.ts)
- `verifyInviteV2` signature check
- `claimInviteV2Core` at `POST /invites/:code/claim`
- `sealRootKeyToRecipient` using crypto_box_seal
- Every v1 invite also stores `capability_v2` for cross-compat
- Web route `/api/public/invites/:code/claim` proxies to broker
**Client-side (CLI) — NOT MIGRATED**
The CLI still uses the v1 flow (`enrollWithBroker`) which reads
`mesh_root_key` from the invite token's base64 payload. This means:
- Long URL `/join/<token>` contains the root key
- Short URL `/i/<code>` resolves to the long URL → still contains root key
- Anyone who can read the URL (history, screenshot, mail archive) has the key
## The v2 CLI flow
```
parseInviteLinkV2(url)
→ short URL /i/<code>? GET /api/public/invite-code/:code
→ returns `{ found, code, mesh_slug, broker_url, owner_pubkey,
canonical_v2, expires_at, role }` (NO root_key)
→ generate local x25519 keypair (curve25519)
→ POST /invites/<code>/claim { recipient_x25519_pubkey, display_name }
→ broker verifies capability_v2 signature
→ broker seals mesh.root_key with crypto_box_seal(root_key, our_pubkey)
→ returns { sealed_root_key, mesh_id, member_id, owner_pubkey, canonical_v2 }
→ open sealed_root_key with our x25519 secret key
→ store root_key in ~/.claudemesh/config.json.meshes[].rootKey
(NOT in the invite link — it was never transmitted unsealed)
→ upgrade enroll to use claim response instead of the /join endpoint
```
## What needs to change in the CLI
1. **New file** `apps/cli/src/services/invite/parse-v2.ts`
- Detect short URL, resolve via `/api/public/invite-code/:code`
- Expect the API returns v2 shape (server already has this route; verify field names)
- Generate x25519 keypair via libsodium
- POST to claim endpoint
- Unseal root_key
2. **Conditional in `parseInviteLink`**
- If URL is short-form and broker supports v2, use the new path
- Fall back to v1 for legacy long-form URLs in transit
3. **Config schema** already has `rootKey` per mesh — just write from
unsealed bytes instead of from the token payload.
4. **Spec test** `tests/golden/invite-v2.test.ts`
- Broker already has `claimInviteV2Core` tests; add a CLI-side
end-to-end that hits a local broker and verifies the sealed key
round-trips.
## Why it wasn't rushed in this session
Crypto code deserves review. The server-side v2 shipped weeks ago
with its own testing and audit; the CLI migration needs the same
rigor — at minimum, a test that proves the sealed key we unseal
matches the root_key the broker had in its DB, verified against
`canonical_v2` signature.
The current v1 flow is a known quantity (the root_key-in-URL risk
is documented in the spec). Broker is already v2-ready so when the
CLI migration lands, emails / links can immediately start using the
claim-only short URL without a server deploy.
## Rollout plan
1. Ship CLI v2 path behind `CLAUDEMESH_INVITE_V2=1` env.
2. Dogfood: new invites generated by `claudemesh share` use `/api/public/invite-code/:code` with v2-shape response that omits token; CLI resolves via claim.
3. Verify with `claudemesh verify` safety numbers cross-check.
4. After 2 weeks uneventful, flip default to v2.
5. After 60 days, stop embedding root_key in long URLs entirely.
6. v3 (future): short URL becomes the only form.
## Effort
~1 day of focused crypto + testing. Broker work is done; API work is
done; CLI work is a new parse path + a new enroll path + a few tests.

View File

@@ -45,3 +45,12 @@ jobs:
- name: 🧪 Test - name: 🧪 Test
run: pnpm run test run: pnpm run test
- name: 📦 Build CLI bundle (check size budget)
working-directory: apps/cli
run: pnpm run build
- name: 🔧 CLI smoke — --version + --help
run: |
node apps/cli/dist/entrypoints/cli.js --version
node apps/cli/dist/entrypoints/cli.js --help | head -5

View File

@@ -4,10 +4,12 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
## Structure ## Structure
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws` - `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`. Runs drizzle migrations on startup under pg_advisory_lock.
- `apps/cli/``claudemesh-cli` npm package (CLI + MCP server) - `apps/cli/``claudemesh-cli` npm package (CLI + MCP server). Was `apps/cli-v2/` until 2026-04-15; legacy v0 at branch `legacy-cli-archive` + tag `cli-v0-legacy-final`.
- `apps/web/` — Marketing site + dashboard at claudemesh.com - `apps/web/` — Marketing site + dashboard at claudemesh.com
- `docs/` — Protocol spec, quickstart, FAQ, roadmap - `docs/` — Protocol spec, quickstart, FAQ, roadmap
- `packaging/` — Homebrew formula + winget manifest templates
- `.github/workflows/release-cli.yml` — tag `cli-v*` → 5 platform binaries → GitHub Release with SHA256SUMS
## Key docs ## Key docs
@@ -18,8 +20,10 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
## Deploy ## Deploy
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"` - **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`. Pending migrations apply automatically on startup.
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks` - **CLI:**
- npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts`
- Binaries: `git tag cli-v<version> && git push github cli-v<version>` — workflow builds 5 platforms.
- **Web:** Vercel auto-deploy on push to GitHub - **Web:** Vercel auto-deploy on push to GitHub
## Dev ## Dev

View File

@@ -7,6 +7,8 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js"; import type { InboundPush } from "~/services/broker/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
export interface InboxFlags { export interface InboxFlags {
mesh?: string; mesh?: string;
@@ -14,47 +16,34 @@ export interface InboxFlags {
wait?: number; wait?: number;
} }
function formatMessage(msg: InboundPush, useColor: boolean): string { function formatMessage(msg: InboundPush): string {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`; const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8); const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString(); const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind; const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`; return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
} }
export async function runInbox(flags: InboxFlags): Promise<void> { export async function runInbox(flags: InboxFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const waitMs = (flags.wait ?? 1) * 1000; const waitMs = (flags.wait ?? 1) * 1000;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
// Wait briefly for broker to push any held messages.
await new Promise<void>((resolve) => setTimeout(resolve, waitMs)); await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer(); const messages = client.drainPushBuffer();
if (flags.json) { if (flags.json) {
console.log(JSON.stringify(messages, null, 2)); process.stdout.write(JSON.stringify(messages, null, 2) + "\n");
return; return;
} }
if (messages.length === 0) { if (messages.length === 0) {
console.log(dim(`No messages on mesh "${mesh.slug}".`)); render.info(dim(`No messages on mesh "${mesh.slug}".`));
return; return;
} }
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`)); render.section(`inbox — ${mesh.slug} (${messages.length} message${messages.length === 1 ? "" : "s"})`);
console.log("");
for (const msg of messages) { for (const msg of messages) {
console.log(formatMessage(msg, useColor)); process.stdout.write(formatMessage(msg) + "\n\n");
console.log("");
} }
}); });
} }

View File

@@ -6,6 +6,7 @@
import { withMesh } from "./connect.js"; import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js"; import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
export interface InfoFlags { export interface InfoFlags {
mesh?: string; mesh?: string;
@@ -13,11 +14,6 @@ export interface InfoFlags {
} }
export async function runInfo(flags: InfoFlags): Promise<void> { export async function runInfo(flags: InfoFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const config = readConfig(); const config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
@@ -39,20 +35,24 @@ export async function runInfo(flags: InfoFlags): Promise<void> {
}; };
if (flags.json) { if (flags.json) {
console.log(JSON.stringify(output, null, 2)); process.stdout.write(JSON.stringify(output, null, 2) + "\n");
return; return;
} }
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`)); render.section(`${mesh.slug} · ${mesh.brokerUrl}`);
console.log(dim(` mesh: ${mesh.meshId}`)); render.kv([
console.log(dim(` member: ${mesh.memberId}`)); ["mesh", mesh.meshId],
console.log(` peers: ${peers.length} connected`); ["member", mesh.memberId],
console.log(` state: ${state.length} keys`); ["peers", `${peers.length} connected`],
["state", `${state.length} keys`],
]);
if (brokerInfo && typeof brokerInfo === "object") { if (brokerInfo && typeof brokerInfo === "object") {
const extras: Array<[string, string]> = [];
for (const [k, v] of Object.entries(brokerInfo)) { for (const [k, v] of Object.entries(brokerInfo)) {
if (["slug", "meshId", "brokerUrl"].includes(k)) continue; if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
console.log(dim(` ${k}: ${JSON.stringify(v)}`)); extras.push([k, JSON.stringify(v)]);
} }
if (extras.length) render.kv(extras);
} }
}); });
} }

View File

@@ -9,9 +9,10 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
// In-memory counter (resets on deploy — good enough for a signal). // Persistent counts live in PostHog (event: install_script_fetched).
// For persistent tracking, write to DB or use PostHog server SDK. // Stdout logs are aggregated by the Coolify log collector. No in-memory
let installFetches = 0; // counter — it reset on every deploy and misled operators into thinking
// the signal was fresh.
const SCRIPT = `#!/usr/bin/env bash const SCRIPT = `#!/usr/bin/env bash
# claudemesh-cli installer # claudemesh-cli installer
@@ -140,19 +141,24 @@ say ""
`; `;
export async function GET(): Promise<Response> { export async function GET(): Promise<Response> {
installFetches++; // Log server-side for Coolify log aggregation.
// Log server-side for monitoring
const h = await headers(); const h = await headers();
const ua = h.get("user-agent") ?? "unknown"; const ua = h.get("user-agent") ?? "unknown";
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown"; const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
const referer = h.get("referer") ?? "direct"; const referer = h.get("referer") ?? "direct";
const ts = new Date().toISOString();
console.log( console.log(
`[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`, JSON.stringify({
level: "info",
event: "install_script_fetched",
ts,
ip,
ua: ua.slice(0, 120),
referer,
}),
); );
// PostHog server-side event (if configured) // PostHog server-side event — source of truth for counts.
try { try {
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
@@ -164,10 +170,10 @@ export async function GET(): Promise<Response> {
api_key: posthogKey, api_key: posthogKey,
event: "install_script_fetched", event: "install_script_fetched",
distinct_id: ip, distinct_id: ip,
timestamp: ts,
properties: { properties: {
user_agent: ua, user_agent: ua,
referer, referer,
install_count: installFetches,
}, },
}), }),
}).catch(() => {}); // fire-and-forget }).catch(() => {}); // fire-and-forget

View File

@@ -17,18 +17,42 @@ broker, ready for real teams.
--- ---
## In progress — *v0.1.x* ## v1.0.0-alpha — *shipping now*
Security and onboarding work landing inside the v0.1 line, before The ship-all push — Claude Code-grade CLI, zero-Node binary distribution,
v0.2.0 cuts. end-to-end crypto backup, per-peer capability grants, self-update.
- **v2 invite protocol** — short opaque codes (`claudemesh.com/i/{code}`) - **Single-binary distribution** — `curl -fsSL claudemesh.com/install | sh`
replace base64url URLs that embedded the mesh root key. The key is downloads the right binary (darwin/linux/windows × x64/arm64) when
now sealed to a recipient-controlled x25519 pubkey on claim, never in Node isn't present. GitHub Releases auto-publishes on each `cli-v*` tag.
a URL. v1 invites keep working through v0.1.x; removed at v0.2.0. - **`claudemesh://` URL scheme** — invite emails become one-click.
- **Email invites** — admins invite by email. A new `pending_invite` `claudemesh url-handler install` registers the scheme per-OS.
table tracks `{email, code, sentAt, acceptedAt, revokedAt}`; - **`claudemesh <url>`** — join + launch in one command. `-y` makes it
delivery goes through Postmark. fully non-interactive for CI.
- **Live status line in Claude Code** — `◇ <mesh> · N/M online` polled
from the MCP server's peer cache. Enable with
`claudemesh install --status-line`.
- **Per-peer capability grants** — `claudemesh grant/revoke/block/grants`.
Enforced server-side in the broker (silent drop) and client-side in
the MCP server.
- **Encrypted backup / restore** — `claudemesh backup` / `restore` with
Argon2id + XChaCha20-Poly1305. Portable `.cmb` recovery file.
- **Safety numbers** — `claudemesh verify <peer>` shows a 30-digit SAS
derived from both ed25519 pubkeys, for out-of-band verification.
- **Shell completions** — `claudemesh completions zsh|bash|fish`.
- **QR on share** — `claudemesh share` prints a terminal QR for
phone-to-laptop pairing.
- **Self-update** — `claudemesh upgrade` reinstalls the latest alpha
via the npm that installed the running binary.
- **Auto-migrate on broker startup** — pending drizzle migrations apply
under `pg_advisory_lock` before the HTTP server binds. Exits non-zero
on failure so Coolify fails the healthcheck closed.
- **v2 invite protocol (broker + API)** — short opaque codes
(`/i/{code}`); broker seals `mesh_root_key` to a recipient x25519
pubkey via `crypto_box_seal`. CLI migration tracked at
`.artifacts/specs/2026-04-15-invite-v2-cli-migration.md`.
- **Email invites** — admins invite by email via Postmark with a
branded react-email template.
--- ---

View File

@@ -0,0 +1,54 @@
# Homebrew formula template — lives in the `alezmad/homebrew-claudemesh` tap.
#
# The release-cli workflow bumps `version`, `url`, and `sha256` per platform
# via `brew bump-formula-pr`. This template is the source shape — copy it
# into the tap repo as `Formula/claudemesh.rb` when bootstrapping, then let
# CI keep it up to date.
class Claudemesh < Formula
desc "Peer mesh for Claude Code sessions"
homepage "https://claudemesh.com"
version "1.0.0-alpha.28"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-darwin-arm64"
sha256 "REPLACED_BY_CI"
else
url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-darwin-x64"
sha256 "REPLACED_BY_CI"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-linux-arm64"
sha256 "REPLACED_BY_CI"
else
url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-linux-x64"
sha256 "REPLACED_BY_CI"
end
end
def install
bin.install Dir["*"].first => "claudemesh"
end
def caveats
<<~EOS
To enable click-to-launch from invite emails:
claudemesh url-handler install
To show live peer count in Claude Code:
claudemesh install --status-line
Shell completions:
claudemesh completions zsh > "$(brew --prefix)/share/zsh/site-functions/_claudemesh"
EOS
end
test do
assert_match "claudemesh", shell_output("#{bin}/claudemesh --version")
end
end

View File

@@ -0,0 +1,38 @@
# Bootstrapping the `homebrew-claudemesh` tap
A Homebrew tap is just a GitHub repo named `homebrew-<anything>` in the
organization whose formulas you want to expose. Users add it with:
```
brew tap alezmad/claudemesh
brew install claudemesh
```
## One-time setup
1. Create a public GitHub repo called **`homebrew-claudemesh`** under the
`alezmad` account (or any organization Homebrew can resolve — the name
after `homebrew-` is the "tap" users type, and the owner is the namespace).
2. Copy `packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb` from THIS
repo into the tap's `Formula/claudemesh.rb`.
3. Fill in the `sha256` placeholders. For each platform, run:
```
curl -sL https://github.com/alezmad/claudemesh/releases/download/cli-v1.0.0/claudemesh-darwin-arm64 | sha256sum
```
(And so on for the three other platforms.)
4. Commit + push. Users can now `brew tap alezmad/claudemesh && brew install claudemesh`.
## Keeping it up to date
The release workflow (`.github/workflows/release-cli.yml`) has an
`update-homebrew` job that fires on non-prerelease tags. It calls
`brew bump-formula-pr` against the tap, which opens a PR with updated
`url`, `version`, and `sha256` entries.
Requires a `HOMEBREW_TAP_TOKEN` secret on the repo — a PAT scoped to the
tap repo with `contents:write`.
## Why not auto-create the repo from this workflow?
Repo creation needs org-level permissions that shouldn't live in CI. One
manual step, then everything after is automatic.